From 933b0c9d53402902cf147e8df26bcee5dedd196e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:09:59 +0000 Subject: [PATCH 1/3] firmware: add night mode as a bounded sensing suspension (protocol v4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 48 IR emitters are the counter's power budget: 24 series pairs behind 22 ohm ballast, all lit together for the settle+read window of every 5 ms poll — roughly 0.5-1.0 A peak and, at the pulsed sampler's ~35% duty, an average an order of magnitude above the ~18 mA the C6 and the three expanders draw between them. European honey bees are diurnal (flight needs light, and stops below roughly 10 C regardless), so every night that draw buys nothing. Add a control characteristic (8e8b0103) that HiveHub writes a suspension DURATION to. The counter has no RTC, no NVS and no network, and this change deliberately does not give it any: it is told how long to stay quiet, never when. Everything that matters falls out of that. * It expires. A HiveHub that dies cannot leave a counter blind. * It is capped at MAX_IDLE_SECONDS (1 h), several times HiveHub's upload cycle, so HiveHub re-arms per cycle and nothing accumulates. An over-long request is clamped, not refused — refusing would leave the emitters running all night because one field was too large. * It is never persisted, so any reset comes back counting. * It is refused during an OTA, which already parks the emitters itself. Not deep sleep, which was the obvious implementation. Parking the emitters captures >90% of the saving; sleeping for the rest costs the measurement read (every night row would carry ok=false), the OTA relay path that is specifically recommended for night use, the ability to cancel, the RAM totals, and a truthful uptime_s — and the C6's deep-sleep timer runs off the temperature-dependent internal RC oscillator, drifting minutes to half an hour over a 8-12 h night. The deadline arithmetic is a pure header (idle_state.h) with host tests covering the millis() rollover and the "HiveHub stopped re-arming" case, neither reproducible on a hive. Gate state machines are reset on both edges so a half-finished pairing cannot span the gap and fabricate a crossing. Protocol v4 reports it: idle_s counts the suspension down and status bit 0x80 marks it. Without them a night of zeros is indistinguishable from a counter whose emitter FETs have died. The addition is purely additive, so HiveHub's key-skipping parser reads v4 documents unchanged. Also fixes documentation that has gone stale around this area: README's power budget still claimed the emitters run continuously and could be switched with a CMD_LEDS_OFF that went away with the wired link, and it understated the emitter draw that is the whole reason this feature exists. docs/ble-mode.md's "deep sleep is incompatible" line now says what is actually true. Adds a CI workflow — the repo had none, so neither the host tests nor the ESP32 build were checked anywhere but a laptop. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gFpezSuXW3sHMd1xaSYnd --- .github/workflows/ci.yml | 56 +++++ Firmware/README.md | 8 + Firmware/include/ble_link.h | 16 ++ Firmware/include/counter_protocol.h | 66 +++++- Firmware/include/idle_state.h | 122 +++++++++++ Firmware/include/measurement_json.h | 16 +- Firmware/include/version.h | 2 +- Firmware/src/ble_link.cpp | 86 +++++++- Firmware/src/main.cpp | 136 +++++++++++- Firmware/test/run_tests.sh | 12 +- .../test/test_idle_state/test_idle_state.cpp | 202 ++++++++++++++++++ .../test_measurement_json.cpp | 53 ++++- README.md | 51 +++-- claude.md | 13 +- docs/ble-mode.md | 129 ++++++++++- 15 files changed, 918 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Firmware/include/idle_state.h create mode 100644 Firmware/test/test_idle_state/test_idle_state.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6558071 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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_.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 diff --git a/Firmware/README.md b/Firmware/README.md index 2375526..043ae2f 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -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` @@ -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. diff --git a/Firmware/include/ble_link.h b/Firmware/include/ble_link.h index 7745103..b0f147f 100644 --- a/Firmware/include/ble_link.h +++ b/Firmware/include/ble_link.h @@ -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(); diff --git a/Firmware/include/counter_protocol.h b/Firmware/include/counter_protocol.h index 958a52f..4072fb8 100644 --- a/Firmware/include/counter_protocol.h +++ b/Firmware/include/counter_protocol.h @@ -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 @@ -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 @@ -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 diff --git a/Firmware/include/idle_state.h b/Firmware/include/idle_state.h new file mode 100644 index 0000000..daaabce --- /dev/null +++ b/Firmware/include/idle_state.h @@ -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 + +#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 diff --git a/Firmware/include/measurement_json.h b/Firmware/include/measurement_json.h index 831c824..9acf362 100644 --- a/Firmware/include/measurement_json.h +++ b/Firmware/include/measurement_json.h @@ -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. @@ -30,14 +30,14 @@ 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 @@ -45,7 +45,8 @@ namespace beecounter_proto { // 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`. @@ -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(t.protocol_version), fw_version ? fw_version : "", static_cast(t.uptime_s), @@ -76,7 +77,8 @@ inline int buildMeasurementJson(char* out, unsigned capacity, static_cast(t.mcps_healthy), static_cast(t.total_in), static_cast(t.total_out), - static_cast(t.glitch_count)); + static_cast(t.glitch_count), + static_cast(t.idle_s)); if (length <= 0 || static_cast(length) >= capacity) return -1; return length; } diff --git a/Firmware/include/version.h b/Firmware/include/version.h index a11cf82..66115fe 100644 --- a/Firmware/include/version.h +++ b/Firmware/include/version.h @@ -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" diff --git a/Firmware/src/ble_link.cpp b/Firmware/src/ble_link.cpp index 369b734..969c462 100644 --- a/Firmware/src/ble_link.cpp +++ b/Firmware/src/ble_link.cpp @@ -16,6 +16,12 @@ namespace { constexpr char BLE_DEVICE_NAME[] = "BeeCounter"; constexpr char SVC_BEECOUNTER[] = "8e8b0101-7a1c-4b9e-9a2f-1d6e0b9c1a01"; constexpr char CHR_MEASUREMENT[] = "8e8b0102-7a1c-4b9e-9a2f-1d6e0b9c1a01"; +// Night mode: HiveHub writes a suspension DURATION here, and reads back the +// current state. Deliberately a separate characteristic from the OTA control +// one — an image transfer and "stop sensing for a while" have nothing in +// common but a direction, and sharing an opcode space between them would mean +// a malformed OTA frame could suspend counting. +constexpr char CHR_CONTROL[] = "8e8b0103-7a1c-4b9e-9a2f-1d6e0b9c1a01"; constexpr char CHR_OTA_CTRL[] = "8e8b0110-7a1c-4b9e-9a2f-1d6e0b9c1a01"; constexpr char CHR_OTA_DATA[] = "8e8b0111-7a1c-4b9e-9a2f-1d6e0b9c1a01"; constexpr char CHR_OTA_STATUS[] = "8e8b0113-7a1c-4b9e-9a2f-1d6e0b9c1a01"; @@ -233,6 +239,79 @@ class OtaStatusCallbacks : public NimBLECharacteristicCallbacks { } }; +// Night-mode control. The parsing is intentionally strict: a write that is not +// exactly a known opcode of the right length is ignored rather than guessed at, +// because every misreading of this characteristic costs counted bees. +class ControlCallbacks : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo&) override { + const NimBLEAttValue value = characteristic->getValue(); + if (value.size() == 0) return; + const uint8_t* data = value.data(); + + switch (data[0]) { + case CTRL_OP_SET_IDLE: { + if (value.size() != CTRL_SET_IDLE_LENGTH) { + Serial.printf("[BLE-CTRL] SET_IDLE ignored: %u bytes, expected %u\n", + (unsigned)value.size(), + (unsigned)CTRL_SET_IDLE_LENGTH); + return; + } + // Refusing during an OTA is not caution, it is correctness: the + // transfer already parks the emitters and pauses polling, and a + // suspension armed underneath it would still be running when the + // counter reboots into the new image — except it would not, because + // the state is not persisted. Rejecting keeps the two mechanisms + // from having an opinion about each other at all. + if (isOtaActive()) { + Serial.println(F("[BLE-CTRL] SET_IDLE refused: OTA in progress")); + return; + } + const uint32_t duration_s = + (uint32_t)data[1] | ((uint32_t)data[2] << 8) | + ((uint32_t)data[3] << 16) | ((uint32_t)data[4] << 24); + const uint32_t granted = applyIdleRequest(duration_s); + if (granted != duration_s) { + Serial.printf("[BLE-CTRL] idle %lus requested, %lus granted (cap %lus)\n", + (unsigned long)duration_s, (unsigned long)granted, + (unsigned long)MAX_IDLE_SECONDS); + } else if (granted == 0) { + Serial.println(F("[BLE-CTRL] idle 0s: sensing resumed")); + } else { + Serial.printf("[BLE-CTRL] sensing suspended for %lus\n", + (unsigned long)granted); + } + break; + } + case CTRL_OP_RESUME: + if (value.size() != 1) { + Serial.printf("[BLE-CTRL] RESUME ignored: %u bytes, expected 1\n", + (unsigned)value.size()); + return; + } + applyIdleRequest(0); + Serial.println(F("[BLE-CTRL] sensing resumed")); + break; + default: + Serial.printf("[BLE-CTRL] unknown opcode 0x%02X ignored\n", + (unsigned)data[0]); + break; + } + } + + // Built on read, like the measurement document, so a polling client always + // sees the live remaining time rather than whatever the last write left. + void onRead(NimBLECharacteristic* characteristic, NimBLEConnInfo&) override { + const uint32_t remaining = idleRemainingSeconds(); + uint8_t value[CTRL_STATUS_LENGTH]; + value[0] = remaining > 0 ? CTRL_STATE_IDLE : CTRL_STATE_SENSING; + value[1] = static_cast(remaining); + value[2] = static_cast(remaining >> 8); + value[3] = static_cast(remaining >> 16); + value[4] = static_cast(remaining >> 24); + characteristic->setValue(value, sizeof(value)); + } +}; + class ServerCallbacks : public NimBLEServerCallbacks { void onConnect(NimBLEServer*, NimBLEConnInfo&) override { Serial.println(F("[BLE] HiveHub connected")); @@ -254,6 +333,7 @@ class ServerCallbacks : public NimBLEServerCallbacks { // deleteCallbacks argument is gone) and would have leaked. File-scope // singletons outlive the server by construction — they are stateless anyway. MeasurementCallbacks measurementCallbacks; +ControlCallbacks controlCallbacks; OtaControlCallbacks otaControlCallbacks; OtaDataCallbacks otaDataCallbacks; OtaStatusCallbacks otaStatusCallbacks; @@ -275,8 +355,12 @@ void begin() { refreshMeasurement(measurement); NimBLECharacteristic* control = service->createCharacteristic( + CHR_CONTROL, NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE); + control->setCallbacks(&controlCallbacks); + + NimBLECharacteristic* otaControl = service->createCharacteristic( CHR_OTA_CTRL, NIMBLE_PROPERTY::WRITE); - control->setCallbacks(&otaControlCallbacks); + otaControl->setCallbacks(&otaControlCallbacks); NimBLECharacteristic* payload = service->createCharacteristic( CHR_OTA_DATA, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR); payload->setCallbacks(&otaDataCallbacks); diff --git a/Firmware/src/main.cpp b/Firmware/src/main.cpp index 343afec..ea5c7fc 100644 --- a/Firmware/src/main.cpp +++ b/Firmware/src/main.cpp @@ -26,6 +26,25 @@ // which differences consecutive reads into per-interval counts. // 4. Accepts a firmware image over the same GATT service (see ble_link.cpp), // verifying size and CRC-32 before swapping app slots. +// 5. Suspends 1-3 on request (night mode), so the emitters — which dominate +// this board's power draw by an order of magnitude — are dark through the +// hours European honey bees do not fly. See the section below. +// +// Night mode (protocol v4) +// ------------------------ +// Honey bees are diurnal: flight needs light, and stops below roughly 10 C +// regardless. Sampling 24 gates through the night therefore burns the largest +// item in the power budget to count nothing. HiveHub — which knows the time, +// and this board does not — writes a suspension DURATION to the control +// characteristic once per upload cycle for as long as its configured night +// window lasts; idle_state.h turns that into a deadline and this file stops +// pulsing and polling until it expires. +// +// It is deliberately NOT deep sleep. The emitters are >90% of the draw, so +// parking them captures essentially the whole saving, while staying awake keeps +// the counter readable, updatable and cancellable all night — none of which +// survives deep sleep — and keeps the lifetime totals in RAM where they belong. +// See docs/ble-mode.md for the reasoning and the numbers. // // The wired HiveScale link is gone // -------------------------------- @@ -50,6 +69,7 @@ #include "pins.h" #include "counter_protocol.h" #include "gate_logic.h" +#include "idle_state.h" // The BLE/GATT transport: a connectable NimBLE GATT server serving the // measurement characteristic and the OTA characteristics. See ble_link.h. @@ -182,6 +202,11 @@ static volatile uint32_t g_total_out = 0; static volatile uint32_t g_glitch_count = 0; static volatile uint8_t g_status_flags = 0; +// Night-mode suspension. Owned here because this is where the emitters and the +// poll loop live; armed from the BLE control characteristic through +// ble::applyIdleRequest() below. Never persisted: a reset resumes counting. +static idlestate::State g_idle; + // ============================================================================ // MCP23017 channels + runtime health (all on Wire / bus 0) // ============================================================================ @@ -320,6 +345,17 @@ static void resetGatesForMcp(uint8_t addr) { } } +// Drop every gate back to IDLE. Used on both edges of a night-mode suspension: +// a gate that was half-way through a pairing when sensing stopped must not +// combine that stale half with the first sample taken hours later and fabricate +// a crossing. Same reasoning as resetGatesForMcp() across a chip outage — the +// lifetime totals are untouched either way. +static void resetAllGates() { + for (uint8_t i = 0; i < gates::NUM_GATES; i++) { + g_gate_rt[i] = gatelogic::GateRuntime(); + } +} + // One checked 2-byte read of GPIOA/GPIOB. Unlike readGPIOAB() this reports bus // failures instead of silently returning 0x0000 ("all blocked"). static bool readMcpChecked(uint8_t idx, uint16_t& out) { @@ -532,6 +568,7 @@ static bool pollAllGates() { // 1 force IR LEDs ON (steady) // 0 force IR LEDs OFF // a IR LEDs AUTO (normal pulsed mode) +// n arm a 60 s night-mode suspension (press again to resume) // h print the command list // ============================================================================ #ifdef IR_DEBUG @@ -549,6 +586,7 @@ static void irDebugPrintHelp() { Serial.println(F(" 1 force IR LEDs ON (steady)")); Serial.println(F(" 0 force IR LEDs OFF")); Serial.println(F(" a IR LEDs AUTO (pulsed, normal mode)")); + Serial.println(F(" n arm/clear a 60 s night-mode suspension")); Serial.println(F(" h show this help")); Serial.println(); } @@ -648,6 +686,17 @@ static void irDebugPoll() { setIrLeds(false); Serial.println(F("[IR-DEBUG] IR LEDs -> AUTO (pulsed)")); break; + case 'n': { + // Exercise the night-mode path without a HiveHub: 60 s is long + // enough to watch the emitters go dark and the counts freeze, short + // enough that a forgotten bench board resumes on its own. + const bool idle_now = !idlestate::sensing(g_idle); + const uint32_t granted = ble::applyIdleRequest(idle_now ? 0 : 60); + Serial.printf("[IR-DEBUG] night mode %s (%lus)\n", + granted ? "ARMED" : "cleared", + (unsigned long)granted); + break; + } case 'h': case '?': irDebugPrintHelp(); @@ -691,14 +740,56 @@ void getTelemetry(Telemetry& t) { for (uint8_t i = 0; i < NUM_MCP; i++) { if (g_mcp[i].ok) healthy++; } + // Derived here, not read straight out of g_status_flags, so the bit and the + // countdown can never disagree in a published document: the deadline can + // pass between a GATT read and the loop() pass that services the expiry, + // and "STATUS_NIGHT_IDLE set, idle_s 0" is a contradiction HiveHub would + // have to guess its way out of. + const uint32_t idle_left = idlestate::remainingSeconds(g_idle, millis()); + const uint8_t status = idle_left + ? (uint8_t)(g_status_flags | beecounter_proto::STATUS_NIGHT_IDLE) + : (uint8_t)(g_status_flags & ~beecounter_proto::STATUS_NIGHT_IDLE); + t.protocol_version = beecounter_proto::PROTOCOL_VERSION; - t.status_flags = g_status_flags; + t.status_flags = status; t.uptime_s = up; t.num_gates = gates::NUM_GATES; t.mcps_healthy = healthy; t.total_in = g_total_in; t.total_out = g_total_out; t.glitch_count = g_glitch_count; + t.idle_s = idle_left; +} + +uint32_t applyIdleRequest(uint32_t duration_s) { + const bool was_sensing = idlestate::sensing(g_idle); + const idlestate::Request r = + idlestate::request(g_idle, millis(), duration_s); + + if (idlestate::sensing(g_idle)) { + // Resumed (or refused to start): clear the flag and drop any pairing + // state that predates the gap before the next poll can build on it. + if (!was_sensing) { + resetAllGates(); + Serial.println(F("[IDLE] sensing resumed on request")); + } + g_status_flags &= (uint8_t)~beecounter_proto::STATUS_NIGHT_IDLE; + } else { + // Entering. Park the emitters immediately rather than waiting for the + // next poll to not run — the whole point of the feature is the current + // they draw. Re-arming an already-idle counter is the common case and + // must not reset the gates again: nothing has sampled in between. + if (was_sensing) { + resetAllGates(); + driveIrLeds(false); + } + g_status_flags |= beecounter_proto::STATUS_NIGHT_IDLE; + } + return r.granted_s; +} + +uint32_t idleRemainingSeconds() { + return idlestate::remainingSeconds(g_idle, millis()); } } // namespace ble @@ -780,8 +871,37 @@ void loop() { // An OTA pauses sensing: flash writes must not leave an emitter pulse // active or corrupt a crossing in progress. Counting is deliberately // sacrificed for the duration of a transfer. + // Night mode ends on its own deadline, checked here rather than inside the + // poll branch so it still expires while an OTA is running — otherwise a + // relay that straddles sunrise would leave the counter suspended until the + // next HiveHub cycle re-armed or cleared it. + if (idlestate::serviceExpiry(g_idle, now)) { + // Nothing has sampled since the suspension began, so any pairing state + // still on a gate is hours stale. + resetAllGates(); + g_status_flags &= (uint8_t)~beecounter_proto::STATUS_NIGHT_IDLE; + Serial.println(F("[IDLE] suspension expired; counting again")); + } + if (ble::isOtaActive()) { driveIrLeds(false); + } else if (!idlestate::sensing(g_idle)) { + // Night mode: emitters dark, gates unpolled, totals frozen. + // + // The re-assert runs on the poll cadence rather than every loop pass. + // It is here as a backstop — applyIdleRequest() already parked the + // emitters on entry — so that nothing else (a FORCE_ON left set from a + // bench session, a future code path) can leave a bank lit through the + // night; doing it at full loop speed would spend the CPU cycles this + // mode exists to save. + if (now - g_last_poll_ms >= POLL_INTERVAL_MS) { + g_last_poll_ms = now; + driveIrLeds(false); + } + // Yield: with no gates to poll there is nothing here to be responsive + // to except BLE, which runs on its own task. A bare spin would burn the + // MCU's share of the budget for the whole night. + delay(1); } else if (now - g_last_poll_ms >= POLL_INTERVAL_MS) { g_last_poll_ms = now; pollAllGates(); // pulses the emitters internally in AUTO mode @@ -793,7 +913,14 @@ void loop() { static LedMode last_led_mode = LedMode::AUTO; if (g_led_mode != last_led_mode) { last_led_mode = g_led_mode; - setIrLeds(true); // setIrLeds() applies the mode-correct steady level + // Suspended: leave the emitters dark whatever the mode says. FORCE_ON + // exists for bench work and must not be able to undo night mode from a + // debug console that was left in that state. + if (idlestate::sensing(g_idle)) { + setIrLeds(true); // setIrLeds() applies the mode-correct steady level + } else { + driveIrLeds(false); + } } // Periodic debug dump on serial -- once every 30 s. @@ -802,12 +929,13 @@ void loop() { last_dump_ms = now; Serial.printf( "[STAT] uptime=%lus total_in=%lu total_out=%lu " - "glitches=%lu status=0x%02X\n", + "glitches=%lu status=0x%02X idle=%lus\n", (unsigned long)(now / 1000), (unsigned long)g_total_in, (unsigned long)g_total_out, (unsigned long)g_glitch_count, - (unsigned)g_status_flags + (unsigned)g_status_flags, + (unsigned long)idlestate::remainingSeconds(g_idle, now) ); } } diff --git a/Firmware/test/run_tests.sh b/Firmware/test/run_tests.sh index 5aff4b3..43e52fd 100755 --- a/Firmware/test/run_tests.sh +++ b/Firmware/test/run_tests.sh @@ -8,7 +8,10 @@ # machine, and the saturating counters; # * include/measurement_json.h — the exact bytes of the BLE measurement # characteristic, i.e. HiveHub's half of the -# wire contract. +# wire contract; +# * include/idle_state.h — the night-mode suspension deadline, including +# the millis() rollover and the "HiveHub stopped +# re-arming" case. # # Everything hardware-facing stays in src/main.cpp and is still verified on the # bench (see the IR_DEBUG console in the README). @@ -37,3 +40,10 @@ trap 'rm -rf "$OUT"' EXIT -o "$OUT/test_measurement_json" "$OUT/test_measurement_json" + +"$CXX" -std=c++11 -Wall -Wextra -Werror \ + -I include \ + test/test_idle_state/test_idle_state.cpp \ + -o "$OUT/test_idle_state" + +"$OUT/test_idle_state" diff --git a/Firmware/test/test_idle_state/test_idle_state.cpp b/Firmware/test/test_idle_state/test_idle_state.cpp new file mode 100644 index 0000000..df3fd85 --- /dev/null +++ b/Firmware/test/test_idle_state/test_idle_state.cpp @@ -0,0 +1,202 @@ +// ============================================================================ +// Host-side tests for include/idle_state.h +// ============================================================================ +// +// Night mode is a feature whose failure mode is silent: a counter that stays +// suspended when it should be counting reports perfectly well-formed documents +// full of zeros, and nothing about a hive in August distinguishes that from a +// stretch of bad weather until someone reads the totals a week later. The +// deadline arithmetic is therefore pinned here rather than trusted to a bench +// session, including the two cases that are impractical to reproduce on real +// hardware: the millis() rollover, and an idle window that outlives HiveHub. +// +// c++ -std=c++11 -I include +// test/test_idle_state/test_idle_state.cpp -o /tmp/t && /tmp/t +// +// or via test/run_tests.sh, which builds every host test. +// ============================================================================ + +#include "idle_state.h" + +#include +#include + +using namespace idlestate; +using beecounter_proto::MAX_IDLE_SECONDS; + +static int g_failures = 0; +static const char* g_case = ""; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL %s:%d [%s] %s\n", __FILE__, __LINE__, \ + g_case, #cond); \ + ++g_failures; \ + } \ + } while (0) + +// -------------------------------------------------------------------------- + +static void test_default_is_sensing() { + // A counter that boots suspended is a counter that never counts again if + // the request that suspended it came from a HiveHub now out of range. + g_case = "a fresh state senses"; + State s; + CHECK(sensing(s)); + CHECK(remainingSeconds(s, 0) == 0); + CHECK(!expired(s, 0)); + CHECK(!expired(s, 0xFFFFFFFFUL)); +} + +static void test_request_suspends_until_the_deadline() { + g_case = "a request suspends for exactly its duration"; + State s; + const Request r = request(s, 1000, 600); + CHECK(r.granted_s == 600); + CHECK(!r.clamped); + CHECK(!sensing(s)); + CHECK(remainingSeconds(s, 1000) == 600); + + CHECK(!expired(s, 1000 + 599999UL)); // one ms short + CHECK(remainingSeconds(s, 1000 + 599999UL) == 1); + CHECK(expired(s, 1000 + 600000UL)); // exactly on it + CHECK(expired(s, 1000 + 600001UL)); +} + +static void test_zero_duration_resumes() { + // HiveHub expresses "not tonight after all" by re-arming with 0 on the + // connection it already has, rather than needing a second round trip. + g_case = "a zero-second request resumes sensing"; + State s; + request(s, 1000, 600); + CHECK(!sensing(s)); + const Request r = request(s, 2000, 0); + CHECK(r.granted_s == 0); + CHECK(!r.clamped); + CHECK(sensing(s)); + CHECK(remainingSeconds(s, 2000) == 0); +} + +static void test_explicit_resume() { + g_case = "resume() clears an armed suspension"; + State s; + request(s, 1000, 600); + resume(s); + CHECK(sensing(s)); + CHECK(remainingSeconds(s, 1000) == 0); + CHECK(!expired(s, 0xFFFFFFFFUL)); +} + +static void test_oversized_request_is_clamped_not_refused() { + // Refusing would leave the emitters running all night because one field was + // too large — the opposite of what the request was for. Clamping costs one + // re-arm at the cap. + g_case = "an over-long request is clamped to the cap"; + State s; + const Request r = request(s, 0, 86400); // a whole day + CHECK(r.clamped); + CHECK(r.granted_s == MAX_IDLE_SECONDS); + CHECK(!sensing(s)); + CHECK(remainingSeconds(s, 0) == MAX_IDLE_SECONDS); + CHECK(expired(s, MAX_IDLE_SECONDS * 1000UL)); +} + +static void test_cap_is_shorter_than_a_night() { + // The cap is load-bearing: it is what bounds how long a counter stays blind + // after HiveHub falls off the air. If it ever grew past a night, a single + // request could strand a counter until morning with nothing to re-arm it. + g_case = "the cap cannot cover a whole night"; + CHECK(MAX_IDLE_SECONDS <= 3600); + CHECK(MAX_IDLE_SECONDS > 0); +} + +static void test_rearming_extends_the_window() { + // The normal night: HiveHub pushes a fresh deadline every upload cycle, so + // no single request ever has to cover more than one cycle. + g_case = "re-arming each cycle keeps a long night suspended"; + State s; + uint32_t now = 5000; + request(s, now, 900); // 15 min, HiveHub's 10 min + margin + for (int cycle = 0; cycle < 40; ++cycle) { // ~6.7 h of 10-min cycles + now += 600000UL; // one upload cycle later + CHECK(!expired(s, now)); // the old deadline has not run out + request(s, now, 900); // re-armed + CHECK(!sensing(s)); + } + // HiveHub stops re-arming (sunrise, or it died). The counter frees itself. + now += 900000UL; + CHECK(expired(s, now)); + CHECK(serviceExpiry(s, now)); + CHECK(sensing(s)); +} + +static void test_service_expiry_fires_once() { + // The caller uses the return value as a one-shot edge to reset the gate + // state machines. A second true would reset gates mid-count. + g_case = "serviceExpiry is an edge, not a level"; + State s; + request(s, 0, 10); + CHECK(!serviceExpiry(s, 5000)); // still inside the window + CHECK(!sensing(s)); + CHECK(serviceExpiry(s, 10000)); // the one poll that ends it + CHECK(sensing(s)); + CHECK(!serviceExpiry(s, 10001)); // and never again + CHECK(!serviceExpiry(s, 999999UL)); +} + +static void test_remaining_rounds_up() { + // A live suspension reporting 0 would read as "sensing" to HiveHub and to + // anyone reading idle_s out of stored history. + g_case = "remaining never rounds a live suspension down to zero"; + State s; + request(s, 0, 10); + CHECK(remainingSeconds(s, 9999) == 1); // 1 ms left + CHECK(remainingSeconds(s, 9001) == 1); // 999 ms left + CHECK(remainingSeconds(s, 9000) == 1); + CHECK(remainingSeconds(s, 8999) == 2); + CHECK(remainingSeconds(s, 10000) == 0); // genuinely over + CHECK(remainingSeconds(s, 20000) == 0); // and past it +} + +static void test_millis_rollover() { + // millis() wraps every ~49.7 days. An unsigned comparison would make a + // suspension armed just before the wrap look already-expired (resuming the + // emitters in the middle of the night) or never-expiring, depending on + // which side of the subtraction wrapped. Signed differences hold. + g_case = "a suspension armed across the millis() wrap behaves"; + const uint32_t near_wrap = 0xFFFFFF00UL; // 256 ms before the wrap + State s; + request(s, near_wrap, 600); // deadline is past the wrap + CHECK(!sensing(s)); + CHECK(!expired(s, near_wrap)); + CHECK(!expired(s, 0)); // the wrap itself + CHECK(!expired(s, 100000)); // 100 s in, still suspended + CHECK(remainingSeconds(s, 0) == 600); // 256 ms rounds up + // Written as runtime arithmetic on a uint32_t: the wrap is the point of the + // test, and spelling it as a constant expression is a compile-time overflow. + uint32_t past_deadline = near_wrap; + past_deadline += 600000UL; + CHECK(expired(s, past_deadline)); // wrapped, and past the deadline +} + +int main() { + std::printf("idle_state tests\n"); + test_default_is_sensing(); + test_request_suspends_until_the_deadline(); + test_zero_duration_resumes(); + test_explicit_resume(); + test_oversized_request_is_clamped_not_refused(); + test_cap_is_shorter_than_a_night(); + test_rearming_extends_the_window(); + test_service_expiry_fires_once(); + test_remaining_rounds_up(); + test_millis_rollover(); + + if (g_failures == 0) { + std::printf("all tests passed\n"); + return EXIT_SUCCESS; + } + std::printf("%d check(s) failed\n", g_failures); + return EXIT_FAILURE; +} diff --git a/Firmware/test/test_measurement_json/test_measurement_json.cpp b/Firmware/test/test_measurement_json/test_measurement_json.cpp index c3530e6..8253374 100644 --- a/Firmware/test/test_measurement_json/test_measurement_json.cpp +++ b/Firmware/test/test_measurement_json/test_measurement_json.cpp @@ -65,35 +65,69 @@ static ble::Telemetry nominal() { t.total_in = 100; t.total_out = 95; t.glitch_count = 2; + t.idle_s = 0; return t; } // -------------------------------------------------------------------------- static void test_nominal_document() { - g_case = "nominal v3 document"; + g_case = "nominal v4 document"; char json[MEASUREMENT_JSON_CAPACITY]; const int n = buildMeasurementJson(json, sizeof(json), nominal(), "0.1.0"); CHECK(n > 0); CHECK(std::strcmp( json, - "{\"fw\":3,\"ver\":\"0.1.0\",\"uptime_s\":1234,\"status\":15," + "{\"fw\":4,\"ver\":\"0.1.0\",\"uptime_s\":1234,\"status\":15," "\"num_gates\":24,\"mcps_healthy\":3,\"total_in\":100," - "\"total_out\":95,\"glitches\":2}") == 0); + "\"total_out\":95,\"glitches\":2,\"idle_s\":0}") == 0); CHECK(n == (int)std::strlen(json)); } -static void test_protocol_version_is_three() { +static void test_protocol_version_is_four() { // The version byte is what HiveHub branches on. If this changes without the // parser learning the new revision, every counter goes unreadable. g_case = "fw is the protocol revision, not the image version"; - CHECK(PROTOCOL_VERSION == 3); + CHECK(PROTOCOL_VERSION == 4); char json[MEASUREMENT_JSON_CAPACITY]; buildMeasurementJson(json, sizeof(json), nominal(), "9.9.9"); - containsField(json, "\"fw\":3"); + containsField(json, "\"fw\":4"); containsField(json, "\"ver\":\"9.9.9\""); } +static void test_night_mode_is_visible_in_the_document() { + // The reason idle_s exists: without it, a night of zero crossings and a + // counter whose emitter FETs have died produce identical documents. The + // status bit and the countdown are the only things that separate them. + g_case = "a suspended counter says so"; + ble::Telemetry t = nominal(); + t.status_flags |= STATUS_NIGHT_IDLE; + t.idle_s = 754; + char json[MEASUREMENT_JSON_CAPACITY]; + CHECK(buildMeasurementJson(json, sizeof(json), t, "0.2.0") > 0); + containsField(json, "\"idle_s\":754"); + // 15 (ready + three expanders) | 0x80 = 143. + containsField(json, "\"status\":143"); + + // And a counting one reports the same fields, saying the opposite. + t = nominal(); + CHECK(buildMeasurementJson(json, sizeof(json), t, "0.2.0") > 0); + containsField(json, "\"idle_s\":0"); + containsField(json, "\"status\":15"); +} + +static void test_night_idle_bit_does_not_collide() { + // 0x80 was the last free bit in the status byte. If a future flag reuses + // it, a suspended counter and whatever that flag means become the same + // reading on the wire. + g_case = "STATUS_NIGHT_IDLE owns bit 7 alone"; + CHECK(STATUS_NIGHT_IDLE == 0x80); + const uint8_t others = STATUS_READY | STATUS_MCP_U2_OK | STATUS_MCP_U3_OK | + STATUS_MCP_U4_OK | STATUS_IR_LEDS_ON | + STATUS_SENSOR_FAULT_FLAG | STATUS_OVERFLOW_FLAG; + CHECK((others & STATUS_NIGHT_IDLE) == 0); +} + static void test_uptime_past_the_old_ceiling() { // The whole point of widening the field: a counter up for a week used to // report 65535 forever, so an unexpected reset was undetectable. @@ -152,12 +186,13 @@ static void test_saturated_worst_case_fits_the_buffer() { t.total_in = UINT32_MAX; t.total_out = UINT32_MAX; t.glitch_count = UINT32_MAX; + t.idle_s = UINT32_MAX; char json[MEASUREMENT_JSON_CAPACITY]; const int n = buildMeasurementJson(json, sizeof(json), t, "255.255.255-rc1"); CHECK(n > 0); CHECK(n < (int)MEASUREMENT_JSON_CAPACITY); // Headroom, so a longer version string cannot silently start truncating. - CHECK(n <= 176); + CHECK(n <= 200); std::printf(" worst case is %d of %u bytes\n", n, MEASUREMENT_JSON_CAPACITY); } @@ -185,7 +220,9 @@ static void test_missing_version_string() { int main() { std::printf("measurement_json tests\n"); test_nominal_document(); - test_protocol_version_is_three(); + test_protocol_version_is_four(); + test_night_mode_is_visible_in_the_document(); + test_night_idle_bit_does_not_collide(); test_uptime_past_the_old_ceiling(); test_glitches_past_the_old_ceiling(); test_healthy_count_is_named_for_what_it_counts(); diff --git a/README.md b/README.md index 437b5b7..c3e92f4 100644 --- a/README.md +++ b/README.md @@ -292,17 +292,36 @@ Sensors face upward from the bottom PCB into the channel. The black baffle absor | Component | Current | Count | Total | |---|---|---|---| -| ESP32-C6 mini (active, no WiFi) | 15 mA | 1 | 15 mA | +| ESP32-C6 mini (active, BLE advertising) | 15 mA | 1 | 15 mA | | MCP23017 (active) | 1 mA | 3 | 3 mA | -| IR LEDs (pulsed while counting) | ~bank current | 3 banks | budget per LED string | | Quiescent leakage | — | — | ~1 mA | -| **Bee counter total** | | | **~20 mA + LED draw** | +| **Electronics subtotal** | | | **~19 mA** | +| IR emitters, all banks lit (peak) | ~20–40 mA/gate | 24 gates | **~0.5–1.0 A** | +| IR emitters, pulsed at 35 % duty (average) | | | **~170–350 mA** | | HiveScale ESP32 (sleep) | ~0.05 mA | 1 | 0.05 mA | | HiveScale ESP32 (awake, ~10 min cycle) | 80 mA × 30 s / 600 s | 1 | ~4 mA avg | -The C6 firmware currently keeps the IR LEDs on continuously while counting -(`LedMode::AUTO`); they can be forced off over I²C (`CMD_LEDS_OFF`) for -diagnostics. If average power matters, a future revision can pulse the banks. +**The emitters are the power budget.** Each gate drives two IR LEDs in series +through a 22 Ω ballast off the 3.3 V rail — roughly (3.3 − 2·V_f)/22, i.e. tens +of milliamps per gate — and the pulsed sampler lights all three banks together +for the settle+read window of every 5 ms poll. Everything else on the board +together is under 20 mA, an order of magnitude below. The per-gate figure +depends on the actual LED forward voltage; **measure it on an assembled board** +before sizing a panel or a pack, rather than trusting the range above. + +Two things follow, and both are implemented: + +* **Pulsed emitters** (`LedMode::AUTO`, the default since the 2026-06 revision) + cut the duty cycle from 100 % to ~35 %. Raising `POLL_INTERVAL_MS` lowers it + proportionally. There is no `CMD_LEDS_OFF` — that was an I²C command on the + wired link, which no longer exists; the bench overrides are the `-DIR_DEBUG` + console's `1`/`0`/`a` keys. +* **Night mode** parks the emitters entirely through the hours honey bees do not + fly, on request from HiveHub. Over an 8–12 h night that is the difference + between the counter fitting an off-grid budget and not. See + [`docs/ble-mode.md`](docs/ble-mode.md#night-mode-the-control-characteristic) — + including why it is *not* implemented as deep sleep. + Add solar for indefinite runtime. --- @@ -314,18 +333,20 @@ GATT contract lives in [`docs/ble-mode.md`](docs/ble-mode.md); this is a summary - Advertises as `BeeCounter`; HiveHub connects by the MAC paired in its portal. - One service, `8e8b0101-7a1c-4b9e-9a2f-1d6e0b9c1a01`, holding a READ - measurement characteristic plus three OTA characteristics. + measurement characteristic, a READ/WRITE night-mode control characteristic and + three OTA characteristics. - The measurement value is built on read, so it is never a stale snapshot: ```json -{"fw":3,"ver":"0.1.0","uptime_s":1234,"status":15,"num_gates":24, - "mcps_healthy":3,"total_in":100,"total_out":95,"glitches":2} +{"fw":4,"ver":"0.2.0","uptime_s":1234,"status":15,"num_gates":24, + "mcps_healthy":3,"total_in":100,"total_out":95,"glitches":2,"idle_s":0} ``` `mcps_healthy` counts MCP23017 port expanders (0..3), not gates — each covers -eight of the 24. `fw` is the wire format's revision, not the image version -(`ver`); see [docs/ble-mode.md](docs/ble-mode.md) for the v2 → v3 delta and the -deployment order it requires. +eight of the 24. `idle_s` is the night-mode countdown, `0` while counting. `fw` +is the wire format's revision, not the image version (`ver`); see +[docs/ble-mode.md](docs/ble-mode.md) for the revision history and the deployment +order it requires. ### Totals only @@ -356,8 +377,9 @@ Implemented in `Firmware/` (PlatformIO, `seeed_xiao_esp32c6` env). See all 16 inputs per chip. Read failures are detected, so an expander that goes missing has its gates skipped rather than read as "all beams blocked", and is re-probed until it comes back. -- **BLE/GATT peripheral:** serves lifetime totals as JSON on read, and accepts a - firmware image on the OTA characteristics (`src/ble_link.cpp`). +- **BLE/GATT peripheral:** serves lifetime totals as JSON on read, accepts a + firmware image on the OTA characteristics, and takes a bounded "stop sensing" + request on the control characteristic (`src/ble_link.cpp`). - Per-gate debounce + direction state machine (IDLE → INNER/OUTER_FIRST → PAIRED) emits IN/OUT counts; glitches tallied for diagnostics. The logic lives in `Firmware/include/gate_logic.h` and is covered by host-side tests @@ -374,6 +396,7 @@ Implemented in `Firmware/` (PlatformIO, `seeed_xiao_esp32c6` env). See - Difference consecutive totals server-side to get the interval counts. - Write combined record to SD card; transmit via WiFi if available. - Optionally relay a firmware update to the C6 over BLE (`update_beecounter`). +- Optionally re-arm the counter's night-mode suspension for the next cycle. - Sleep ~10 minutes. --- diff --git a/claude.md b/claude.md index b45af41..54fa481 100644 --- a/claude.md +++ b/claude.md @@ -41,8 +41,9 @@ backend. - `Firmware/` — PlatformIO project for the ESP32-C6 (`board = seeed_xiao_esp32c6`). - `src/main.cpp` — MCP polling loop, per-gate debounce/direction state machine, pulsed IR emitter control, and the `-DIR_DEBUG` serial console. - - `src/ble_link.cpp` — NimBLE GATT peripheral: the measurement characteristic - and the OTA characteristics. + - `src/ble_link.cpp` — NimBLE GATT peripheral: the measurement characteristic, + the night-mode control characteristic and the OTA characteristics. + - `include/idle_state.h` — night-mode suspension as pure deadline arithmetic. - `include/pins.h` — **authoritative** GPIO map for the PCB. - `include/counter_protocol.h` — status bitfield + OTA state codes shared between `main.cpp` and `ble_link.cpp`. @@ -68,6 +69,14 @@ backend. - **Bump `HIVETRAFFIC_FW_VERSION` in `include/version.h` for every released image.** HiveHub refuses an OTA relay that isn't newer, and uses the same field afterwards to confirm the update took. +- **Night mode is a bounded duration, never a schedule.** The counter has no + clock and must never be given one: HiveHub writes "stop sensing for N + seconds", capped at `MAX_IDLE_SECONDS`, and re-arms it each cycle. Every + failure — a dead HiveHub, a malformed write, a reset — has to end with the + counter counting. Do not add persistence, and do not replace it with deep + sleep: the emitters are >90% of the draw, and sleeping costs the measurement + read, the OTA path and the RAM totals for the remainder. See + `docs/ble-mode.md`. - **The counter reports lifetime totals only** — no latch, no reset, no per-interval state on the device. HiveHub differences consecutive reads. This is deliberate: a missed connection can't lose traffic if nothing is diff --git a/docs/ble-mode.md b/docs/ble-mode.md index 6f0b94a..e08791b 100644 --- a/docs/ble-mode.md +++ b/docs/ble-mode.md @@ -24,17 +24,19 @@ advertises as `BeeCounter`, but HiveHub connects by the paired MAC. | Service | `8e8b0101-7a1c-4b9e-9a2f-1d6e0b9c1a01` | | Measurement characteristic | `8e8b0102-7a1c-4b9e-9a2f-1d6e0b9c1a01` | | Properties | READ | +| Control characteristic (night mode) | `8e8b0103-7a1c-4b9e-9a2f-1d6e0b9c1a01` | +| Properties | READ, WRITE | The value is generated when HiveHub reads it, so it contains current lifetime totals rather than a periodically cached snapshot: ```json -{"fw":3,"ver":"0.1.0","uptime_s":1234,"status":15,"num_gates":24,"mcps_healthy":3,"total_in":100,"total_out":95,"glitches":2} +{"fw":4,"ver":"0.2.0","uptime_s":1234,"status":15,"num_gates":24,"mcps_healthy":3,"total_in":100,"total_out":95,"glitches":2,"idle_s":0} ``` | Field | Type | Meaning | | --- | --- | --- | -| `fw` | uint8 | Revision of *this document's* format (`PROTOCOL_VERSION`), currently 3 | +| `fw` | uint8 | Revision of *this document's* format (`PROTOCOL_VERSION`), currently 4 | | `ver` | string | Image version from `include/version.h`, `MAJOR.MINOR.PATCH` | | `uptime_s` | uint32 | Seconds since boot | | `status` | uint8 | Status bitfield, see `include/counter_protocol.h` | @@ -42,6 +44,7 @@ totals rather than a periodically cached snapshot: | `mcps_healthy` | uint8 | MCP23017 expanders currently answering, 0..3 — **not** gates | | `total_in` / `total_out` | uint32 | Monotonic lifetime totals, saturating | | `glitches` | uint32 | Diagnostic tally of ambiguous/aborted pairings, saturating | +| `idle_s` | uint32 | Seconds of night-mode suspension still to run; `0` while counting | The field names, UUIDs, and integer types match HiveHub's `firmware/include/bee_counter_wire.h` parser, which reads `fw` first and @@ -145,8 +148,95 @@ without validating missed-crossing rates on assembled entrance hardware. * Lifetime totals are held in RAM. HiveHub recognizes a backwards total after reboot as a reset, but traffic before the first successful post-reboot read cannot be reconstructed. -* Reflective sensors require continuous sampling, so deep sleep is incompatible - with uninterrupted counting. +* Reflective sensors require continuous sampling, so **uninterrupted** counting + and sleep are incompatible. Night mode (below) is the deliberate exception: + it stops counting on purpose, during hours when there is nothing to count, and + says so in the document. + +## Night mode (the control characteristic) + +Sampling 24 gates costs almost nothing in CPU and almost everything in emitter +current: 48 IR LEDs in 24 series pairs behind 22 R ballast, all lit together for +the settle+read window of every 5 ms poll. That is roughly 0.5-1.0 A peak and, +at the ~35 % duty the pulsed sampler runs, an average draw an order of magnitude +above the ~18 mA the C6 and the three expanders draw between them. On an +off-grid hive it is the entire power budget. + +European honey bees are diurnal. Flight requires light — they will not fly in +darkness at any temperature — and stops below roughly 10 C regardless, so the +emitters spend every night burning the largest item in the budget to count +nothing. Night mode parks them. + +### What the counter knows, and what it does not + +**The counter never learns what time it is.** It has no RTC, no NVS and no +network. HiveHub has all three (NTP plus a DS3231 at +/-2 ppm) and owns the +schedule; the counter is told only *how long* to stay quiet: + +| Frame | Bytes | +| --- | --- | +| SET_IDLE | `0x01 + duration_s(4 LE)` | +| RESUME | `0x02` | +| Read-back | `state(1) + remaining_s(4 LE)`, state `0x00` sensing / `0x01` idle | + +A `SET_IDLE` of `0` means the same thing as `RESUME`, so HiveHub can cancel on a +connection it already has without a second opcode. + +This is the whole design, and every property that matters falls out of it: + +* **It expires.** A HiveHub that crashes, loses power or is carried off cannot + leave a counter blind — the deadline runs out and sensing resumes. A stored + 20:00-06:00 schedule would stay wrong until someone walked to the hive. +* **It is bounded.** `MAX_IDLE_SECONDS` is one hour, several times HiveHub's + default 10-minute cycle. HiveHub re-arms every cycle for as long as its night + window lasts, so no single request ever has to cover a whole night, and the + counter's own clock only has to be right for one cycle at a time. +* **A longer request is clamped, not refused.** Refusing would leave the + emitters running all night because one field was too large. +* **It is never persisted.** Any reset — brownout, watchdog, OTA reboot — comes + back counting. +* **It is refused during an OTA.** A transfer already parks the emitters and + pauses polling; the two mechanisms have no opinion about each other. + +The deadline arithmetic lives in `include/idle_state.h`, free of Arduino, and is +pinned by `test/test_idle_state/` — including the `millis()` rollover and the +"HiveHub stopped re-arming" case, neither of which is practical to reproduce on +a hive. + +### Why not deep sleep + +Deep sleep was the obvious implementation and is the wrong one. It buys the +residual ~18 mA on top of what parking the emitters already saves — under 10 % +of the total — and costs: + +* the counter is **invisible** for 8-12 h: no measurement read, so every night + row carries `bee_counter.ok=false`; +* **no OTA**, which contradicts the standing advice to relay firmware at night + (see HiveHub's `docs/hivetraffic-bee-counter.md`) because a transfer costs + counted bees; +* **no cancelling** it once entered, however wrong the schedule turns out to be; +* the lifetime totals live in RAM and would need moving into RTC memory; +* `uptime_s` resets every morning, so every day looks like an unexplained reboot + — the one thing that field exists to reveal; +* and the ESP32-C6's deep-sleep timer runs off the internal ~150 kHz RC + oscillator, which is strongly temperature-dependent. Over an 8-12 h sleep in a + hive that swings 10-25 C overnight, expect several minutes of drift and up to + ~30 minutes worst case. Re-arming a short window from HiveHub's DS3231 instead + means nothing accumulates and the worst-case error is one cycle. + +Staying awake keeps the counter readable, updatable and cancellable all night, +for a few percent of the saving. If the residual current ever does matter, deep +sleep belongs behind its own opcode, with the totals moved to `RTC_DATA_ATTR` +first. + +### What a night looks like in the data + +`total_in` / `total_out` are frozen for the duration, so the differenced +interval across the suspension is genuinely zero rather than missing. The +`STATUS_NIGHT_IDLE` bit (`0x80`) and the `idle_s` countdown are what separate +that from a counter whose emitter FETs have died — which produces an identical +row of zeros and is otherwise indistinguishable until someone reads a week of +totals. ## Firmware update over connectable BLE @@ -254,7 +344,27 @@ before treating OTA as secure against a nearby active attacker. set, name, meaning or range of the reported fields changes; a firmware fix that reports the same fields bumps `ver` and leaves `fw` alone. -### v3 — current +### v4 — current + +Night mode. The counter can be told to stop sensing for a bounded period (see +[Night mode](#night-mode-the-control-characteristic)), and the document now says +when it has. + +| Change | v3 | v4 | +| --- | --- | --- | +| Suspension countdown | — | `idle_s` (uint32, seconds remaining, `0` while counting) | +| `status` bit `0x80` | unused | `STATUS_NIGHT_IDLE` | +| Control characteristic | — | `8e8b0103-…`, READ + WRITE | + +The addition is purely additive: v4 documents carry every v3 key unchanged, so a +parser that skips unknown keys reads one correctly without knowing about night +mode at all. HiveHub's `bee_counter_wire.h` does exactly that, which is why the +usual deployment-order rule is a formality here rather than a hazard — but it +still applies, because a HiveHub that does not understand `idle_s` cannot tell a +suspended counter from a broken one, and it is HiveHub that decides when to +suspend. + +### v3 Three contract-level defects that could not be fixed on the counter alone, fixed together so the fleet needs one coordinated deployment rather than three. @@ -265,6 +375,7 @@ fixed together so the fleet needs one coordinated deployment rather than three. | `glitches` | `uint16_t`, saturating at 65535 | `uint32_t`, saturating at 4294967295 | | MCP health field | `gates_healthy` | `mcps_healthy` (same 0..3 value) | + The rename carries no change in meaning: the field counted MCP23017 expanders in v2 as well. What changed is that the name no longer claims otherwise — `gates_healthy: 3` next to `num_gates: 24` read as "21 of 24 gates dead" to @@ -293,10 +404,14 @@ These are contract-level issues that cannot be fixed on the counter alone — each needs HiveHub's parser to change in step, so they are deliberately left alone until both sides can be revised together. -* **The OTA service has no authentication.** There is no pairing, no +* **The GATT service has no authentication.** There is no pairing, no authorization and no firmware signature check: CRC-32 is an integrity check, not an authenticity one, so anyone in radio range can push a validly-framed - image. This is an accepted, documented risk rather than an oversight, but + image — and, since v4, write a `SET_IDLE` and stop the counter for up to an + hour. The second is strictly the lesser of the two (it expires by itself, it + changes nothing persistent, and anyone able to do it could already replace the + firmware), but it is a new way to deny counting and it is named here rather + than left to be discovered. This is an accepted, documented risk rather than an oversight, but closing it needs a design decision (Secure Boot + signed images vs. a BLE-layer authentication handshake vs. a per-device provisioning key) *and* a migration story for counters already in the field, since an unauthenticated From 281e5ad79c903b1673c9a28fe8435f3a2a451e38 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:32:28 +0000 Subject: [PATCH 2/3] firmware: pin the platform so the C6 build resolves at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new CI workflow's first run failed with "This board doesn't support arduino framework!". Not a regression from night mode — a latent breakage this repo had no CI to notice. `platform = espressif32` was unpinned, so it resolves to the official Espressif platform, which at 7.0.1 still bundles Arduino core 2.0.17. That core has no ESP32-C6 Arduino support; C6 support arrived in Arduino-ESP32 3.x. Anyone running the documented `pio run` on a fresh install has been getting that error rather than a firmware image. Pin the pioarduino release HiveHub already builds this same chip with (55.03.39 = Arduino-ESP32 3.3.9 / ESP-IDF 5.5.4), and pin the libraries exactly while here — including NimBLE at 2.5.1. The caret range let it float, and the 2.x releases below 2.5.0 each trip a different C6 failure (a libbt.a link error, an init crash on core 3.3.7+, a scan crash on the C6), so "some 2.x" is not a safe thing to ask for on this board. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gFpezSuXW3sHMd1xaSYnd --- Firmware/platformio.ini | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/Firmware/platformio.ini b/Firmware/platformio.ini index 812231b..9341830 100644 --- a/Firmware/platformio.ini +++ b/Firmware/platformio.ini @@ -18,8 +18,25 @@ ; Settings shared by every build environment. The single concrete env below ; inherits these. +; +; ── Dependency reproducibility ─────────────────────────────────────────────── +; The platform is pinned to an exact, immutable release zip, and every library +; to an exact version. This is not tidiness: an UNPINNED `platform = espressif32` +; resolves to the official Espressif platform, which as of 7.0.1 still bundles +; Arduino core 2.0.17 — and that core has no ESP32-C6 Arduino support at all, so +; a fresh `pio run` fails outright with "This board doesn't support arduino +; framework!". Arduino-ESP32 3.x is where the C6 gained support, and the +; pioarduino fork below is what ships it. +; +; 55.03.39 = Arduino-ESP32 3.3.9 / ESP-IDF 5.5.4, the same known-good release +; HiveHub pins for the same chip. The version string's leading zeros are +; rejected by PlatformIO's semver parser, so the release zip is referenced +; directly rather than as `@55.03.39`. +; +; After changing any pin, delete .pio/libdeps (or run `pio pkg update`) — a +; stale install can satisfy an old range forever. [env] -platform = espressif32 +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip board = seeed_xiao_esp32c6 framework = arduino monitor_speed = 115200 @@ -43,7 +60,10 @@ build_flags = -DCORE_DEBUG_LEVEL=3 lib_deps = - adafruit/Adafruit MCP23017 Arduino Library @ ^2.3.2 + adafruit/Adafruit MCP23017 Arduino Library@2.3.2 +; Transitive dependency of the MCP23017 driver, pinned explicitly so a fresh +; cache always resolves the same tree. + adafruit/Adafruit BusIO@1.16.1 ; --------------------------------------------------------------------------- ; Production firmware — the only build. @@ -72,6 +92,9 @@ lib_deps = board_build.partitions = partitions_4mb_ota_no_fs.csv lib_deps = ${env.lib_deps} - ; BLE: NimBLE 2.x — the version that supports the ESP32-C6 radio - ; (same library and major version HiveInside uses). - h2zero/NimBLE-Arduino@^2.2.0 + ; BLE: NimBLE 2.5.x — the first line that is actually usable on an ESP32-C6. + ; Earlier 2.x releases trip a libbt.a "multiple definition of ble_sm_alg_*" + ; link error, an init crash on Arduino core 3.3.7+, and a scan crash on the + ; C6 specifically; HiveHub pins 2.5.0 for the same reasons. Exact rather + ; than a caret range so a future 2.x cannot silently reintroduce any of it. + h2zero/NimBLE-Arduino@2.5.1 From ff4fc8b68a4b0305b20e0e5f32ceeb9aa7dd34c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:35:27 +0000 Subject: [PATCH 3/3] test: compile idle_state.h against Arduino's macro namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the HiveHub fix. These host tests build the pure headers against a bare compiler, where Arduino's few hundred all-caps macros do not exist — so a constant in the shared counter_protocol.h that collides with one of them passes every check here and fails only in the firmware build. HiveHub hit exactly that with an enumerator named DISABLED meeting esp32-hal-gpio.h's `#define DISABLED 0x00`. Define the macros that actually bite before the include, so the collision surfaces in seconds on a host compiler instead of partway through a firmware build. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gFpezSuXW3sHMd1xaSYnd --- .../test/test_idle_state/test_idle_state.cpp | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Firmware/test/test_idle_state/test_idle_state.cpp b/Firmware/test/test_idle_state/test_idle_state.cpp index df3fd85..180dbe3 100644 --- a/Firmware/test/test_idle_state/test_idle_state.cpp +++ b/Firmware/test/test_idle_state/test_idle_state.cpp @@ -16,6 +16,30 @@ // or via test/run_tests.sh, which builds every host test. // ============================================================================ +// --------------------------------------------------------------------------- +// Arduino's macro soup, reproduced before the include on purpose. +// --------------------------------------------------------------------------- +// idle_state.h and counter_protocol.h are built two ways: here, against a bare +// host compiler, and in the firmware, after Arduino.h has defined a few hundred +// all-caps macros. The second is far more hostile, and a test that only sees +// the first cannot catch a name collision — HiveHub hit exactly that, where an +// enumerator named DISABLED met esp32-hal-gpio.h's `#define DISABLED 0x00` and +// broke the build while every host check passed. +// +// So define the ones that actually bite, with their real Arduino values, before +// the include. Any constant added to the shared protocol header that strays +// into the all-caps macro namespace now fails on a host compiler in seconds +// rather than partway through a firmware build. Deliberately NOT #undef'd. +#define DISABLED 0x00 +#define INPUT 0x01 +#define OUTPUT 0x03 +#define PULLUP 0x04 +#define PULLDOWN 0x08 +#define HIGH 0x1 +#define LOW 0x0 +#define ANALOG 0xC0 +#define OPEN_DRAIN 0x10 + #include "idle_state.h" #include