Ajout Android CLI

This commit is contained in:
2026-09-25 15:16:08 +02:00
parent 7a49e73ed1
commit 1faace10d2
7 changed files with 186 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export IOT_USER_TOKEN=demo-user-alice
export IOT_BACKEND_URL=http://127.0.0.1:5000
+16
View File
@@ -0,0 +1,16 @@
plugins {
kotlin("jvm") version "2.4.20"
application
}
repositories {
mavenCentral()
}
application {
mainClass.set("iotmonitor.MainKt")
}
kotlin {
jvmToolchain(17)
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
rootProject.name = "iot-monitor-android"
@@ -0,0 +1,80 @@
package iotmonitor
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
/**
* Logique réseau minimale. Réutilisable telle quelle dans une MainActivity.
*
* Règles de sécurité (modules 03/04) :
* - le secret de device passe par en-tête, jamais loggé ;
* - on n'imprime que l'identité et le résultat ;
* - les erreurs sont remontées sans détail d'implémentation.
*/
class BackendClient(
private val baseUrl: String,
private val http: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build(),
) {
/** Envoie une mesure en tant que device. Retourne le code de statut HTTP. */
fun ingest(deviceId: String, deviceSecret: String, measure: Measure): Int {
val body = buildIngestBody(measure)
val request = HttpRequest.newBuilder()
.uri(URI.create("$baseUrl/api/ingest"))
.header("Content-Type", "application/json")
.header("X-Device-ID", deviceId)
.header("X-Device-Secret", deviceSecret)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build()
val response = http.send(request, HttpResponse.BodyHandlers.ofString())
// Le secret n'est jamais imprimé.
println("[ingest] device=$deviceId -> HTTP ${response.statusCode()}")
return response.statusCode()
}
/**
* Liste les mesures d'un device en tant qu'utilisateur.
* L'autorisation est décidée par le backend (module 03) : 403 => refus.
*/
fun listMeasures(userToken: String, deviceId: String): List<Measure> {
val request = HttpRequest.newBuilder()
.uri(URI.create("$baseUrl/api/measures?device_id=$deviceId"))
.header("Authorization", "Bearer $userToken")
.GET()
.build()
val response = http.send(request, HttpResponse.BodyHandlers.ofString())
println("[measures] device=$deviceId -> HTTP ${response.statusCode()}")
if (response.statusCode() != 200) {
return emptyList()
}
return parseMeasures(response.body())
}
private fun buildIngestBody(measure: Measure): String =
"""{"device_id":"${measure.device_id}","timestamp":"${measure.timestamp}", "light_value":${measure.light_value}}"""
private fun parseMeasures(body: String): List<Measure> {
// Parse très simple, sans dépendance JSON : on extrait chaque objet
// entre accolades (ordre des champs indifférent), puis les valeurs
// par clé. Suffisant pour le format minimal du backend.
val objectRegex = Regex("""\{[^{}]*\}""")
val fieldRegex = Regex(""""([a-z_]+)":"?([^",\}]+)"?""")
return objectRegex.findAll(body).mapNotNull { objMatch ->
val fields = fieldRegex.findAll(objMatch.value)
.associate { m -> m.groupValues[1] to m.groupValues[2] }
val deviceId = fields["device_id"] ?: return@mapNotNull null
val timestamp = fields["timestamp"] ?: return@mapNotNull null
val light = fields["light_value"]?.toLongOrNull() ?: return@mapNotNull null
Measure(device_id = deviceId, timestamp = timestamp, light_value = light)
}.toList()
}
}
@@ -0,0 +1,76 @@
package iotmonitor
import java.time.Instant
/**
* CLI minimale : `ingest` ou `measures`.
*
* Aucun secret en dur : tout vient de l'environnement (ou renvoie une
* erreur lisible). L'état « connecté » est dérivé du code de statut HTTP.
*/
fun main(args: Array<String>) {
val command = args.firstOrNull()
?: run {
println("usage: ingest | measures --device <id>")
return
}
val baseUrl = System.getenv("IOT_BACKEND_URL")
?: run {
println("erreur : IOT_BACKEND_URL manquant (ex. http://127.0.0.1:5000)")
return
}
val client = BackendClient(baseUrl)
when (command) {
"ingest" -> runIngest(client)
"measures" -> runMeasures(client, args)
else -> println("commande inconnue : $command")
}
}
private fun runIngest(client: BackendClient) {
val deviceId = System.getenv("IOT_DEVICE_ID")
?: run {
println("erreur : IOT_DEVICE_ID manquant")
return
}
val secret = System.getenv("IOT_DEVICE_SECRET")
?: run {
println("erreur : IOT_DEVICE_SECRET manquant (ne jamais mettre en dur)")
return
}
val measure = Measure(
device_id = deviceId,
timestamp = Instant.now().toString(),
light_value = (0..1023).random().toLong(), // valeur brute simulée
)
val status = client.ingest(deviceId, secret, measure)
println("état : ${if (status in 200..299) "connecté (accepté)" else "refusé (HTTP $status)"}")
}
private fun runMeasures(client: BackendClient, args: Array<String>) {
val deviceId = args.getOrNull(args.indexOf("--device") + 1)
?: run {
println("usage: measures --device <id>")
return
}
val token = System.getenv("IOT_USER_TOKEN")
?: run {
println("erreur : IOT_USER_TOKEN manquant")
return
}
val measures = client.listMeasures(token, deviceId)
if (measures.isEmpty()) {
println("état : aucune mesure retournée ou accès refusé")
} else {
println("état : connecté — ${measures.size} mesure(s)")
measures.forEach { m ->
println(" device=${m.device_id} time=${m.timestamp} value=${m.light_value}")
}
}
}
@@ -0,0 +1,11 @@
package iotmonitor
/**
* Modèle de données minimal (seulement les champs exposés par l'API,
* pas de sur-exposition — module 04).
*/
data class Measure(
val device_id: String,
val timestamp: String,
val light_value: Long,
)