Skip to content

Commit 7f463ed

Browse files
authored
Merge pull request #14 from MacNite/claude/night-mode-review-setryb
Night mode: a bounded sensing suspension (protocol v4)
2 parents c495c29 + ff4fc8b commit 7f463ed

16 files changed

Lines changed: 970 additions & 55 deletions

File tree

.github/workflows/ci.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, "claude/**", "feature/**"]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
# The pure headers — gate_logic.h, measurement_json.h, idle_state.h — carry
14+
# the logic that is impractical to exercise on a hive: debounce timing, the
15+
# exact bytes HiveHub parses, and night-mode deadlines across a millis()
16+
# rollover. They need nothing but a host compiler, so there is no reason for
17+
# them to run only on someone's laptop.
18+
host-tests:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
- name: Build and run host logic tests
23+
run: ./Firmware/test/run_tests.sh
24+
25+
# The production image must compile from a clean dependency cache (every
26+
# library is pinned in Firmware/platformio.ini), and the -DIR_DEBUG bench
27+
# console must keep compiling too: it has no environment of its own, so
28+
# nothing else would ever catch it going stale.
29+
firmware-build:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
- uses: actions/setup-python@v5
34+
with:
35+
python-version: "3.12"
36+
- name: Install PlatformIO
37+
run: pip install platformio
38+
# The bench console runs first so the production image is what ends up in
39+
# .pio/build for the upload below — an IR_DEBUG binary published as the
40+
# release artifact would be flashable, plausible and wrong.
41+
- name: Build the IR_DEBUG bench console
42+
env:
43+
PLATFORMIO_BUILD_FLAGS: -DIR_DEBUG
44+
run: pio run
45+
working-directory: Firmware
46+
- name: Build production image
47+
run: pio run
48+
working-directory: Firmware
49+
- name: Upload firmware image
50+
uses: actions/upload-artifact@v4
51+
with:
52+
name: hivetraffic-esp32-c6
53+
# rename_firmware.py names this hivetraffic_esp32-c6_<version>.bin,
54+
# which is the name HiveHub's upload form parses; keep it as produced.
55+
path: Firmware/.pio/build/seeed_xiao_esp32c6/hivetraffic_*.bin
56+
if-no-files-found: error

Firmware/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ Press a single key in the serial monitor:
179179
| `1` | Force IR LEDs ON (steady) |
180180
| `0` | Force IR LEDs OFF |
181181
| `a` | IR LEDs AUTO (normal pulsed mode) |
182+
| `n` | Arm / clear a 60 s night-mode suspension (press again to resume) |
182183
| `h` | Show the command list |
183184

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

278286
Everything hardware-facing stays in `src/main.cpp` and is still verified on the
279287
bench with the IR-sensor console above.

Firmware/include/ble_link.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,25 @@ struct Telemetry {
2525
uint32_t total_out;
2626
// 32-bit since protocol v3; saturating, never wrapping (gate_logic.h).
2727
uint32_t glitch_count;
28+
// Seconds of night-mode suspension still to run, 0 when counting. New in
29+
// protocol v4, alongside STATUS_NIGHT_IDLE. It is what lets HiveHub — and
30+
// anyone reading the stored history later — tell "no bees flew" from "this
31+
// counter was deliberately not looking", which the totals alone cannot say.
32+
uint32_t idle_s;
2833
};
2934

3035
void getTelemetry(Telemetry& out);
36+
37+
// Apply a night-mode request written to the control characteristic.
38+
// Implemented in main.cpp, where the suspension state and the emitters live;
39+
// declared here because ble_link.cpp is what receives the write.
40+
// `duration_s` of 0 resumes sensing. Returns the duration actually granted
41+
// after clamping to beecounter_proto::MAX_IDLE_SECONDS.
42+
uint32_t applyIdleRequest(uint32_t duration_s);
43+
44+
// Seconds of suspension left, for the control characteristic's read-back.
45+
uint32_t idleRemainingSeconds();
46+
3147
void begin();
3248
bool isOtaActive();
3349
void loopOta();

Firmware/include/counter_protocol.h

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,17 @@ namespace beecounter_proto {
3737
// v3 = uptime_s and glitches widened to 32 bits, and "gates_healthy" renamed to
3838
// "mcps_healthy" to say what it has always counted. See the revision
3939
// history in docs/ble-mode.md for the full delta.
40+
// v4 = adds the "idle_s" field and the STATUS_NIGHT_IDLE bit, so a document
41+
// says whether the counter is deliberately not sensing (night mode) and
42+
// for how much longer. Without it a night of zero crossings is
43+
// indistinguishable from a counter whose emitters have failed.
4044
//
41-
// A counter in the field keeps emitting v2 until it is updated over the air,
42-
// and the OTA relay has to read this very characteristic before it can update
43-
// anything — so HiveHub's parser reads "fw" first and accepts both revisions.
44-
// Its tolerant parser must be deployed BEFORE any counter emitting v3.
45-
constexpr uint8_t PROTOCOL_VERSION = 3;
45+
// A counter in the field keeps emitting an older revision until it is updated
46+
// over the air, and the OTA relay has to read this very characteristic before
47+
// it can update anything — so HiveHub's parser reads "fw" first and accepts
48+
// every revision. Its tolerant parser must be deployed BEFORE any counter
49+
// emitting the new one.
50+
constexpr uint8_t PROTOCOL_VERSION = 4;
4651

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

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

8293
constexpr uint8_t OTA_ERR_NONE = 0x00;
8394

95+
// --------------------------------------------------------------------------
96+
// Control characteristic — night mode / sensing suspension
97+
// --------------------------------------------------------------------------
98+
// The counter has never had an input other than OTA. This is the second one,
99+
// and it is deliberately the smallest thing that can express "stop sensing":
100+
// HiveHub writes a DURATION, never a schedule and never a wall-clock time.
101+
//
102+
// Why a deadline rather than a schedule
103+
// -------------------------------------
104+
// The counter has no RTC, no NVS and no idea what time it is; giving it a
105+
// 20:00-06:00 window would mean teaching it all three, and every one of them is
106+
// a way for a counter to end up permanently blind on its own. A duration cannot
107+
// do that: it expires. HiveHub knows the time (NTP + a DS3231 at +/-2 ppm) and
108+
// re-arms the idle window once per upload cycle, so the counter's own clock
109+
// only has to be right for one cycle at a time and nothing accumulates.
110+
//
111+
// Everything about this is fail-open. An idle request is capped at
112+
// MAX_IDLE_SECONDS; the state is never persisted, so any reset resumes
113+
// counting; and a HiveHub that stops calling simply lets the deadline run out.
114+
// The failure mode of the whole feature is "the counter counts", which is the
115+
// behaviour it had before this existed.
116+
constexpr uint8_t CTRL_OP_SET_IDLE = 0x01; // + duration_s (4 LE)
117+
constexpr uint8_t CTRL_OP_RESUME = 0x02; // no payload: sense again now
118+
119+
// Longest suspension the counter will accept, whatever HiveHub asks for. One
120+
// hour is several times HiveHub's default 10-minute upload cycle — enough that
121+
// a couple of missed cycles do not wake the emitters up in the middle of the
122+
// night — while bounding how long a counter can stay blind after HiveHub falls
123+
// off the air entirely. A longer request is CLAMPED to this rather than
124+
// refused: refusing would leave the emitters running all night because one
125+
// field was too large.
126+
constexpr uint32_t MAX_IDLE_SECONDS = 3600;
127+
128+
// Control status, as read back from the control characteristic:
129+
// state(1) + remaining_s(4 LE)
130+
// state is one of the two below; remaining_s is 0 unless idle.
131+
constexpr uint8_t CTRL_STATE_SENSING = 0x00;
132+
constexpr uint8_t CTRL_STATE_IDLE = 0x01;
133+
134+
// Bytes in that read-back value.
135+
constexpr uint8_t CTRL_STATUS_LENGTH = 5;
136+
137+
// Bytes in a well-formed SET_IDLE write (opcode + uint32 LE).
138+
constexpr uint8_t CTRL_SET_IDLE_LENGTH = 5;
139+
84140
} // namespace beecounter_proto

Firmware/include/idle_state.h

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// ============================================================================
2+
// idle_state.h — night-mode suspension, as pure deadline arithmetic
3+
// ============================================================================
4+
//
5+
// Arduino-free for the same reason gate_logic.h and measurement_json.h are: the
6+
// interesting part of "stop counting until told otherwise" is entirely about
7+
// clock arithmetic that is awkward to reproduce on a bench and impossible to
8+
// reproduce on a hive. It is exercised by test/test_idle_state/ on a host
9+
// compiler; src/main.cpp owns the emitters and the poll loop and calls in here
10+
// to decide whether to run them.
11+
//
12+
// The model
13+
// ---------
14+
// The counter never learns what time it is. HiveHub writes SET_IDLE with a
15+
// DURATION, this header turns that into a millis() deadline, and every poll
16+
// asks whether the deadline has passed. Three properties fall out of that and
17+
// they are the whole design:
18+
//
19+
// 1. **It expires.** A HiveHub that crashes, loses power, or is carried away
20+
// cannot leave a counter suspended: the deadline runs out and sensing
21+
// resumes on its own. Compare a stored 20:00-06:00 schedule, which stays
22+
// wrong until someone walks to the hive.
23+
// 2. **It is bounded.** Requests are clamped to MAX_IDLE_SECONDS rather than
24+
// refused, so a malformed or over-eager duration costs a re-arm next
25+
// cycle, not a night of running emitters.
26+
// 3. **It is not persistent.** There is no NVS write and no RTC-memory copy.
27+
// Any reset — brownout, OTA, watchdog — comes back counting.
28+
//
29+
// millis() rollover
30+
// -----------------
31+
// Deadlines are compared with signed differences, like the rest of this
32+
// firmware, so the ~49.7-day millis() wrap is a non-event: (int32_t)(now -
33+
// deadline) >= 0 stays correct across it as long as the interval itself is
34+
// shorter than half the counter's range, which MAX_IDLE_SECONDS (1 h) is by
35+
// four orders of magnitude.
36+
// ============================================================================
37+
38+
#pragma once
39+
40+
#include <stdint.h>
41+
42+
#include "counter_protocol.h"
43+
44+
namespace idlestate {
45+
46+
// Suspension state. Default-constructed is "sensing", which is what a freshly
47+
// booted counter must always be.
48+
struct State {
49+
bool active = false;
50+
uint32_t deadline_ms = 0; // only meaningful while active
51+
};
52+
53+
// Result of a SET_IDLE request, so the caller can log what it actually did
54+
// rather than what it was asked to do.
55+
struct Request {
56+
uint32_t granted_s = 0; // duration actually applied, after clamping
57+
bool clamped = false;
58+
};
59+
60+
// Arm (or re-arm) the suspension for `duration_s` seconds from `now_ms`.
61+
//
62+
// A zero duration resumes sensing immediately — that is the same thing
63+
// CTRL_OP_RESUME does, and accepting it here means HiveHub can express "not
64+
// tonight" by re-arming with 0 rather than needing a second opcode on a path
65+
// where it already has one.
66+
//
67+
// Re-arming while already idle is the normal case, not an edge case: HiveHub
68+
// pushes a fresh deadline every upload cycle for as long as the night window
69+
// lasts, so the deadline moves forward roughly every 10 minutes and the counter
70+
// stays suspended without any single request having to cover the whole night.
71+
inline Request request(State& s, uint32_t now_ms, uint32_t duration_s) {
72+
Request r;
73+
r.clamped = duration_s > beecounter_proto::MAX_IDLE_SECONDS;
74+
r.granted_s = r.clamped ? beecounter_proto::MAX_IDLE_SECONDS : duration_s;
75+
76+
if (r.granted_s == 0) {
77+
s.active = false;
78+
s.deadline_ms = 0;
79+
return r;
80+
}
81+
s.active = true;
82+
s.deadline_ms = now_ms + r.granted_s * 1000UL;
83+
return r;
84+
}
85+
86+
// Resume sensing now, discarding any deadline.
87+
inline void resume(State& s) {
88+
s.active = false;
89+
s.deadline_ms = 0;
90+
}
91+
92+
// Has an armed suspension run out? False when not suspended at all.
93+
inline bool expired(const State& s, uint32_t now_ms) {
94+
if (!s.active) return false;
95+
return (int32_t)(now_ms - s.deadline_ms) >= 0;
96+
}
97+
98+
// Clear the suspension if its deadline has passed. Returns true exactly on the
99+
// poll that ends it, so the caller can do its one-off resume work (resetting
100+
// the gate state machines) without tracking the edge itself.
101+
inline bool serviceExpiry(State& s, uint32_t now_ms) {
102+
if (!expired(s, now_ms)) return false;
103+
resume(s);
104+
return true;
105+
}
106+
107+
// Whether sensing should run right now.
108+
inline bool sensing(const State& s) {
109+
return !s.active;
110+
}
111+
112+
// Seconds left on the suspension, rounded UP so a live suspension never reports
113+
// zero (which reads as "sensing" to HiveHub and would make it re-arm a beat
114+
// early). Zero means not suspended.
115+
inline uint32_t remainingSeconds(const State& s, uint32_t now_ms) {
116+
if (!s.active) return 0;
117+
const int32_t left_ms = (int32_t)(s.deadline_ms - now_ms);
118+
if (left_ms <= 0) return 0;
119+
return ((uint32_t)left_ms + 999UL) / 1000UL;
120+
}
121+
122+
} // namespace idlestate

Firmware/include/measurement_json.h

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// beecounter_proto::PROTOCOL_VERSION. Keep the three in step: a field added
1313
// here is a field HiveHub's parser has to learn, in the same revision.
1414
//
15-
// snprintf rather than a JSON library: the document is a flat object of nine
15+
// snprintf rather than a JSON library: the document is a flat object of ten
1616
// fixed keys with no nesting, no arrays and no escaping to do, so there is
1717
// nothing for a parser-builder to buy us at the cost of heap churn on every
1818
// GATT read.
@@ -30,22 +30,23 @@ namespace beecounter_proto {
3030

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

5152
// Serialize `t` plus the image version string into `out`.
@@ -67,7 +68,7 @@ inline int buildMeasurementJson(char* out, unsigned capacity,
6768
out, capacity,
6869
"{\"fw\":%u,\"ver\":\"%s\",\"uptime_s\":%lu,\"status\":%u,"
6970
"\"num_gates\":%u,\"mcps_healthy\":%u,\"total_in\":%lu,"
70-
"\"total_out\":%lu,\"glitches\":%lu}",
71+
"\"total_out\":%lu,\"glitches\":%lu,\"idle_s\":%lu}",
7172
static_cast<unsigned>(t.protocol_version),
7273
fw_version ? fw_version : "",
7374
static_cast<unsigned long>(t.uptime_s),
@@ -76,7 +77,8 @@ inline int buildMeasurementJson(char* out, unsigned capacity,
7677
static_cast<unsigned>(t.mcps_healthy),
7778
static_cast<unsigned long>(t.total_in),
7879
static_cast<unsigned long>(t.total_out),
79-
static_cast<unsigned long>(t.glitch_count));
80+
static_cast<unsigned long>(t.glitch_count),
81+
static_cast<unsigned long>(t.idle_s));
8082
if (length <= 0 || static_cast<unsigned>(length) >= capacity) return -1;
8183
return length;
8284
}

Firmware/include/version.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@
1414
// and it is the only way to confirm afterwards that an update actually took.
1515
//
1616
// Bump this on every released image.
17-
#define HIVETRAFFIC_FW_VERSION "0.1.0"
17+
#define HIVETRAFFIC_FW_VERSION "0.2.0"

0 commit comments

Comments
 (0)