Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: CI

on:
push:
branches: [main, "claude/**", "feature/**"]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
# The pure headers — gate_logic.h, measurement_json.h, idle_state.h — carry
# the logic that is impractical to exercise on a hive: debounce timing, the
# exact bytes HiveHub parses, and night-mode deadlines across a millis()
# rollover. They need nothing but a host compiler, so there is no reason for
# them to run only on someone's laptop.
host-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and run host logic tests
run: ./Firmware/test/run_tests.sh

# The production image must compile from a clean dependency cache (every
# library is pinned in Firmware/platformio.ini), and the -DIR_DEBUG bench
# console must keep compiling too: it has no environment of its own, so
# nothing else would ever catch it going stale.
firmware-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install PlatformIO
run: pip install platformio
# The bench console runs first so the production image is what ends up in
# .pio/build for the upload below — an IR_DEBUG binary published as the
# release artifact would be flashable, plausible and wrong.
- name: Build the IR_DEBUG bench console
env:
PLATFORMIO_BUILD_FLAGS: -DIR_DEBUG
run: pio run
working-directory: Firmware
- name: Build production image
run: pio run
working-directory: Firmware
- name: Upload firmware image
uses: actions/upload-artifact@v4
with:
name: hivetraffic-esp32-c6
# rename_firmware.py names this hivetraffic_esp32-c6_<version>.bin,
# which is the name HiveHub's upload form parses; keep it as produced.
path: Firmware/.pio/build/seeed_xiao_esp32c6/hivetraffic_*.bin
if-no-files-found: error
8 changes: 8 additions & 0 deletions Firmware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ Press a single key in the serial monitor:
| `1` | Force IR LEDs ON (steady) |
| `0` | Force IR LEDs OFF |
| `a` | IR LEDs AUTO (normal pulsed mode) |
| `n` | Arm / clear a 60 s night-mode suspension (press again to resume) |
| `h` | Show the command list |

Each reading lists the raw MCP23017 port words plus a per-gate `BLOCK`/`clear`
Expand Down Expand Up @@ -274,6 +275,13 @@ No ESP32, no MCP23017 and no bee required.
document against the buffer it has to fit in. Run it before changing any
reported field — and bump `PROTOCOL_VERSION` and update HiveHub's parser in
the same revision when you do (see `docs/ble-mode.md`).
* **`include/idle_state.h`** — the night-mode suspension deadline. The suite
covers clamping an over-long request, the `millis()` rollover, and the case
that matters most in the field: HiveHub stopping re-arming, after which the
counter has to free itself. A counter stuck suspended emits perfectly
well-formed documents full of zeros, which is indistinguishable from a spell
of bad weather until someone reads a week of totals — so none of this is
something a bench session would catch.

Everything hardware-facing stays in `src/main.cpp` and is still verified on the
bench with the IR-sensor console above.
Expand Down
16 changes: 16 additions & 0 deletions Firmware/include/ble_link.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,25 @@ struct Telemetry {
uint32_t total_out;
// 32-bit since protocol v3; saturating, never wrapping (gate_logic.h).
uint32_t glitch_count;
// Seconds of night-mode suspension still to run, 0 when counting. New in
// protocol v4, alongside STATUS_NIGHT_IDLE. It is what lets HiveHub — and
// anyone reading the stored history later — tell "no bees flew" from "this
// counter was deliberately not looking", which the totals alone cannot say.
uint32_t idle_s;
};

void getTelemetry(Telemetry& out);

// Apply a night-mode request written to the control characteristic.
// Implemented in main.cpp, where the suspension state and the emitters live;
// declared here because ble_link.cpp is what receives the write.
// `duration_s` of 0 resumes sensing. Returns the duration actually granted
// after clamping to beecounter_proto::MAX_IDLE_SECONDS.
uint32_t applyIdleRequest(uint32_t duration_s);

// Seconds of suspension left, for the control characteristic's read-back.
uint32_t idleRemainingSeconds();

void begin();
bool isOtaActive();
void loopOta();
Expand Down
66 changes: 61 additions & 5 deletions Firmware/include/counter_protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,17 @@ namespace beecounter_proto {
// v3 = uptime_s and glitches widened to 32 bits, and "gates_healthy" renamed to
// "mcps_healthy" to say what it has always counted. See the revision
// history in docs/ble-mode.md for the full delta.
// v4 = adds the "idle_s" field and the STATUS_NIGHT_IDLE bit, so a document
// says whether the counter is deliberately not sensing (night mode) and
// for how much longer. Without it a night of zero crossings is
// indistinguishable from a counter whose emitters have failed.
//
// A counter in the field keeps emitting v2 until it is updated over the air,
// and the OTA relay has to read this very characteristic before it can update
// anything — so HiveHub's parser reads "fw" first and accepts both revisions.
// Its tolerant parser must be deployed BEFORE any counter emitting v3.
constexpr uint8_t PROTOCOL_VERSION = 3;
// A counter in the field keeps emitting an older revision until it is updated
// over the air, and the OTA relay has to read this very characteristic before
// it can update anything — so HiveHub's parser reads "fw" first and accepts
// every revision. Its tolerant parser must be deployed BEFORE any counter
// emitting the new one.
constexpr uint8_t PROTOCOL_VERSION = 4;

// --------------------------------------------------------------------------
// Status bitfield — reported as the JSON "status" field
Expand All @@ -62,6 +67,12 @@ constexpr uint8_t STATUS_SENSOR_FAULT_FLAG = 0x20; // a gate is stuck low/hig
// wrap, so the reported value stays pinned at the maximum and stays monotonic;
// this flag is what distinguishes "pinned" from "stopped counting".
constexpr uint8_t STATUS_OVERFLOW_FLAG = 0x40;
// Sensing is deliberately suspended (night mode): the emitters are dark, the
// gates are not polled and the totals are frozen. This is the bit that keeps a
// night of zero crossings from reading as a dead counter — see idle_state.h for
// the deadline that clears it, and the control characteristic below for who
// sets it.
constexpr uint8_t STATUS_NIGHT_IDLE = 0x80;

// --------------------------------------------------------------------------
// OTA state machine — byte 0 of the OTA status characteristic
Expand All @@ -81,4 +92,49 @@ constexpr uint8_t OTA_STATE_ERR_END = 0x15; // Update.end() failed

constexpr uint8_t OTA_ERR_NONE = 0x00;

// --------------------------------------------------------------------------
// Control characteristic — night mode / sensing suspension
// --------------------------------------------------------------------------
// The counter has never had an input other than OTA. This is the second one,
// and it is deliberately the smallest thing that can express "stop sensing":
// HiveHub writes a DURATION, never a schedule and never a wall-clock time.
//
// Why a deadline rather than a schedule
// -------------------------------------
// The counter has no RTC, no NVS and no idea what time it is; giving it a
// 20:00-06:00 window would mean teaching it all three, and every one of them is
// a way for a counter to end up permanently blind on its own. A duration cannot
// do that: it expires. HiveHub knows the time (NTP + a DS3231 at +/-2 ppm) and
// re-arms the idle window once per upload cycle, so the counter's own clock
// only has to be right for one cycle at a time and nothing accumulates.
//
// Everything about this is fail-open. An idle request is capped at
// MAX_IDLE_SECONDS; the state is never persisted, so any reset resumes
// counting; and a HiveHub that stops calling simply lets the deadline run out.
// The failure mode of the whole feature is "the counter counts", which is the
// behaviour it had before this existed.
constexpr uint8_t CTRL_OP_SET_IDLE = 0x01; // + duration_s (4 LE)
constexpr uint8_t CTRL_OP_RESUME = 0x02; // no payload: sense again now

// Longest suspension the counter will accept, whatever HiveHub asks for. One
// hour is several times HiveHub's default 10-minute upload cycle — enough that
// a couple of missed cycles do not wake the emitters up in the middle of the
// night — while bounding how long a counter can stay blind after HiveHub falls
// off the air entirely. A longer request is CLAMPED to this rather than
// refused: refusing would leave the emitters running all night because one
// field was too large.
constexpr uint32_t MAX_IDLE_SECONDS = 3600;

// Control status, as read back from the control characteristic:
// state(1) + remaining_s(4 LE)
// state is one of the two below; remaining_s is 0 unless idle.
constexpr uint8_t CTRL_STATE_SENSING = 0x00;
constexpr uint8_t CTRL_STATE_IDLE = 0x01;

// Bytes in that read-back value.
constexpr uint8_t CTRL_STATUS_LENGTH = 5;

// Bytes in a well-formed SET_IDLE write (opcode + uint32 LE).
constexpr uint8_t CTRL_SET_IDLE_LENGTH = 5;

} // namespace beecounter_proto
122 changes: 122 additions & 0 deletions Firmware/include/idle_state.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// ============================================================================
// idle_state.h — night-mode suspension, as pure deadline arithmetic
// ============================================================================
//
// Arduino-free for the same reason gate_logic.h and measurement_json.h are: the
// interesting part of "stop counting until told otherwise" is entirely about
// clock arithmetic that is awkward to reproduce on a bench and impossible to
// reproduce on a hive. It is exercised by test/test_idle_state/ on a host
// compiler; src/main.cpp owns the emitters and the poll loop and calls in here
// to decide whether to run them.
//
// The model
// ---------
// The counter never learns what time it is. HiveHub writes SET_IDLE with a
// DURATION, this header turns that into a millis() deadline, and every poll
// asks whether the deadline has passed. Three properties fall out of that and
// they are the whole design:
//
// 1. **It expires.** A HiveHub that crashes, loses power, or is carried away
// cannot leave a counter suspended: the deadline runs out and sensing
// resumes on its own. Compare a stored 20:00-06:00 schedule, which stays
// wrong until someone walks to the hive.
// 2. **It is bounded.** Requests are clamped to MAX_IDLE_SECONDS rather than
// refused, so a malformed or over-eager duration costs a re-arm next
// cycle, not a night of running emitters.
// 3. **It is not persistent.** There is no NVS write and no RTC-memory copy.
// Any reset — brownout, OTA, watchdog — comes back counting.
//
// millis() rollover
// -----------------
// Deadlines are compared with signed differences, like the rest of this
// firmware, so the ~49.7-day millis() wrap is a non-event: (int32_t)(now -
// deadline) >= 0 stays correct across it as long as the interval itself is
// shorter than half the counter's range, which MAX_IDLE_SECONDS (1 h) is by
// four orders of magnitude.
// ============================================================================

#pragma once

#include <stdint.h>

#include "counter_protocol.h"

namespace idlestate {

// Suspension state. Default-constructed is "sensing", which is what a freshly
// booted counter must always be.
struct State {
bool active = false;
uint32_t deadline_ms = 0; // only meaningful while active
};

// Result of a SET_IDLE request, so the caller can log what it actually did
// rather than what it was asked to do.
struct Request {
uint32_t granted_s = 0; // duration actually applied, after clamping
bool clamped = false;
};

// Arm (or re-arm) the suspension for `duration_s` seconds from `now_ms`.
//
// A zero duration resumes sensing immediately — that is the same thing
// CTRL_OP_RESUME does, and accepting it here means HiveHub can express "not
// tonight" by re-arming with 0 rather than needing a second opcode on a path
// where it already has one.
//
// Re-arming while already idle is the normal case, not an edge case: HiveHub
// pushes a fresh deadline every upload cycle for as long as the night window
// lasts, so the deadline moves forward roughly every 10 minutes and the counter
// stays suspended without any single request having to cover the whole night.
inline Request request(State& s, uint32_t now_ms, uint32_t duration_s) {
Request r;
r.clamped = duration_s > beecounter_proto::MAX_IDLE_SECONDS;
r.granted_s = r.clamped ? beecounter_proto::MAX_IDLE_SECONDS : duration_s;

if (r.granted_s == 0) {
s.active = false;
s.deadline_ms = 0;
return r;
}
s.active = true;
s.deadline_ms = now_ms + r.granted_s * 1000UL;
return r;
}

// Resume sensing now, discarding any deadline.
inline void resume(State& s) {
s.active = false;
s.deadline_ms = 0;
}

// Has an armed suspension run out? False when not suspended at all.
inline bool expired(const State& s, uint32_t now_ms) {
if (!s.active) return false;
return (int32_t)(now_ms - s.deadline_ms) >= 0;
}

// Clear the suspension if its deadline has passed. Returns true exactly on the
// poll that ends it, so the caller can do its one-off resume work (resetting
// the gate state machines) without tracking the edge itself.
inline bool serviceExpiry(State& s, uint32_t now_ms) {
if (!expired(s, now_ms)) return false;
resume(s);
return true;
}

// Whether sensing should run right now.
inline bool sensing(const State& s) {
return !s.active;
}

// Seconds left on the suspension, rounded UP so a live suspension never reports
// zero (which reads as "sensing" to HiveHub and would make it re-arm a beat
// early). Zero means not suspended.
inline uint32_t remainingSeconds(const State& s, uint32_t now_ms) {
if (!s.active) return 0;
const int32_t left_ms = (int32_t)(s.deadline_ms - now_ms);
if (left_ms <= 0) return 0;
return ((uint32_t)left_ms + 999UL) / 1000UL;
}

} // namespace idlestate
16 changes: 9 additions & 7 deletions Firmware/include/measurement_json.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// beecounter_proto::PROTOCOL_VERSION. Keep the three in step: a field added
// here is a field HiveHub's parser has to learn, in the same revision.
//
// snprintf rather than a JSON library: the document is a flat object of nine
// snprintf rather than a JSON library: the document is a flat object of ten
// fixed keys with no nesting, no arrays and no escaping to do, so there is
// nothing for a parser-builder to buy us at the cost of heap churn on every
// GATT read.
Expand All @@ -30,22 +30,23 @@ namespace beecounter_proto {

// Buffer the measurement document must be built into.
//
// The worst case is protocol v3 with every field saturated and a long version
// The worst case is protocol v4 with every field saturated and a long version
// string. Counting the fixed punctuation and the maximum decimal widths:
//
// {"fw":255,"ver":"<15>","uptime_s":4294967295,"status":255,"num_gates":255,
// "mcps_healthy":255,"total_in":4294967295,"total_out":4294967295,
// "glitches":4294967295}
// "glitches":4294967295,"idle_s":4294967295}
//
// is 171 bytes, plus the NUL — measured, not estimated, by
// is 191 bytes, plus the NUL — measured, not estimated, by
// test/test_measurement_json/ (which prints the number and fails if it grows
// past the buffer). 224 leaves room for a version string longer than any
// version.h has carried without another audit of this number; the truncation
// check in buildMeasurementJson() is what actually guarantees a malformed
// document is never published, so this is headroom, not a promise.
//
// (v2 fitted in ~155 bytes. Widening uptime_s and glitches to 32 bits and
// renaming gates_healthy -> mcps_healthy added ~16 bytes to the worst case.)
// renaming gates_healthy -> mcps_healthy added ~16 bytes; v4's idle_s added
// ~21 more. The buffer has not had to grow for either.)
constexpr unsigned MEASUREMENT_JSON_CAPACITY = 224;

// Serialize `t` plus the image version string into `out`.
Expand All @@ -67,7 +68,7 @@ inline int buildMeasurementJson(char* out, unsigned capacity,
out, capacity,
"{\"fw\":%u,\"ver\":\"%s\",\"uptime_s\":%lu,\"status\":%u,"
"\"num_gates\":%u,\"mcps_healthy\":%u,\"total_in\":%lu,"
"\"total_out\":%lu,\"glitches\":%lu}",
"\"total_out\":%lu,\"glitches\":%lu,\"idle_s\":%lu}",
static_cast<unsigned>(t.protocol_version),
fw_version ? fw_version : "",
static_cast<unsigned long>(t.uptime_s),
Expand All @@ -76,7 +77,8 @@ inline int buildMeasurementJson(char* out, unsigned capacity,
static_cast<unsigned>(t.mcps_healthy),
static_cast<unsigned long>(t.total_in),
static_cast<unsigned long>(t.total_out),
static_cast<unsigned long>(t.glitch_count));
static_cast<unsigned long>(t.glitch_count),
static_cast<unsigned long>(t.idle_s));
if (length <= 0 || static_cast<unsigned>(length) >= capacity) return -1;
return length;
}
Expand Down
2 changes: 1 addition & 1 deletion Firmware/include/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
// and it is the only way to confirm afterwards that an update actually took.
//
// Bump this on every released image.
#define HIVETRAFFIC_FW_VERSION "0.1.0"
#define HIVETRAFFIC_FW_VERSION "0.2.0"
Loading
Loading