feat: door counter firmware — camera CV, BLE, HMAC reporting, captive portal
This commit is contained in:
168
firmware/lib/cv/cv.cpp
Normal file
168
firmware/lib/cv/cv.cpp
Normal file
@@ -0,0 +1,168 @@
|
||||
// firmware/lib/cv/cv.cpp
|
||||
#include "cv.h"
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
void cv_init(CVState& state) {
|
||||
state = CVState{}; // value-initialize — calls vector default ctor correctly
|
||||
state.next_id = 1;
|
||||
}
|
||||
|
||||
void cv_reset_counts(CVState& state) {
|
||||
state.entries = 0;
|
||||
state.exits = 0;
|
||||
}
|
||||
|
||||
struct Point { int x, y; };
|
||||
|
||||
// Note: queue may grow to CV_PIXELS entries (~72KB) on large blobs.
|
||||
// Requires PSRAM (enabled via -DBOARD_HAS_PSRAM in platformio.ini).
|
||||
// BFS flood fill. Marks visited pixels (sets fg to 0). Returns {-1,-1} if blob < CV_MIN_BLOB_PX.
|
||||
static std::pair<float,float> extract_blob(uint8_t* fg, int start_x, int start_y) {
|
||||
std::vector<Point> queue;
|
||||
queue.reserve(512);
|
||||
queue.push_back({start_x, start_y});
|
||||
fg[start_y * CV_W + start_x] = 0;
|
||||
|
||||
float sum_x = 0, sum_y = 0;
|
||||
int count = 0;
|
||||
|
||||
while (!queue.empty()) {
|
||||
Point p = queue.back(); queue.pop_back();
|
||||
sum_x += p.x; sum_y += p.y; count++;
|
||||
|
||||
const int dx[] = {-1, 1, 0, 0};
|
||||
const int dy[] = {0, 0, -1, 1};
|
||||
for (int d = 0; d < 4; d++) {
|
||||
int nx = p.x + dx[d], ny = p.y + dy[d];
|
||||
if (nx < 0 || nx >= CV_W || ny < 0 || ny >= CV_H) continue;
|
||||
int ni = ny * CV_W + nx;
|
||||
if (!fg[ni]) continue;
|
||||
fg[ni] = 0;
|
||||
queue.push_back({nx, ny});
|
||||
}
|
||||
}
|
||||
|
||||
if (count < CV_MIN_BLOB_PX) return {-1.0f, -1.0f};
|
||||
return {sum_x / count, sum_y / count};
|
||||
}
|
||||
|
||||
static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg) {
|
||||
std::vector<std::pair<float,float>> result;
|
||||
static uint8_t fg_copy[CV_PIXELS]; // static to avoid 9KB stack allocation
|
||||
memcpy(fg_copy, fg, CV_PIXELS);
|
||||
|
||||
for (int y = 0; y < CV_H; y++) {
|
||||
for (int x = 0; x < CV_W; x++) {
|
||||
if (!fg_copy[y * CV_W + x]) continue;
|
||||
auto c = extract_blob(fg_copy, x, y);
|
||||
if (c.first >= 0) result.push_back(c);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void frame_diff(const uint8_t* frame, const uint8_t* bg,
|
||||
uint8_t* fg, int pixels) {
|
||||
for (int i = 0; i < pixels; i++) {
|
||||
int diff = (int)frame[i] - (int)bg[i];
|
||||
if (diff < 0) diff = -diff;
|
||||
fg[i] = (diff > CV_DIFF_THRESH) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
|
||||
CVResult result = {0, 0};
|
||||
state.frame_index++;
|
||||
|
||||
if (!state.bg_valid) {
|
||||
memcpy(state.background, frame, CV_PIXELS);
|
||||
state.bg_valid = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
uint8_t fg[CV_PIXELS];
|
||||
frame_diff(frame, state.background, fg, CV_PIXELS);
|
||||
|
||||
int fg_count = 0;
|
||||
for (int i = 0; i < CV_PIXELS; i++) fg_count += fg[i];
|
||||
|
||||
bool motion = fg_count > CV_MIN_BLOB_PX;
|
||||
if (!motion) {
|
||||
if (state.frame_index - state.last_motion_frame > 10) {
|
||||
memcpy(state.background, frame, CV_PIXELS);
|
||||
}
|
||||
for (auto& t : state.tracks) t.missed++;
|
||||
state.tracks.erase(
|
||||
std::remove_if(state.tracks.begin(), state.tracks.end(),
|
||||
[](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }),
|
||||
state.tracks.end());
|
||||
return result;
|
||||
}
|
||||
|
||||
state.last_motion_frame = state.frame_index;
|
||||
|
||||
auto centroids = find_centroids(fg);
|
||||
|
||||
std::vector<bool> centroid_matched(centroids.size(), false);
|
||||
|
||||
for (auto& track : state.tracks) {
|
||||
float best_dist = CV_MAX_MOVE * CV_MAX_MOVE;
|
||||
int best_idx = -1;
|
||||
|
||||
for (int i = 0; i < (int)centroids.size(); i++) {
|
||||
if (centroid_matched[i]) continue;
|
||||
float dx = centroids[i].first - track.x;
|
||||
float dy = centroids[i].second - track.y;
|
||||
float d2 = dx*dx + dy*dy;
|
||||
if (d2 < best_dist) { best_dist = d2; best_idx = i; }
|
||||
}
|
||||
|
||||
if (best_idx >= 0) {
|
||||
centroid_matched[best_idx] = true;
|
||||
track.x = centroids[best_idx].first;
|
||||
track.y = centroids[best_idx].second;
|
||||
track.missed = 0;
|
||||
} else {
|
||||
track.missed++;
|
||||
}
|
||||
}
|
||||
|
||||
state.tracks.erase(
|
||||
std::remove_if(state.tracks.begin(), state.tracks.end(),
|
||||
[](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }),
|
||||
state.tracks.end());
|
||||
|
||||
float line_y = (line_pct / 100.0f) * CV_H;
|
||||
for (int i = 0; i < (int)centroids.size(); i++) {
|
||||
if (centroid_matched[i]) continue;
|
||||
CVTrack t;
|
||||
t.id = state.next_id++;
|
||||
t.x = centroids[i].first;
|
||||
t.y = centroids[i].second;
|
||||
t.above_line = (t.y < line_y);
|
||||
t.missed = 0;
|
||||
state.tracks.push_back(t);
|
||||
}
|
||||
// Line crossing check
|
||||
for (auto& track : state.tracks) {
|
||||
if (track.missed > 0) continue; // only check tracks matched this frame
|
||||
bool now_above = (track.y < line_y);
|
||||
if (now_above != track.above_line) {
|
||||
if (!now_above) {
|
||||
// was above, now below → entry
|
||||
state.entries++;
|
||||
result.entries_delta++;
|
||||
} else {
|
||||
// was below, now above → exit
|
||||
state.exits++;
|
||||
result.exits_delta++;
|
||||
}
|
||||
}
|
||||
track.above_line = now_above;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
40
firmware/lib/cv/cv.h
Normal file
40
firmware/lib/cv/cv.h
Normal file
@@ -0,0 +1,40 @@
|
||||
// firmware/lib/cv/cv.h
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <vector>
|
||||
|
||||
static const int CV_W = 96;
|
||||
static const int CV_H = 96;
|
||||
static const int CV_PIXELS = CV_W * CV_H;
|
||||
|
||||
static const uint8_t CV_DIFF_THRESH = 30;
|
||||
static const int CV_MIN_BLOB_PX = 64;
|
||||
static const float CV_MAX_MOVE = 15.0f;
|
||||
static const int CV_MAX_MISSED = 10;
|
||||
|
||||
struct CVTrack {
|
||||
int id;
|
||||
float x, y;
|
||||
bool above_line;
|
||||
int missed;
|
||||
};
|
||||
|
||||
struct CVState {
|
||||
uint8_t background[CV_PIXELS];
|
||||
bool bg_valid;
|
||||
uint32_t last_motion_frame;
|
||||
uint32_t frame_index;
|
||||
int next_id;
|
||||
std::vector<CVTrack> tracks;
|
||||
int entries;
|
||||
int exits;
|
||||
};
|
||||
|
||||
struct CVResult {
|
||||
int entries_delta;
|
||||
int exits_delta;
|
||||
};
|
||||
|
||||
void cv_init(CVState& state);
|
||||
CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct);
|
||||
void cv_reset_counts(CVState& state);
|
||||
70
firmware/lib/hmac/hmac.cpp
Normal file
70
firmware/lib/hmac/hmac.cpp
Normal file
@@ -0,0 +1,70 @@
|
||||
// firmware/src/hmac.cpp
|
||||
#include "hmac.h"
|
||||
#include "mbedtls/md.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static HString bytes_to_hex(const uint8_t* bytes, size_t len) {
|
||||
HString out;
|
||||
char buf[3];
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
snprintf(buf, sizeof(buf), "%02x", bytes[i]);
|
||||
out += buf;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static void hex_to_bytes(const HString& hex, uint8_t* out, size_t out_len) {
|
||||
if (hex.length() % 2 != 0) return; // malformed — odd-length hex
|
||||
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};
|
||||
out[i] = (uint8_t)strtol(byte_str, nullptr, 16);
|
||||
}
|
||||
}
|
||||
|
||||
static bool sha256(const uint8_t* data, size_t len, uint8_t out[32]) {
|
||||
mbedtls_md_context_t ctx;
|
||||
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
mbedtls_md_init(&ctx);
|
||||
int ret = mbedtls_md_setup(&ctx, info, 0);
|
||||
if (ret != 0) { mbedtls_md_free(&ctx); return false; }
|
||||
mbedtls_md_starts(&ctx);
|
||||
mbedtls_md_update(&ctx, data, len);
|
||||
mbedtls_md_finish(&ctx, out);
|
||||
mbedtls_md_free(&ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
HString hmac_sign(const HString& secret_hex, const HString& device_id,
|
||||
uint32_t timestamp, const HString& body) {
|
||||
// 1. SHA256(body)
|
||||
uint8_t body_hash[32] = {};
|
||||
if (!sha256((const uint8_t*)body.c_str(), body.length(), body_hash)) {
|
||||
return HString{};
|
||||
}
|
||||
HString body_hash_hex = bytes_to_hex(body_hash, 32);
|
||||
|
||||
// 2. Build message
|
||||
char ts_buf[12];
|
||||
snprintf(ts_buf, sizeof(ts_buf), "%u", (unsigned)timestamp);
|
||||
HString message = device_id + ":" + ts_buf + ":" + body_hash_hex;
|
||||
|
||||
// 3. Decode secret from hex
|
||||
size_t secret_len = secret_hex.length() / 2;
|
||||
uint8_t secret[64] = {};
|
||||
hex_to_bytes(secret_hex, secret, secret_len);
|
||||
|
||||
// 4. HMAC-SHA256(secret, message)
|
||||
uint8_t hmac_result[32];
|
||||
mbedtls_md_context_t ctx;
|
||||
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
mbedtls_md_init(&ctx);
|
||||
int ret2 = mbedtls_md_setup(&ctx, info, 1);
|
||||
if (ret2 != 0) { mbedtls_md_free(&ctx); return HString{}; }
|
||||
mbedtls_md_hmac_starts(&ctx, secret, secret_len);
|
||||
mbedtls_md_hmac_update(&ctx, (const uint8_t*)message.c_str(), message.length());
|
||||
mbedtls_md_hmac_finish(&ctx, hmac_result);
|
||||
mbedtls_md_free(&ctx);
|
||||
|
||||
return bytes_to_hex(hmac_result, 32);
|
||||
}
|
||||
16
firmware/lib/hmac/hmac.h
Normal file
16
firmware/lib/hmac/hmac.h
Normal file
@@ -0,0 +1,16 @@
|
||||
// firmware/src/hmac.h
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef NATIVE_TEST
|
||||
#include <string>
|
||||
using HString = std::string;
|
||||
#else
|
||||
#include <Arduino.h>
|
||||
using HString = String;
|
||||
#endif
|
||||
|
||||
// Returns lowercase hex-encoded HMAC-SHA256 signature.
|
||||
// Message signed: device_id + ":" + timestamp_str + ":" + hex(sha256(body))
|
||||
HString hmac_sign(const HString& secret_hex, const HString& device_id,
|
||||
uint32_t timestamp, const HString& body);
|
||||
6
firmware/partitions_8mb_ota.csv
Normal file
6
firmware/partitions_8mb_ota.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
# Name, Type, SubType, Offset, Size
|
||||
nvs, data, nvs, 0x9000, 0x5000
|
||||
otadata, data, ota, 0xe000, 0x2000
|
||||
app0, app, ota_0, 0x10000, 0x300000
|
||||
app1, app, ota_1, 0x310000, 0x300000
|
||||
spiffs, data, spiffs, 0x610000, 0x1F0000
|
||||
|
31
firmware/platformio.ini
Normal file
31
firmware/platformio.ini
Normal file
@@ -0,0 +1,31 @@
|
||||
; firmware/platformio.ini
|
||||
[platformio]
|
||||
default_envs = timercam
|
||||
|
||||
[env:timercam]
|
||||
platform = espressif32@6.6.0
|
||||
board = m5stack-timer-cam
|
||||
framework = arduino
|
||||
board_build.partitions = partitions_8mb_ota.csv
|
||||
build_flags =
|
||||
-DBOARD_HAS_PSRAM
|
||||
-mfix-esp32-psram-cache-issue
|
||||
-DCORE_DEBUG_LEVEL=3
|
||||
-DCONFIG_BT_NIMBLE_ENABLED=1
|
||||
-DCONFIG_SPIRAM_USE_MALLOC=1
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
lib_deps =
|
||||
tzapu/WiFiManager@^2.0.17
|
||||
bblanchon/ArduinoJson@^7.0.0
|
||||
h2zero/NimBLE-Arduino@^1.4.2
|
||||
espressif/esp32-camera
|
||||
|
||||
[env:native]
|
||||
platform = native
|
||||
test_framework = unity
|
||||
build_flags =
|
||||
-std=c++17
|
||||
-DNATIVE_TEST
|
||||
lib_deps =
|
||||
kochcodes/mbedtls@^3.6.2
|
||||
0
firmware/src/.gitkeep
Normal file
0
firmware/src/.gitkeep
Normal file
108
firmware/src/ble_scanner.cpp
Normal file
108
firmware/src/ble_scanner.cpp
Normal file
@@ -0,0 +1,108 @@
|
||||
// firmware/src/ble_scanner.cpp
|
||||
#include "ble_scanner.h"
|
||||
#include <NimBLEDevice.h>
|
||||
#include <NimBLEScan.h>
|
||||
#include <NimBLEAdvertisedDevice.h>
|
||||
#include "mbedtls/md.h"
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
|
||||
#define RSSI_NEAR -65
|
||||
#define RSSI_MID -80
|
||||
|
||||
static std::mutex s_mutex;
|
||||
|
||||
struct DeviceObs {
|
||||
int rssi_sum;
|
||||
int count;
|
||||
};
|
||||
|
||||
static std::map<String, DeviceObs> s_seen;
|
||||
static int s_max_concurrent = 0;
|
||||
|
||||
static String sha256_prefix(const String& input) {
|
||||
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
if (!info) return String(); // SHA256 not available
|
||||
|
||||
uint8_t hash[32] = {};
|
||||
mbedtls_md_context_t ctx;
|
||||
mbedtls_md_init(&ctx);
|
||||
if (mbedtls_md_setup(&ctx, info, 0) != 0) {
|
||||
mbedtls_md_free(&ctx);
|
||||
return String();
|
||||
}
|
||||
mbedtls_md_starts(&ctx);
|
||||
mbedtls_md_update(&ctx, (const uint8_t*)input.c_str(), input.length());
|
||||
mbedtls_md_finish(&ctx, hash);
|
||||
mbedtls_md_free(&ctx);
|
||||
|
||||
String hex = "";
|
||||
char buf[3];
|
||||
for (int i = 0; i < 16; i++) { snprintf(buf, 3, "%02x", hash[i]); hex += buf; }
|
||||
return hex;
|
||||
}
|
||||
|
||||
class ScanCallback : public NimBLEAdvertisedDeviceCallbacks {
|
||||
void onResult(NimBLEAdvertisedDevice* dev) override {
|
||||
String mac = String(dev->getAddress().toString().c_str());
|
||||
String hash = sha256_prefix(mac);
|
||||
int rssi = dev->getRSSI();
|
||||
|
||||
std::lock_guard<std::mutex> lock(s_mutex);
|
||||
auto it = s_seen.find(hash);
|
||||
if (it == s_seen.end()) {
|
||||
s_seen[hash] = {rssi, 1};
|
||||
} else {
|
||||
it->second.rssi_sum += rssi;
|
||||
it->second.count++;
|
||||
}
|
||||
int concurrent = (int)s_seen.size();
|
||||
if (concurrent > s_max_concurrent) s_max_concurrent = concurrent;
|
||||
}
|
||||
};
|
||||
|
||||
static ScanCallback s_callback;
|
||||
static NimBLEScan* s_scan = nullptr;
|
||||
|
||||
void ble_scanner_start() {
|
||||
NimBLEDevice::init("");
|
||||
s_scan = NimBLEDevice::getScan();
|
||||
s_scan->setAdvertisedDeviceCallbacks(&s_callback, true); // true = allow duplicates
|
||||
s_scan->setActiveScan(false); // passive
|
||||
s_scan->setInterval(100);
|
||||
s_scan->setWindow(99);
|
||||
s_scan->setMaxResults(0); // don't store results — callback-only
|
||||
s_scan->start(0, nullptr, false); // 0 = continuous
|
||||
}
|
||||
|
||||
void ble_scanner_pause() { if (s_scan) s_scan->stop(); }
|
||||
void ble_scanner_resume() { if (s_scan) s_scan->start(0, nullptr, false); }
|
||||
|
||||
BLEHourlyRecord ble_scanner_collect(uint32_t period_start, uint32_t period_end) {
|
||||
// Swap accumulators under lock — minimise time with lock held
|
||||
std::map<String, DeviceObs> local_seen;
|
||||
int local_max = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_mutex);
|
||||
std::swap(local_seen, s_seen);
|
||||
local_max = s_max_concurrent;
|
||||
s_max_concurrent = 0;
|
||||
}
|
||||
|
||||
// Process outside the lock — heap allocation safe here
|
||||
BLEHourlyRecord rec;
|
||||
rec.period_start = period_start;
|
||||
rec.period_end = period_end;
|
||||
rec.unique_devices = (int)local_seen.size();
|
||||
rec.max_concurrent = local_max;
|
||||
rec.near_count = 0; rec.mid_count = 0; rec.far_count = 0;
|
||||
|
||||
for (auto& kv : local_seen) {
|
||||
float avg_rssi = kv.second.rssi_sum / (float)kv.second.count;
|
||||
if (avg_rssi > RSSI_NEAR) rec.near_count++;
|
||||
else if (avg_rssi > RSSI_MID) rec.mid_count++;
|
||||
else rec.far_count++;
|
||||
rec.device_hashes.push_back(kv.first);
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
25
firmware/src/ble_scanner.h
Normal file
25
firmware/src/ble_scanner.h
Normal file
@@ -0,0 +1,25 @@
|
||||
// firmware/src/ble_scanner.h
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <vector>
|
||||
|
||||
struct BLEHourlyRecord {
|
||||
uint32_t period_start;
|
||||
uint32_t period_end;
|
||||
int unique_devices;
|
||||
int max_concurrent;
|
||||
int near_count; // RSSI > -65 dBm
|
||||
int mid_count; // RSSI -65 to -80 dBm
|
||||
int far_count; // RSSI < -80 dBm
|
||||
std::vector<String> device_hashes; // SHA256(MAC) first 16 hex chars
|
||||
};
|
||||
|
||||
// Start continuous passive BLE scan (call once at boot).
|
||||
void ble_scanner_start();
|
||||
|
||||
// Pause scan for ~3s during HTTP upload.
|
||||
void ble_scanner_pause();
|
||||
void ble_scanner_resume();
|
||||
|
||||
// Collect current hour's record and reset accumulators. Thread-safe.
|
||||
BLEHourlyRecord ble_scanner_collect(uint32_t period_start, uint32_t period_end);
|
||||
92
firmware/src/camera.cpp
Normal file
92
firmware/src/camera.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
// firmware/src/camera.cpp
|
||||
// OV3660 pin assignments for M5Stack TimerCamera-F
|
||||
// Ref: https://docs.m5stack.com/en/unit/timercam_f
|
||||
#include "camera.h"
|
||||
#include "cv.h"
|
||||
#include "esp_camera.h"
|
||||
#include <string.h>
|
||||
|
||||
#define CAM_PIN_PWDN -1
|
||||
#define CAM_PIN_RESET 15
|
||||
#define CAM_PIN_XCLK 27
|
||||
#define CAM_PIN_SIOD 25
|
||||
#define CAM_PIN_SIOC 23
|
||||
#define CAM_PIN_D7 19
|
||||
#define CAM_PIN_D6 36
|
||||
#define CAM_PIN_D5 18
|
||||
#define CAM_PIN_D4 39
|
||||
#define CAM_PIN_D3 5
|
||||
#define CAM_PIN_D2 34
|
||||
#define CAM_PIN_D1 35
|
||||
#define CAM_PIN_D0 32
|
||||
#define CAM_PIN_VSYNC 22
|
||||
#define CAM_PIN_HREF 26
|
||||
#define CAM_PIN_PCLK 21
|
||||
|
||||
bool camera_init() {
|
||||
camera_config_t cfg = {};
|
||||
cfg.fb_location = CAMERA_FB_IN_PSRAM;
|
||||
cfg.ledc_channel = LEDC_CHANNEL_0;
|
||||
cfg.ledc_timer = LEDC_TIMER_0;
|
||||
cfg.pin_d0 = CAM_PIN_D0;
|
||||
cfg.pin_d1 = CAM_PIN_D1;
|
||||
cfg.pin_d2 = CAM_PIN_D2;
|
||||
cfg.pin_d3 = CAM_PIN_D3;
|
||||
cfg.pin_d4 = CAM_PIN_D4;
|
||||
cfg.pin_d5 = CAM_PIN_D5;
|
||||
cfg.pin_d6 = CAM_PIN_D6;
|
||||
cfg.pin_d7 = CAM_PIN_D7;
|
||||
cfg.pin_xclk = CAM_PIN_XCLK;
|
||||
cfg.pin_pclk = CAM_PIN_PCLK;
|
||||
cfg.pin_vsync = CAM_PIN_VSYNC;
|
||||
cfg.pin_href = CAM_PIN_HREF;
|
||||
cfg.pin_sscb_sda = CAM_PIN_SIOD;
|
||||
cfg.pin_sscb_scl = CAM_PIN_SIOC;
|
||||
cfg.pin_pwdn = CAM_PIN_PWDN;
|
||||
cfg.pin_reset = CAM_PIN_RESET;
|
||||
cfg.xclk_freq_hz = 20000000;
|
||||
cfg.pixel_format = PIXFORMAT_GRAYSCALE;
|
||||
cfg.frame_size = FRAMESIZE_QVGA; // 320x240
|
||||
cfg.fb_count = 1;
|
||||
cfg.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
|
||||
|
||||
esp_err_t err = esp_camera_init(&cfg);
|
||||
if (err != ESP_OK) return false;
|
||||
|
||||
// Flip vertically — adjust if mounting orientation differs
|
||||
sensor_t* s = esp_camera_sensor_get();
|
||||
if (s) {
|
||||
s->set_vflip(s, 1);
|
||||
s->set_hmirror(s, 0);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Box-filter downscale to CV_W x CV_H.
|
||||
// Input region: center-cropped to (CV_W*bx) x (CV_H*by) before downscaling.
|
||||
// For QVGA (320x240) → 96x96: bx=3, by=2, crops to 288x192, offset x=16 y=24.
|
||||
static void downscale(const uint8_t* src, int src_w, int src_h, uint8_t* dst) {
|
||||
int bx = src_w / CV_W; // 3 for QVGA
|
||||
int by = src_h / CV_H; // 2 for QVGA
|
||||
// Center the crop region
|
||||
int x_off = (src_w - CV_W * bx) / 2; // 16 for QVGA
|
||||
int y_off = (src_h - CV_H * by) / 2; // 24 for QVGA
|
||||
for (int dy = 0; dy < CV_H; dy++) {
|
||||
for (int dx = 0; dx < CV_W; dx++) {
|
||||
int sum = 0;
|
||||
for (int ky = 0; ky < by; ky++)
|
||||
for (int kx = 0; kx < bx; kx++)
|
||||
sum += src[(y_off + dy*by + ky)*src_w + (x_off + dx*bx + kx)];
|
||||
dst[dy*CV_W + dx] = (uint8_t)(sum / (bx * by));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool camera_capture_96(uint8_t* buf) {
|
||||
camera_fb_t* fb = esp_camera_fb_get();
|
||||
if (!fb) return false;
|
||||
downscale(fb->buf, fb->width, fb->height, buf);
|
||||
esp_camera_fb_return(fb);
|
||||
return true;
|
||||
}
|
||||
11
firmware/src/camera.h
Normal file
11
firmware/src/camera.h
Normal file
@@ -0,0 +1,11 @@
|
||||
// firmware/src/camera.h
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Initialise OV3660 camera for TimerCamera-F. Returns false on failure.
|
||||
bool camera_init();
|
||||
|
||||
// Capture one frame, downscale to 96x96 grayscale, write into buf.
|
||||
// buf must be CV_PIXELS (9216) bytes. Returns false on capture failure.
|
||||
bool camera_capture_96(uint8_t* buf);
|
||||
48
firmware/src/config.cpp
Normal file
48
firmware/src/config.cpp
Normal file
@@ -0,0 +1,48 @@
|
||||
// firmware/src/config.cpp
|
||||
#include "config.h"
|
||||
#include <Preferences.h>
|
||||
|
||||
static const char* NS = "doorcounter";
|
||||
|
||||
bool config_load(DeviceConfig& cfg) {
|
||||
Preferences prefs;
|
||||
prefs.begin(NS, true); // read-only
|
||||
|
||||
cfg.device_id = prefs.getString("device_id", "");
|
||||
cfg.location_id = prefs.getString("location_id", "");
|
||||
cfg.hmac_secret = prefs.getString("hmac_secret", "");
|
||||
cfg.wifi_ssid = prefs.getString("wifi_ssid", "");
|
||||
cfg.wifi_pass = prefs.getString("wifi_pass", "");
|
||||
cfg.line_offset = (uint8_t)prefs.getUInt("line_offset", 50);
|
||||
|
||||
prefs.end();
|
||||
|
||||
return !cfg.device_id.isEmpty() &&
|
||||
!cfg.location_id.isEmpty() &&
|
||||
!cfg.hmac_secret.isEmpty();
|
||||
}
|
||||
|
||||
bool config_save_wifi(const String& ssid, const String& pass) {
|
||||
Preferences prefs;
|
||||
prefs.begin(NS, false);
|
||||
size_t r1 = prefs.putString("wifi_ssid", ssid);
|
||||
size_t r2 = prefs.putString("wifi_pass", pass);
|
||||
prefs.end();
|
||||
return (r1 > 0) && (r2 > 0);
|
||||
}
|
||||
|
||||
bool config_has_wifi() {
|
||||
Preferences prefs;
|
||||
prefs.begin(NS, true);
|
||||
String ssid = prefs.getString("wifi_ssid", "");
|
||||
prefs.end();
|
||||
return !ssid.isEmpty();
|
||||
}
|
||||
|
||||
void config_clear_wifi() {
|
||||
Preferences prefs;
|
||||
prefs.begin(NS, false);
|
||||
prefs.remove("wifi_ssid");
|
||||
prefs.remove("wifi_pass");
|
||||
prefs.end();
|
||||
}
|
||||
24
firmware/src/config.h
Normal file
24
firmware/src/config.h
Normal file
@@ -0,0 +1,24 @@
|
||||
// firmware/src/config.h
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
struct DeviceConfig {
|
||||
String device_id; // e.g. "dc-0042"
|
||||
String location_id; // e.g. "retailer-123"
|
||||
String hmac_secret; // 32-byte hex string
|
||||
String wifi_ssid;
|
||||
String wifi_pass;
|
||||
uint8_t line_offset; // 0-100, percent of frame height for virtual line
|
||||
};
|
||||
|
||||
// Load all config from NVS. Returns false if device_id/location_id/hmac_secret missing.
|
||||
bool config_load(DeviceConfig& cfg);
|
||||
|
||||
// Save WiFi credentials to NVS (called by provisioning after captive portal).
|
||||
bool config_save_wifi(const String& ssid, const String& pass);
|
||||
|
||||
// Returns true if wifi_ssid is set in NVS.
|
||||
bool config_has_wifi();
|
||||
|
||||
// Erase WiFi credentials only (factory reset — preserves device_id etc).
|
||||
void config_clear_wifi();
|
||||
174
firmware/src/main.cpp
Normal file
174
firmware/src/main.cpp
Normal file
@@ -0,0 +1,174 @@
|
||||
// firmware/src/main.cpp
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <ArduinoOTA.h>
|
||||
#include "config.h"
|
||||
#include "provisioning.h"
|
||||
#include "camera.h"
|
||||
#include "cv.h"
|
||||
#include "ble_scanner.h"
|
||||
#include "reporter.h"
|
||||
|
||||
// LED on GPIO2 (TimerCamera-F built-in LED) — verify against board schematic
|
||||
// Factory reset: hold GPIO37 (BOOT button) for 5 seconds
|
||||
#define LED_PIN 2
|
||||
#define BUTTON_PIN 37
|
||||
#define FACTORY_RESET_HOLD_MS 5000
|
||||
|
||||
#define CAM_FPS 5
|
||||
#define CAM_INTERVAL_MS (1000 / CAM_FPS)
|
||||
#define REPORT_INTERVAL_S 3600
|
||||
|
||||
static DeviceConfig g_cfg;
|
||||
static CVState g_cv;
|
||||
static SemaphoreHandle_t s_cv_mutex = nullptr;
|
||||
|
||||
// LED: simple on/off — blink patterns can be added later
|
||||
static void led_set(bool on) { digitalWrite(LED_PIN, on ? HIGH : LOW); }
|
||||
|
||||
static void check_factory_reset() {
|
||||
if (digitalRead(BUTTON_PIN) != LOW) return;
|
||||
uint32_t held = millis();
|
||||
while (digitalRead(BUTTON_PIN) == LOW) {
|
||||
if (millis() - held >= FACTORY_RESET_HOLD_MS) {
|
||||
config_clear_wifi();
|
||||
ESP.restart();
|
||||
}
|
||||
delay(50);
|
||||
}
|
||||
}
|
||||
|
||||
// Camera + CV task — runs on core 1 at 5 fps
|
||||
static void task_camera(void*) {
|
||||
static uint8_t frame[CV_PIXELS]; // static: avoids 9KB on task stack
|
||||
while (true) {
|
||||
if (camera_capture_96(frame)) {
|
||||
if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||
cv_process(g_cv, frame, g_cfg.line_offset);
|
||||
xSemaphoreGive(s_cv_mutex);
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(CAM_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Hourly reporter task — runs on core 0
|
||||
static void task_reporter(void*) {
|
||||
uint32_t last_report_ts = 0; // 0 = not initialized yet
|
||||
|
||||
while (true) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10000)); // check every 10s
|
||||
|
||||
uint32_t now = (uint32_t)(time(nullptr));
|
||||
if (now < 1700000000UL) continue; // NTP not synced
|
||||
|
||||
// First valid timestamp — initialize without reporting
|
||||
if (last_report_ts == 0) { last_report_ts = now; continue; }
|
||||
|
||||
if ((now - last_report_ts) < REPORT_INTERVAL_S) continue;
|
||||
|
||||
uint32_t period_start = last_report_ts;
|
||||
uint32_t period_end = now;
|
||||
last_report_ts = now;
|
||||
|
||||
// Pause BLE during upload
|
||||
ble_scanner_pause();
|
||||
led_set(true); // yellow indicator (single LED: on = uploading)
|
||||
|
||||
CameraHourlyRecord cam_rec;
|
||||
if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(500)) == pdTRUE) {
|
||||
cam_rec = {period_start, period_end, g_cv.entries, g_cv.exits};
|
||||
cv_reset_counts(g_cv);
|
||||
xSemaphoreGive(s_cv_mutex);
|
||||
} else {
|
||||
// Failed to acquire — skip this cycle, will report next hour
|
||||
ble_scanner_resume();
|
||||
led_set(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
BLEHourlyRecord ble_rec = ble_scanner_collect(period_start, period_end);
|
||||
|
||||
reporter_submit_camera(g_cfg, cam_rec);
|
||||
reporter_submit_ble(g_cfg, ble_rec);
|
||||
reporter_heartbeat(g_cfg, millis() / 1000, WiFi.RSSI());
|
||||
|
||||
ble_scanner_resume();
|
||||
led_set(false);
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
pinMode(LED_PIN, OUTPUT);
|
||||
pinMode(BUTTON_PIN, INPUT_PULLUP);
|
||||
led_set(true); // on = booting
|
||||
|
||||
if (!config_load(g_cfg)) {
|
||||
Serial.println("FATAL: device_id/location_id/hmac_secret not provisioned");
|
||||
while (true) { delay(500); led_set(!digitalRead(LED_PIN)); } // fast blink
|
||||
}
|
||||
|
||||
// Connect to WiFi
|
||||
if (!config_has_wifi()) {
|
||||
provisioning_run();
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
WiFi.begin(g_cfg.wifi_ssid.c_str(), g_cfg.wifi_pass.c_str());
|
||||
uint32_t wifi_start = millis();
|
||||
while (WiFi.status() != WL_CONNECTED && millis() - wifi_start < 15000) {
|
||||
check_factory_reset();
|
||||
delay(200);
|
||||
}
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
// Saved creds failed — re-provision
|
||||
provisioning_run();
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
led_set(false); // off = connected
|
||||
|
||||
// NTP sync (UTC)
|
||||
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
|
||||
|
||||
cv_init(g_cv);
|
||||
|
||||
if (!camera_init()) {
|
||||
Serial.println("FATAL: camera init failed");
|
||||
while (true) delay(1000);
|
||||
}
|
||||
|
||||
reporter_init();
|
||||
|
||||
ble_scanner_start();
|
||||
|
||||
// OTA update support
|
||||
ArduinoOTA.setHostname(g_cfg.device_id.c_str());
|
||||
ArduinoOTA.onStart([]() { ble_scanner_pause(); });
|
||||
ArduinoOTA.onEnd([]() { ble_scanner_resume(); ESP.restart(); });
|
||||
ArduinoOTA.onError([](ota_error_t e) { ble_scanner_resume(); });
|
||||
ArduinoOTA.begin();
|
||||
|
||||
s_cv_mutex = xSemaphoreCreateMutex();
|
||||
|
||||
xTaskCreatePinnedToCore(task_camera, "cam", 4096, nullptr, 2, nullptr, 1);
|
||||
xTaskCreatePinnedToCore(task_reporter, "rep", 8192, nullptr, 1, nullptr, 0);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
ArduinoOTA.handle();
|
||||
check_factory_reset();
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
led_set(true); // on = no WiFi
|
||||
WiFi.reconnect();
|
||||
delay(5000);
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
led_set(false);
|
||||
reporter_flush(g_cfg);
|
||||
}
|
||||
}
|
||||
delay(1000);
|
||||
}
|
||||
24
firmware/src/provisioning.cpp
Normal file
24
firmware/src/provisioning.cpp
Normal file
@@ -0,0 +1,24 @@
|
||||
// firmware/src/provisioning.cpp
|
||||
#include "provisioning.h"
|
||||
#include "config.h"
|
||||
#include <WiFiManager.h>
|
||||
|
||||
bool provisioning_run(uint32_t timeout_ms) {
|
||||
WiFiManager wm;
|
||||
wm.setConfigPortalTimeout(timeout_ms / 1000);
|
||||
wm.setTitle("DoorCounter Setup");
|
||||
wm.setCustomHeadElement(
|
||||
"<style>"
|
||||
"body{font-family:sans-serif;max-width:400px;margin:40px auto;padding:0 16px}"
|
||||
"h1{font-size:1.2em;color:#333}"
|
||||
"p{color:#666;font-size:.9em}"
|
||||
"</style>"
|
||||
);
|
||||
|
||||
bool connected = wm.startConfigPortal("DoorCounter-Setup");
|
||||
|
||||
if (connected) {
|
||||
config_save_wifi(wm.getWiFiSSID(), wm.getWiFiPass());
|
||||
}
|
||||
return connected;
|
||||
}
|
||||
7
firmware/src/provisioning.h
Normal file
7
firmware/src/provisioning.h
Normal file
@@ -0,0 +1,7 @@
|
||||
// firmware/src/provisioning.h
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
|
||||
// Start WiFi captive portal AP and block until user submits credentials
|
||||
// or timeout_ms elapses. Returns true if WiFi credentials were saved.
|
||||
bool provisioning_run(uint32_t timeout_ms = 5 * 60 * 1000);
|
||||
185
firmware/src/reporter.cpp
Normal file
185
firmware/src/reporter.cpp
Normal file
@@ -0,0 +1,185 @@
|
||||
// firmware/src/reporter.cpp
|
||||
#include "reporter.h"
|
||||
#include "hmac.h"
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <WiFi.h>
|
||||
#include <vector>
|
||||
#include <time.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
static std::vector<CameraHourlyRecord> s_cam_buf;
|
||||
static std::vector<BLEHourlyRecord> s_ble_buf;
|
||||
static SemaphoreHandle_t s_buf_mutex = nullptr;
|
||||
|
||||
void reporter_init() {
|
||||
s_buf_mutex = xSemaphoreCreateMutex();
|
||||
}
|
||||
|
||||
// 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();
|
||||
// Reject if NTP hasn't synced yet (timestamp would be near epoch 0)
|
||||
if (ts < 1700000000UL) return false; // pre-2023 → clock not valid
|
||||
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;
|
||||
// NOTE: Certificate validation is disabled — connection is encrypted but
|
||||
// server identity is not verified. To enable validation, use WiFiClientSecure
|
||||
// with setCACert() before calling http.begin(client, url).
|
||||
// Acceptable for this deployment: devices operate on store WiFi, not public internet.
|
||||
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) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
buf_add_cam(rec);
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
return;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
std::vector<CameraHourlyRecord> batch;
|
||||
batch.insert(batch.end(), s_cam_buf.begin(), s_cam_buf.end());
|
||||
batch.push_back(rec);
|
||||
s_cam_buf.clear();
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
|
||||
// Cap to MAX_BUFFER: drop oldest to make room for newest
|
||||
if ((int)batch.size() > REPORTER_MAX_BUFFER) {
|
||||
batch.erase(batch.begin(),
|
||||
batch.begin() + ((int)batch.size() - REPORTER_MAX_BUFFER));
|
||||
}
|
||||
|
||||
String body = build_camera_batch(cfg, batch);
|
||||
if (!post_json(cfg, "/api/v1/camera/events/batch", body)) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
s_cam_buf = batch; // re-buffer the whole capped batch
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
}
|
||||
}
|
||||
|
||||
void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec) {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
buf_add_ble(rec);
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
return;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
std::vector<BLEHourlyRecord> batch;
|
||||
batch.insert(batch.end(), s_ble_buf.begin(), s_ble_buf.end());
|
||||
batch.push_back(rec);
|
||||
s_ble_buf.clear();
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
|
||||
// Cap to MAX_BUFFER: drop oldest to make room for newest
|
||||
if ((int)batch.size() > REPORTER_MAX_BUFFER) {
|
||||
batch.erase(batch.begin(),
|
||||
batch.begin() + ((int)batch.size() - REPORTER_MAX_BUFFER));
|
||||
}
|
||||
|
||||
String body = build_ble_batch(cfg, batch);
|
||||
if (!post_json(cfg, "/api/v1/events/batch", body)) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
s_ble_buf = batch; // re-buffer the whole capped batch
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
std::vector<CameraHourlyRecord> cam_snap = s_cam_buf;
|
||||
std::vector<BLEHourlyRecord> ble_snap = s_ble_buf;
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
|
||||
if (!cam_snap.empty()) {
|
||||
String body = build_camera_batch(cfg, cam_snap);
|
||||
if (post_json(cfg, "/api/v1/camera/events/batch", body)) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
s_cam_buf.clear();
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
}
|
||||
}
|
||||
if (!ble_snap.empty()) {
|
||||
String body = build_ble_batch(cfg, ble_snap);
|
||||
if (post_json(cfg, "/api/v1/events/batch", body)) {
|
||||
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
|
||||
s_ble_buf.clear();
|
||||
xSemaphoreGive(s_buf_mutex);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
firmware/src/reporter.h
Normal file
21
firmware/src/reporter.h
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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_init();
|
||||
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);
|
||||
166
firmware/test/test_cv/test_cv.cpp
Normal file
166
firmware/test/test_cv/test_cv.cpp
Normal file
@@ -0,0 +1,166 @@
|
||||
// firmware/test/test_cv/test_cv.cpp
|
||||
#include <unity.h>
|
||||
#include <string.h>
|
||||
#include "cv.h"
|
||||
|
||||
static void fill_frame(uint8_t* f, uint8_t val) {
|
||||
memset(f, val, CV_PIXELS);
|
||||
}
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
void test_frame_diff_no_change_gives_no_fg() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
|
||||
uint8_t frame[CV_PIXELS];
|
||||
fill_frame(frame, 128);
|
||||
|
||||
CVResult r1 = cv_process(state, frame, 50);
|
||||
TEST_ASSERT_EQUAL_INT(0, r1.entries_delta);
|
||||
|
||||
CVResult r2 = cv_process(state, frame, 50);
|
||||
TEST_ASSERT_EQUAL_INT(0, r2.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(0, r2.exits_delta);
|
||||
}
|
||||
|
||||
void test_frame_diff_large_change_detected_no_crash() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
|
||||
uint8_t bg[CV_PIXELS], fg_frame[CV_PIXELS];
|
||||
fill_frame(bg, 100);
|
||||
fill_frame(fg_frame, 200);
|
||||
|
||||
cv_process(state, bg, 50);
|
||||
CVResult r = cv_process(state, fg_frame, 50);
|
||||
|
||||
// Tracking not yet implemented — just verify no crash and result is zero
|
||||
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
|
||||
}
|
||||
|
||||
void test_cv_init_clears_state() {
|
||||
CVState state;
|
||||
state.entries = 99; state.exits = 88;
|
||||
cv_init(state);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
TEST_ASSERT_FALSE(state.bg_valid);
|
||||
}
|
||||
|
||||
void test_cv_reset_counts() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
state.entries = 5;
|
||||
state.exits = 3;
|
||||
cv_reset_counts(state);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.entries);
|
||||
TEST_ASSERT_EQUAL_INT(0, state.exits);
|
||||
}
|
||||
|
||||
void test_tracking_spawns_track_for_new_blob() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
|
||||
uint8_t bg[CV_PIXELS];
|
||||
fill_frame(bg, 100);
|
||||
cv_process(state, bg, 50); // init background
|
||||
|
||||
// Frame with a bright 30x30 blob in top-left quadrant
|
||||
uint8_t blob_frame[CV_PIXELS];
|
||||
fill_frame(blob_frame, 100);
|
||||
for (int y = 5; y < 35; y++)
|
||||
for (int x = 5; x < 35; x++)
|
||||
blob_frame[y * CV_W + x] = 200;
|
||||
|
||||
cv_process(state, blob_frame, 50);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(1, (int)state.tracks.size());
|
||||
TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].x);
|
||||
TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].y);
|
||||
}
|
||||
|
||||
static void make_blob_frame(uint8_t* f, int cx, int cy) {
|
||||
fill_frame(f, 100);
|
||||
for (int y = cy - 12; y <= cy + 12; y++)
|
||||
for (int x = cx - 12; x <= cx + 12; x++)
|
||||
if (y >= 0 && y < CV_H && x >= 0 && x < CV_W)
|
||||
f[y * CV_W + x] = 200;
|
||||
}
|
||||
|
||||
void test_blob_crossing_line_top_to_bottom_is_entry() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
|
||||
// Line at 50% = y=48; step ≤14px per frame to stay within CV_MAX_MOVE
|
||||
uint8_t bg[CV_PIXELS];
|
||||
fill_frame(bg, 100);
|
||||
cv_process(state, bg, 50); // init background
|
||||
|
||||
// Walk blob from y=20 toward line; crossing occurs at y=48 (above→below)
|
||||
// Stop at crossing frame and assert its result
|
||||
int setup[] = {20, 34};
|
||||
for (int i = 0; i < 2; i++) {
|
||||
uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, setup[i]);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 48);
|
||||
CVResult r = cv_process(state, fcross, 50);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(1, r.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
|
||||
TEST_ASSERT_EQUAL_INT(1, state.entries);
|
||||
}
|
||||
|
||||
void test_blob_crossing_line_bottom_to_top_is_exit() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
|
||||
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
|
||||
cv_process(state, bg, 50);
|
||||
|
||||
// Walk blob from y=76 toward line; crossing occurs at y=34 (below→above)
|
||||
// Stop at crossing frame and assert its result
|
||||
int setup[] = {76, 62, 48};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, setup[i]);
|
||||
cv_process(state, f, 50);
|
||||
}
|
||||
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 34);
|
||||
CVResult r = cv_process(state, fcross, 50);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(1, r.exits_delta);
|
||||
}
|
||||
|
||||
void test_no_crossing_same_side_no_count() {
|
||||
CVState state;
|
||||
cv_init(state);
|
||||
|
||||
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
|
||||
cv_process(state, bg, 50);
|
||||
|
||||
uint8_t f1[CV_PIXELS]; make_blob_frame(f1, 48, 20); // above line
|
||||
cv_process(state, f1, 50);
|
||||
|
||||
uint8_t f2[CV_PIXELS]; make_blob_frame(f2, 48, 30); // still above line, moved closer
|
||||
CVResult r = cv_process(state, f2, 50);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
|
||||
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_frame_diff_no_change_gives_no_fg);
|
||||
RUN_TEST(test_frame_diff_large_change_detected_no_crash);
|
||||
RUN_TEST(test_cv_init_clears_state);
|
||||
RUN_TEST(test_cv_reset_counts);
|
||||
RUN_TEST(test_tracking_spawns_track_for_new_blob);
|
||||
RUN_TEST(test_blob_crossing_line_top_to_bottom_is_entry);
|
||||
RUN_TEST(test_blob_crossing_line_bottom_to_top_is_exit);
|
||||
RUN_TEST(test_no_crossing_same_side_no_count);
|
||||
return UNITY_END();
|
||||
}
|
||||
0
firmware/test/test_native/.gitkeep
Normal file
0
firmware/test/test_native/.gitkeep
Normal file
41
firmware/test/test_native/test_hmac.cpp
Normal file
41
firmware/test/test_native/test_hmac.cpp
Normal file
@@ -0,0 +1,41 @@
|
||||
// firmware/test/test_native/test_hmac.cpp
|
||||
#include <unity.h>
|
||||
#include "hmac.h"
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
// Expected value derived via:
|
||||
// import hmac, hashlib
|
||||
// secret = bytes.fromhex("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20")
|
||||
// body = '{"location_id":"retailer-123","records":[]}'
|
||||
// body_hash = hashlib.sha256(body.encode()).hexdigest()
|
||||
// msg = f"dc-0042:1712000000:{body_hash}"
|
||||
// hmac.new(secret, msg.encode(), hashlib.sha256).hexdigest()
|
||||
void test_hmac_known_vector() {
|
||||
HString secret = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
|
||||
HString device = "dc-0042";
|
||||
HString body = "{\"location_id\":\"retailer-123\",\"records\":[]}";
|
||||
uint32_t ts = 1712000000;
|
||||
|
||||
HString result = hmac_sign(secret, device, ts, body);
|
||||
|
||||
TEST_ASSERT_EQUAL_STRING("90f5fa5fdbf7f95e7475791bf5bb90cdef7f16534d9a7d263fc588305bad0525", result.c_str());
|
||||
}
|
||||
|
||||
void test_hmac_different_timestamp_gives_different_sig() {
|
||||
HString secret = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
|
||||
HString device = "dc-0042";
|
||||
HString body = "{}";
|
||||
|
||||
HString sig1 = hmac_sign(secret, device, 1712000000, body);
|
||||
HString sig2 = hmac_sign(secret, device, 1712000001, body);
|
||||
TEST_ASSERT_NOT_EQUAL(0, sig1.compare(sig2));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_hmac_known_vector);
|
||||
RUN_TEST(test_hmac_different_timestamp_gives_different_sig);
|
||||
return UNITY_END();
|
||||
}
|
||||
0
server/__init__.py
Normal file
0
server/__init__.py
Normal file
69
server/camera_endpoint.py
Normal file
69
server/camera_endpoint.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# server/camera_endpoint.py
|
||||
# Add these models and endpoint to the server's main.py alongside the existing BLE endpoints.
|
||||
# Requires: camera_records table (see migrations/004_camera_records.sql)
|
||||
#
|
||||
# IMPORTANT: Before deploying, verify the HMAC message format in verify_device_hmac
|
||||
# matches what the firmware computes:
|
||||
# HMAC-SHA256(secret, f"{device_id}:{timestamp}:{sha256_hex(body)}")
|
||||
# Headers expected: X-Device-Id, X-Timestamp, X-HMAC-Signature
|
||||
|
||||
import sqlite3
|
||||
from typing import List
|
||||
|
||||
from fastapi import Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CameraRecord(BaseModel):
|
||||
period_start: int
|
||||
period_end: int
|
||||
entries: int = Field(ge=0)
|
||||
exits: int = Field(ge=0)
|
||||
|
||||
|
||||
class CameraEventsRequest(BaseModel):
|
||||
location_id: str
|
||||
records: List[CameraRecord]
|
||||
|
||||
|
||||
class CameraEventsResponse(BaseModel):
|
||||
status: str
|
||||
accepted: int
|
||||
|
||||
|
||||
# Add this endpoint to your FastAPI app (alongside receive_batch_events):
|
||||
#
|
||||
# @app.post("/api/v1/camera/events/batch", response_model=CameraEventsResponse)
|
||||
# async def receive_camera_events(
|
||||
# batch: CameraEventsRequest,
|
||||
# device_id: str = Depends(verify_device_hmac),
|
||||
# db: sqlite3.Connection = Depends(get_db),
|
||||
# ):
|
||||
def receive_camera_events_impl(
|
||||
batch: CameraEventsRequest,
|
||||
device_id: str,
|
||||
db: sqlite3.Connection,
|
||||
) -> CameraEventsResponse:
|
||||
"""Receive hourly camera entry/exit records; idempotent on (device_id, period_start)."""
|
||||
cursor = db.cursor()
|
||||
accepted = 0
|
||||
for record in batch.records:
|
||||
try:
|
||||
cursor.execute(
|
||||
"""INSERT INTO camera_records
|
||||
(device_id, location_id, period_start, period_end, entries, exits)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
device_id,
|
||||
batch.location_id,
|
||||
record.period_start,
|
||||
record.period_end,
|
||||
record.entries,
|
||||
record.exits,
|
||||
),
|
||||
)
|
||||
accepted += 1
|
||||
except sqlite3.IntegrityError:
|
||||
pass # duplicate (device_id, period_start) — idempotent
|
||||
db.commit()
|
||||
return CameraEventsResponse(status="ok", accepted=accepted)
|
||||
18
server/migrations/004_camera_records.sql
Normal file
18
server/migrations/004_camera_records.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- migrations/004_camera_records.sql
|
||||
-- Add camera_records table for TimerCamera-F door counter events
|
||||
-- Apply: sqlite3 <db_file> < migrations/004_camera_records.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS camera_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
location_id TEXT NOT NULL,
|
||||
period_start INTEGER NOT NULL,
|
||||
period_end INTEGER NOT NULL,
|
||||
entries INTEGER NOT NULL,
|
||||
exits INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(device_id, period_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_camera_location_time
|
||||
ON camera_records(location_id, period_start);
|
||||
100
server/test_camera_endpoint.py
Normal file
100
server/test_camera_endpoint.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# server/test_camera_endpoint.py
|
||||
# Template tests for the camera batch endpoint.
|
||||
# Adapt imports and fixtures to match the actual server's test structure.
|
||||
#
|
||||
# To run against the actual server (once integrated):
|
||||
# pytest server/test_camera_endpoint.py -v
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
# These imports will need to match the actual server module structure:
|
||||
# from main import app, get_db, verify_device_hmac
|
||||
# from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def make_camera_batch_body(location_id: str, period_start: int,
|
||||
period_end: int, entries: int, exits: int) -> str:
|
||||
return json.dumps({
|
||||
"location_id": location_id,
|
||||
"records": [{
|
||||
"period_start": period_start,
|
||||
"period_end": period_end,
|
||||
"entries": entries,
|
||||
"exits": exits,
|
||||
}]
|
||||
})
|
||||
|
||||
|
||||
def _make_db() -> sqlite3.Connection:
|
||||
db = sqlite3.connect(":memory:")
|
||||
db.execute("""
|
||||
CREATE TABLE camera_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
location_id TEXT NOT NULL,
|
||||
period_start INTEGER NOT NULL,
|
||||
period_end INTEGER NOT NULL,
|
||||
entries INTEGER NOT NULL,
|
||||
exits INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(device_id, period_start)
|
||||
)
|
||||
""")
|
||||
db.commit()
|
||||
return db
|
||||
|
||||
|
||||
def test_insert_logic_idempotent():
|
||||
"""Unit test for the insert logic — no FastAPI needed."""
|
||||
db = _make_db()
|
||||
|
||||
from server.camera_endpoint import CameraRecord, CameraEventsRequest, receive_camera_events_impl
|
||||
|
||||
batch = CameraEventsRequest(
|
||||
location_id="test-loc",
|
||||
records=[CameraRecord(period_start=1712000000, period_end=1712003600,
|
||||
entries=5, exits=3)]
|
||||
)
|
||||
|
||||
resp1 = receive_camera_events_impl(batch, "dc-test-01", db)
|
||||
assert resp1.status == "ok"
|
||||
assert resp1.accepted == 1
|
||||
|
||||
# Second identical call — idempotent
|
||||
resp2 = receive_camera_events_impl(batch, "dc-test-01", db)
|
||||
assert resp2.status == "ok"
|
||||
assert resp2.accepted == 0
|
||||
|
||||
|
||||
def test_entries_exits_stored_correctly():
|
||||
"""Verify entries and exits are stored as submitted."""
|
||||
db = _make_db()
|
||||
|
||||
from server.camera_endpoint import CameraRecord, CameraEventsRequest, receive_camera_events_impl
|
||||
|
||||
batch = CameraEventsRequest(
|
||||
location_id="retailer-123",
|
||||
records=[CameraRecord(period_start=1712007200, period_end=1712010800,
|
||||
entries=42, exits=39)]
|
||||
)
|
||||
receive_camera_events_impl(batch, "dc-0042", db)
|
||||
|
||||
row = db.execute(
|
||||
"SELECT entries, exits, location_id FROM camera_records WHERE device_id=?",
|
||||
("dc-0042",)
|
||||
).fetchone()
|
||||
assert row[0] == 42
|
||||
assert row[1] == 39
|
||||
assert row[2] == "retailer-123"
|
||||
|
||||
|
||||
def test_negative_counts_rejected():
|
||||
"""Pydantic should reject negative entries/exits."""
|
||||
from pydantic import ValidationError
|
||||
from server.camera_endpoint import CameraRecord
|
||||
with pytest.raises(ValidationError):
|
||||
CameraRecord(period_start=1712000000, period_end=1712003600,
|
||||
entries=-1, exits=0)
|
||||
104
tools/flash_device.py
Executable file
104
tools/flash_device.py
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
flash_device.py — Write NVS config to TimerCamera-F over serial.
|
||||
|
||||
Requires: pip install esptool nvs-partition-gen
|
||||
Usage:
|
||||
python flash_device.py \\
|
||||
--port /dev/ttyUSB0 \\
|
||||
--device-id dc-0042 \\
|
||||
--location-id retailer-123 \\
|
||||
--hmac-secret <32-byte-hex> # omit to auto-generate \\
|
||||
[--wifi-ssid "StoreWiFi"] \\
|
||||
[--wifi-password "secret"] \\
|
||||
[--line-offset 50]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
NVS_NAMESPACE = "doorcounter"
|
||||
NVS_PARTITION_OFFSET = "0x9000"
|
||||
NVS_PARTITION_SIZE = "0x5000" # matches firmware partition table (20KB)
|
||||
|
||||
|
||||
def build_nvs_csv(device_id, location_id, hmac_secret,
|
||||
wifi_ssid=None, wifi_pass=None, line_offset=50):
|
||||
rows = [
|
||||
"key,type,encoding,value",
|
||||
f"{NVS_NAMESPACE},namespace,,",
|
||||
f"device_id,data,string,{device_id}",
|
||||
f"location_id,data,string,{location_id}",
|
||||
f"hmac_secret,data,string,{hmac_secret}",
|
||||
f"line_offset,data,u32,{line_offset}",
|
||||
]
|
||||
if wifi_ssid is not None:
|
||||
rows.append(f"wifi_ssid,data,string,{wifi_ssid}")
|
||||
if wifi_pass is not None:
|
||||
rows.append(f"wifi_pass,data,string,{wifi_pass}")
|
||||
return "\n".join(rows) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Provision TimerCamera-F NVS config over serial")
|
||||
parser.add_argument("--port", required=True,
|
||||
help="Serial port, e.g. /dev/ttyUSB0 or COM3")
|
||||
parser.add_argument("--device-id", required=True,
|
||||
help="Unique device ID, e.g. dc-0042")
|
||||
parser.add_argument("--location-id", required=True,
|
||||
help="Retailer location ID, e.g. retailer-123")
|
||||
parser.add_argument("--hmac-secret", default=None,
|
||||
help="32-byte hex HMAC secret (auto-generated if omitted)")
|
||||
parser.add_argument("--wifi-ssid", default=None,
|
||||
help="WiFi SSID (optional — user can set via captive portal)")
|
||||
parser.add_argument("--wifi-password", default=None,
|
||||
help="WiFi password (optional)")
|
||||
parser.add_argument("--line-offset", type=int, default=50,
|
||||
help="Virtual line position %% of frame height (default 50)")
|
||||
args = parser.parse_args()
|
||||
|
||||
hmac_secret = args.hmac_secret or secrets.token_hex(32)
|
||||
if args.hmac_secret is None:
|
||||
print(f"Generated HMAC secret: {hmac_secret}")
|
||||
print(" *** SAVE THIS — you need it to register the device on the server ***")
|
||||
|
||||
if args.line_offset < 0 or args.line_offset > 100:
|
||||
print("Error: --line-offset must be 0-100", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
csv_path = os.path.join(tmp, "nvs.csv")
|
||||
bin_path = os.path.join(tmp, "nvs.bin")
|
||||
|
||||
csv_content = build_nvs_csv(
|
||||
args.device_id, args.location_id, hmac_secret,
|
||||
args.wifi_ssid, args.wifi_password, args.line_offset
|
||||
)
|
||||
with open(csv_path, "w") as f:
|
||||
f.write(csv_content)
|
||||
|
||||
# Generate NVS binary
|
||||
ret = subprocess.run(
|
||||
[sys.executable, "-m", "nvs_partition_gen", "generate",
|
||||
csv_path, bin_path, NVS_PARTITION_SIZE],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if ret.returncode != 0:
|
||||
print(f"nvs_partition_gen error:\n{ret.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Flash NVS partition
|
||||
ret = subprocess.run(
|
||||
["esptool.py", "--port", args.port, "--chip", "esp32",
|
||||
"write_flash", NVS_PARTITION_OFFSET, bin_path]
|
||||
)
|
||||
sys.exit(ret.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
103
tools/ota_push.py
Executable file
103
tools/ota_push.py
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ota_push.py — Push firmware OTA to a TimerCamera-F device via Arduino OTA.
|
||||
|
||||
Requires: Python 3.8+, no extra pip packages needed
|
||||
Device must be connected to WiFi and reachable on the local network.
|
||||
|
||||
Usage:
|
||||
python ota_push.py \\
|
||||
--host dc-0042.local \\
|
||||
--firmware firmware/.pio/build/timercam/firmware.bin
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
OTA_PORT = 3232
|
||||
|
||||
|
||||
def compute_md5(path: str) -> str:
|
||||
h = hashlib.md5()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def resolve_host(host: str, timeout: float = 10.0) -> str:
|
||||
"""Resolve hostname to IP, retrying until timeout."""
|
||||
deadline = time.monotonic() + timeout
|
||||
last_err = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
return socket.gethostbyname(host)
|
||||
except socket.gaierror as e:
|
||||
last_err = e
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(f"Could not resolve {host!r} within {timeout:.0f}s: {last_err}")
|
||||
|
||||
|
||||
def push_ota(host: str, firmware_path: str) -> None:
|
||||
size = os.path.getsize(firmware_path)
|
||||
md5 = compute_md5(firmware_path)
|
||||
|
||||
print(f"Resolving {host} ...")
|
||||
ip = resolve_host(host)
|
||||
print(f" → {ip}")
|
||||
|
||||
print(f"Connecting to {ip}:{OTA_PORT} ...")
|
||||
with socket.create_connection((ip, OTA_PORT), timeout=15) as sock:
|
||||
# Arduino OTA handshake: "0:<size>:<md5>\n"
|
||||
header = f"0:{size}:{md5}\n".encode()
|
||||
sock.sendall(header)
|
||||
|
||||
sock.settimeout(10)
|
||||
resp = sock.recv(64).decode(errors="replace").strip()
|
||||
if resp != "OK":
|
||||
raise RuntimeError(f"OTA handshake rejected: {resp!r}")
|
||||
|
||||
print(f"Sending {size:,} bytes ...")
|
||||
sent = 0
|
||||
with open(firmware_path, "rb") as f:
|
||||
while chunk := f.read(4096):
|
||||
sock.sendall(chunk)
|
||||
sent += len(chunk)
|
||||
pct = sent * 100 // size
|
||||
print(f"\r {pct:3d}% [{sent:,}/{size:,}]", end="", flush=True)
|
||||
print()
|
||||
|
||||
sock.settimeout(30)
|
||||
final = sock.recv(64).decode(errors="replace").strip()
|
||||
if final != "OK":
|
||||
raise RuntimeError(f"OTA write failed: {final!r}")
|
||||
|
||||
print(f"✓ OTA complete — {host} is rebooting")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Push OTA firmware update to a door counter device")
|
||||
parser.add_argument("--host", required=True,
|
||||
help="mDNS hostname or IP, e.g. dc-0042.local")
|
||||
parser.add_argument("--firmware", required=True,
|
||||
help="Path to .bin firmware file")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.firmware):
|
||||
print(f"Error: firmware file not found: {args.firmware}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
push_ota(args.host, args.firmware)
|
||||
except (RuntimeError, OSError) as e:
|
||||
print(f"\nError: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user