feat: reporter — HMAC-signed hourly POST with 24-record offline buffer

Fix Arduino String .size() → .length() in hmac.cpp (pre-existing bug surfaced by compilation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 06:28:24 -07:00
parent 6422e052df
commit 244426ec8b
3 changed files with 155 additions and 5 deletions

View File

@@ -15,8 +15,8 @@ static HString bytes_to_hex(const uint8_t* bytes, size_t len) {
} }
static void hex_to_bytes(const HString& hex, uint8_t* out, size_t out_len) { static void hex_to_bytes(const HString& hex, uint8_t* out, size_t out_len) {
if (hex.size() % 2 != 0) return; // malformed — odd-length hex if (hex.length() % 2 != 0) return; // malformed — odd-length hex
for (size_t i = 0; i < out_len && (i * 2 + 1) < hex.size(); i++) { for (size_t i = 0; i < out_len && (i * 2 + 1) < hex.length(); i++) {
char byte_str[3] = {hex[i*2], hex[i*2+1], 0}; char byte_str[3] = {hex[i*2], hex[i*2+1], 0};
out[i] = (uint8_t)strtol(byte_str, nullptr, 16); out[i] = (uint8_t)strtol(byte_str, nullptr, 16);
} }
@@ -39,7 +39,7 @@ HString hmac_sign(const HString& secret_hex, const HString& device_id,
uint32_t timestamp, const HString& body) { uint32_t timestamp, const HString& body) {
// 1. SHA256(body) // 1. SHA256(body)
uint8_t body_hash[32] = {}; uint8_t body_hash[32] = {};
if (!sha256((const uint8_t*)body.c_str(), body.size(), body_hash)) { if (!sha256((const uint8_t*)body.c_str(), body.length(), body_hash)) {
return HString{}; return HString{};
} }
HString body_hash_hex = bytes_to_hex(body_hash, 32); HString body_hash_hex = bytes_to_hex(body_hash, 32);
@@ -50,7 +50,7 @@ HString hmac_sign(const HString& secret_hex, const HString& device_id,
HString message = device_id + ":" + ts_buf + ":" + body_hash_hex; HString message = device_id + ":" + ts_buf + ":" + body_hash_hex;
// 3. Decode secret from hex // 3. Decode secret from hex
size_t secret_len = secret_hex.size() / 2; size_t secret_len = secret_hex.length() / 2;
uint8_t secret[64] = {}; uint8_t secret[64] = {};
hex_to_bytes(secret_hex, secret, secret_len); hex_to_bytes(secret_hex, secret, secret_len);
@@ -62,7 +62,7 @@ HString hmac_sign(const HString& secret_hex, const HString& device_id,
int ret2 = mbedtls_md_setup(&ctx, info, 1); int ret2 = mbedtls_md_setup(&ctx, info, 1);
if (ret2 != 0) { mbedtls_md_free(&ctx); return HString{}; } if (ret2 != 0) { mbedtls_md_free(&ctx); return HString{}; }
mbedtls_md_hmac_starts(&ctx, secret, secret_len); mbedtls_md_hmac_starts(&ctx, secret, secret_len);
mbedtls_md_hmac_update(&ctx, (const uint8_t*)message.c_str(), message.size()); mbedtls_md_hmac_update(&ctx, (const uint8_t*)message.c_str(), message.length());
mbedtls_md_hmac_finish(&ctx, hmac_result); mbedtls_md_hmac_finish(&ctx, hmac_result);
mbedtls_md_free(&ctx); mbedtls_md_free(&ctx);

130
firmware/src/reporter.cpp Normal file
View File

@@ -0,0 +1,130 @@
// firmware/src/reporter.cpp
#include "reporter.h"
#include "hmac.h"
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <vector>
#include <time.h>
static std::vector<CameraHourlyRecord> s_cam_buf;
static std::vector<BLEHourlyRecord> s_ble_buf;
// Returns current Unix timestamp (NTP-synced via configTime in main.cpp).
static uint32_t now_ts() {
return (uint32_t)time(nullptr);
}
static bool post_json(const DeviceConfig& cfg, const char* path, const String& body) {
uint32_t ts = now_ts();
String sig = hmac_sign(cfg.hmac_secret, cfg.device_id, ts, body);
if (sig.isEmpty()) return false; // HMAC failed
HTTPClient http;
String url = String(REPORTER_API_HOST) + path;
http.begin(url);
http.addHeader("Content-Type", "application/json");
http.addHeader("X-Device-Id", cfg.device_id);
http.addHeader("X-Timestamp", String(ts));
http.addHeader("X-HMAC-Signature", sig);
int code = http.POST(body);
http.end();
return (code == 200);
}
static String build_camera_batch(const DeviceConfig& cfg,
const std::vector<CameraHourlyRecord>& recs) {
JsonDocument doc;
doc["location_id"] = cfg.location_id;
JsonArray arr = doc["records"].to<JsonArray>();
for (const auto& r : recs) {
JsonObject o = arr.add<JsonObject>();
o["period_start"] = r.period_start;
o["period_end"] = r.period_end;
o["entries"] = r.entries;
o["exits"] = r.exits;
}
String out; serializeJson(doc, out);
return out;
}
static String build_ble_batch(const DeviceConfig& cfg,
const std::vector<BLEHourlyRecord>& recs) {
JsonDocument doc;
doc["location_id"] = cfg.location_id;
JsonArray arr = doc["records"].to<JsonArray>();
for (const auto& r : recs) {
JsonObject o = arr.add<JsonObject>();
o["period_start"] = r.period_start;
o["period_end"] = r.period_end;
o["unique_devices"] = r.unique_devices;
o["max_concurrent"] = r.max_concurrent;
o["near_count"] = r.near_count;
o["mid_count"] = r.mid_count;
o["far_count"] = r.far_count;
if (!r.device_hashes.empty()) {
JsonArray ha = o["device_hashes"].to<JsonArray>();
for (const auto& h : r.device_hashes) ha.add(h);
}
}
String out; serializeJson(doc, out);
return out;
}
static void buf_add_cam(const CameraHourlyRecord& r) {
if ((int)s_cam_buf.size() < REPORTER_MAX_BUFFER) s_cam_buf.push_back(r);
}
static void buf_add_ble(const BLEHourlyRecord& r) {
if ((int)s_ble_buf.size() < REPORTER_MAX_BUFFER) s_ble_buf.push_back(r);
}
void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& rec) {
if (WiFi.status() != WL_CONNECTED) { buf_add_cam(rec); return; }
std::vector<CameraHourlyRecord> batch;
batch.insert(batch.end(), s_cam_buf.begin(), s_cam_buf.end());
batch.push_back(rec);
s_cam_buf.clear();
String body = build_camera_batch(cfg, batch);
if (!post_json(cfg, "/api/v1/camera/events/batch", body)) {
for (const auto& r : batch) buf_add_cam(r);
}
}
void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec) {
if (WiFi.status() != WL_CONNECTED) { buf_add_ble(rec); return; }
std::vector<BLEHourlyRecord> batch;
batch.insert(batch.end(), s_ble_buf.begin(), s_ble_buf.end());
batch.push_back(rec);
s_ble_buf.clear();
String body = build_ble_batch(cfg, batch);
if (!post_json(cfg, "/api/v1/events/batch", body)) {
for (const auto& r : batch) buf_add_ble(r);
}
}
void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi) {
JsonDocument doc;
doc["firmware_version"] = "1.0.0";
doc["free_storage_pct"] = 100;
doc["wifi_rssi"] = wifi_rssi;
doc["pending_records"] = (int)(s_cam_buf.size() + s_ble_buf.size());
doc["uptime_seconds"] = uptime_s;
String body; serializeJson(doc, body);
post_json(cfg, "/api/v1/heartbeat", body);
}
void reporter_flush(const DeviceConfig& cfg) {
if (!s_cam_buf.empty()) {
String body = build_camera_batch(cfg, s_cam_buf);
if (post_json(cfg, "/api/v1/camera/events/batch", body)) s_cam_buf.clear();
}
if (!s_ble_buf.empty()) {
String body = build_ble_batch(cfg, s_ble_buf);
if (post_json(cfg, "/api/v1/events/batch", body)) s_ble_buf.clear();
}
}

20
firmware/src/reporter.h Normal file
View File

@@ -0,0 +1,20 @@
// firmware/src/reporter.h
#pragma once
#include <Arduino.h>
#include "config.h"
#include "ble_scanner.h"
struct CameraHourlyRecord {
uint32_t period_start;
uint32_t period_end;
int entries;
int exits;
};
static const int REPORTER_MAX_BUFFER = 24;
static const char* REPORTER_API_HOST = "https://logs.research.bike";
void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& rec);
void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec);
void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi);
void reporter_flush(const DeviceConfig& cfg);