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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions Firmware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ The previous 2-FET build split U3's gates across banks 1 and 2 (00..13 / 14..27)

Driving the GPIO HIGH turns the bank's emitters on. In the default `LedMode::AUTO` the emitters are **pulsed**: all three banks are lit together only for the settle + MCP-read window of each poll (~1.75 ms at 100 kHz), then switched off until the next poll. This drops the emitter duty cycle from 100% to roughly 35% at the default 5 ms poll interval, cutting average emitter current proportionally, with no change to detection behaviour. The IR_DEBUG console's `1` / `0` / `a` keys force steady-on, blackout and pulsed mode respectively for bench work.

Each bank can also be **switched off entirely**, which is a separate control from the LED mode: HiveHub writes an enable bitmask to the control characteristic (`SET_BANKS`, protocol v5) and `include/bank_state.h` decides what is applied. One bank draws ~0.14 A at 3.3 V, two ~0.22 A, three ~0.30 A, so this is the coarsest and most effective power knob on the board — for an entrance narrower than 24 gates, or a supply that will not carry the full one.

All three banks are enabled by default and after any reset; the mask is never persisted, and HiveHub re-asserts it every upload cycle. A mask of `0` is refused rather than applied. Gates on a dark bank are **skipped**, not read as "clear": an unpowered QRE1113 is a bare phototransistor under a 100 kΩ pull-up, and direct sun into the entrance can pull one low. The expander itself is still read and health-checked, so `mcps_healthy` keeps its meaning. The IR_DEBUG console's `4` / `5` / `6` keys toggle banks 1/2/3 for bench work.

### Counting

Each gate is a small state machine:
Expand Down Expand Up @@ -180,10 +184,18 @@ Press a single key in the serial monitor:
| `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) |
| `4` | Toggle emitter bank 1 (GATE_00..07) |
| `5` | Toggle emitter bank 2 (GATE_10..17) |
| `6` | Toggle emitter bank 3 (GATE_20..27) |
| `h` | Show the command list |

Each reading lists the raw MCP23017 port words plus a per-gate `BLOCK`/`clear`
line for the inner and outer sensor. The emitters are pulsed on for every read
The bank keys are `4`/`5`/`6` because the schematic labels those rails `/GPIO4`,
`/GPIO5` and `/GPIO6` — misleading net names (they are physically GPIO19/20/18)
but the ones silkscreened next to the FETs.

Each reading lists the raw MCP23017 port words, the current bank mask, plus a
per-gate `BLOCK`/`clear` line for the inner and outer sensor. A gate whose bank
is switched off prints `<bank disabled>` rather than a beam state. The emitters are pulsed on for every read
regardless of the LED mode, so the readout is always valid. Wave a finger or a
bee through a gate and you should see that gate's `inner`/`outer` flip to
`BLOCK`.
Expand Down Expand Up @@ -282,6 +294,13 @@ No ESP32, no MCP23017 and no bee required.
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.
* **`include/bank_state.h`** — the emitter-bank enable mask. Every mistake it
can make is silent: a bank that should be on but is off produces a
permanently flat third of the totals, which reads exactly like a dead FET,
and a bank that should be off but is on quietly costs ~80 mA on a supply
sized without it. The suite pins the refusal of an all-off mask, the
one-based bank numbering that `gates::TABLE[].led_bank` depends on, and the
masking of bits above the last physical bank.

Everything hardware-facing stays in `src/main.cpp` and is still verified on the
bench with the IR-sensor console above.
Expand Down
114 changes: 114 additions & 0 deletions Firmware/include/bank_state.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// ============================================================================
// bank_state.h — which emitter banks (MOSFETs) are enabled
// ============================================================================
//
// Arduino-free, like idle_state.h and gate_logic.h, and for the same reason:
// the interesting part of "run only some of the counter" is a handful of
// bitmask rules that decide whether eight gates get counted at all. Getting one
// wrong is silent — the totals for those gates simply stay flat, which looks
// exactly like a dead FET — so the rules are pinned by
// test/test_bank_state/ on a host compiler. src/main.cpp owns the GPIO and the
// poll loop and calls in here for the verdict.
//
// The model
// ---------
// Since the 2026-08 hardware revision there is one IRLB8721 per MCP23017, so a
// bank IS a chip is eight gates:
//
// bank 1 (bit 0) -> U2 @ 0x20, gates 00..07
// bank 2 (bit 1) -> U3 @ 0x21, gates 10..17
// bank 3 (bit 2) -> U4 @ 0x22, gates 20..27
//
// HiveHub writes a mask; this header decides what is actually applied. Three
// rules, each of which exists because the alternative fails quietly:
//
// 1. **Bits above the last bank are ignored.** A four-bank board's mask
// arriving at a three-bank counter must not conjure a bank 4 whose GPIO
// does not exist.
// 2. **A mask of 0 is refused, not applied.** Every other decision in this
// firmware is arranged so that a bad write costs a cycle rather than a
// deployment; accepting 0 would let one corrupted byte blind a counter
// until someone walks to the hive. A counter that should count nothing is
// unpaired in HiveHub, not masked to zero here.
// 3. **It is not persisted.** A reset comes back with every bank enabled and
// counting, and HiveHub re-asserts the mask on its next upload cycle. The
// worst case is one cycle of drawing more current than asked for, which is
// the same failure direction night mode chose.
//
// Why not just leave the FET off and read the chip anyway
// ------------------------------------------------------
// That is exactly what this does — the chip is still read and still health-
// checked, so `mcps_healthy` keeps meaning what it has always meant. What the
// caller must additionally do is SKIP the gates on a dark bank, which is not
// paranoia: an unlit QRE1113 is not a sensor that reads "clear", it is a bare
// phototransistor under a 100k pull-up, and direct sun through a hive entrance
// is quite capable of pulling one low. Counting those would invent crossings on
// gates the operator deliberately switched off.
// ============================================================================

#pragma once

#include <stdint.h>

#include "counter_protocol.h"

namespace bankstate {

// Enabled-bank bitmask. Default-constructed is "everything on", which is what a
// freshly booted counter must always be.
struct State {
uint8_t mask = beecounter_proto::BANK_MASK_ALL;
};

// Result of a SET_BANKS request, so the caller can log what it actually did
// rather than what it was asked to do.
struct Request {
uint8_t granted = beecounter_proto::BANK_MASK_ALL;
bool accepted = false; // false: refused, `granted` is the unchanged mask
bool changed = false; // did the applied mask actually move?
};

// Bit for bank number 1..NUM banks. Bank 0 does not exist and yields 0, so a
// caller that mixes up 0- and 1-based numbering gets "no bank" rather than a
// silently shifted-by-one map.
inline uint8_t bankBit(uint8_t bank) {
if (bank == 0 || bank > 8) return 0;
return (uint8_t)(1u << (bank - 1));
}

// Is this bank's emitter rail allowed to light?
inline bool enabled(const State& s, uint8_t bank) {
const uint8_t bit = bankBit(bank);
return bit != 0 && (s.mask & bit) != 0;
}

// How many banks the mask turns on. Drives the reported active gate count and
// the log line; also the cheapest way to say "this counter is running on a
// third of its entrance".
inline uint8_t enabledCount(const State& s) {
uint8_t n = 0;
for (uint8_t bit = 1; bit; bit = (uint8_t)(bit << 1)) {
if (s.mask & bit) n++;
}
return n;
}

// Apply a requested mask, honouring the three rules above.
inline Request request(State& s, uint8_t requested) {
Request r;
const uint8_t sane = (uint8_t)(requested & beecounter_proto::BANK_MASK_ALL);
if (sane == 0) {
// Refused. Leave the counter counting on whatever it already had.
r.granted = s.mask;
r.accepted = false;
r.changed = false;
return r;
}
r.accepted = true;
r.changed = sane != s.mask;
s.mask = sane;
r.granted = sane;
return r;
}

} // namespace bankstate
18 changes: 18 additions & 0 deletions Firmware/include/ble_link.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ struct Telemetry {
// 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;
// Enabled emitter banks, one bit per MOSFET (bit 0 = bank 1 = gates
// 00..07, and so on). New in protocol v5, reported as "banks". A cleared
// bit means those eight gates are dark and deliberately not counted, so
// their share of the totals stays flat — which without this field is
// indistinguishable from the FET having died. 0x07 on any counter that
// has not been told otherwise.
uint8_t bank_mask;
};

void getTelemetry(Telemetry& out);
Expand All @@ -44,6 +51,17 @@ uint32_t applyIdleRequest(uint32_t duration_s);
// Seconds of suspension left, for the control characteristic's read-back.
uint32_t idleRemainingSeconds();

// Apply an emitter-bank enable mask written to the control characteristic.
// Implemented in main.cpp alongside applyIdleRequest(), for the same reason:
// the FET pins and the gate state machines live there. Returns the mask
// actually in force afterwards, which is the unchanged one if the request was
// refused (see bank_state.h — a mask of 0 is never applied).
uint8_t applyBankMask(uint8_t mask);

// The enabled-bank mask currently in force, for the control characteristic's
// read-back.
uint8_t bankMask();

void begin();
bool isOtaActive();
void loopOta();
Expand Down
59 changes: 55 additions & 4 deletions Firmware/include/counter_protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,19 @@ namespace beecounter_proto {
// 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.
// v5 = adds the "banks" field: the bitmask of emitter banks (MOSFETs) that are
// currently enabled. Since the 2026-08 revision one FET feeds one
// MCP23017, so a disabled bank means eight specific gates are dark and
// not counted, and the totals for them are permanently flat. Without the
// field that is indistinguishable from a dead FET — the same failure
// idle_s was added to disambiguate, at a third of the counter each time.
//
// 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;
constexpr uint8_t PROTOCOL_VERSION = 5;

// --------------------------------------------------------------------------
// Status bitfield — reported as the JSON "status" field
Expand Down Expand Up @@ -115,6 +121,43 @@ constexpr uint8_t OTA_ERR_NONE = 0x00;
// 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
constexpr uint8_t CTRL_OP_SET_BANKS = 0x03; // + bank bitmask (1 byte)

// --------------------------------------------------------------------------
// Emitter bank enables — the second power control, and a very different one
// --------------------------------------------------------------------------
// Night mode answers "when should the whole counter stop?"; this answers "how
// much of the counter should exist at all?". Since the 2026-08 hardware
// revision there are three IRLB8721 MOSFETs, one per MCP23017, so an entrance
// narrower than 24 gates — or a power budget that will not carry 24 — can run
// with only the banks it needs:
//
// bank 1 (bit 0) -> U2, gates 00..07
// bank 2 (bit 1) -> U3, gates 10..17
// bank 3 (bit 2) -> U4, gates 20..27
//
// Measured on the 3.3 V rail, with the pulsed sampler at its defaults:
// 1 bank / 8 gates ~0.14 A
// 2 banks / 16 gates ~0.22 A
// 3 banks / 24 gates ~0.30 A
// i.e. roughly 80 mA per bank on top of a ~60 mA floor, which is why this is a
// coarse but very effective knob: dropping one bank saves about as much as a
// quarter of the night does.
//
// Unlike night mode this is a CONFIGURATION, not a deadline — there is nothing
// for it to expire into. It is still not persisted, for the same reason night
// mode is not: a counter that resets comes back with everything enabled and
// counting, and HiveHub re-asserts the mask on its next upload cycle. The worst
// case is one cycle of drawing more current than asked, never a counter that
// boots blind on eight gates because of a write it received a month ago.
//
// A mask of 0 is REFUSED rather than applied. It is not a configuration anyone
// needs — a counter that should count nothing is unpaired — and accepting it
// would turn one malformed byte into a permanently blind counter, which is
// exactly what every other decision in this file is arranged to prevent. Bits
// above the highest bank are ignored, so a future four-FET board reading this
// firmware's mask sees no phantom bank.
constexpr uint8_t BANK_MASK_ALL = 0x07; // all three banks enabled (default)

// Longest suspension the counter will accept, whatever HiveHub asks for. One
// hour is several times HiveHub's default 10-minute upload cycle — enough that
Expand All @@ -126,15 +169,23 @@ constexpr uint8_t CTRL_OP_RESUME = 0x02; // no payload: sense again now
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.
// state(1) + remaining_s(4 LE) + bank_mask(1)
// state is one of the two below; remaining_s is 0 unless idle; bank_mask is the
// enabled-bank bitmask currently in force.
//
// The trailing byte is new in protocol v5 and is deliberately APPENDED: a
// client that reads five bytes and stops — every HiveHub built against v4 —
// still gets exactly the value it used to.
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;
constexpr uint8_t CTRL_STATUS_LENGTH = 6;

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

// Bytes in a well-formed SET_BANKS write (opcode + mask).
constexpr uint8_t CTRL_SET_BANKS_LENGTH = 2;

} // namespace beecounter_proto
17 changes: 10 additions & 7 deletions Firmware/include/measurement_json.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,20 @@ namespace beecounter_proto {
//
// {"fw":255,"ver":"<15>","uptime_s":4294967295,"status":255,"num_gates":255,
// "mcps_healthy":255,"total_in":4294967295,"total_out":4294967295,
// "glitches":4294967295,"idle_s":4294967295}
// "glitches":4294967295,"idle_s":4294967295,"banks":255}
//
// is 191 bytes, plus the NUL — measured, not estimated, by
// is 203 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
// past the buffer). 240 leaves room for a version string longer than any
// version.h has carried without another audit of this number; the truncation
// check in buildMeasurementJson() is what actually guarantees a malformed
// document is never published, so this is headroom, not a promise.
//
// (v2 fitted in ~155 bytes. Widening uptime_s and glitches to 32 bits and
// renaming gates_healthy -> mcps_healthy added ~16 bytes; v4's idle_s added
// ~21 more. The buffer has not had to grow for either.)
constexpr unsigned MEASUREMENT_JSON_CAPACITY = 224;
// ~21 more, both absorbed by the old 224-byte buffer. v5's "banks" added ~12
// and is what finally moved it.)
constexpr unsigned MEASUREMENT_JSON_CAPACITY = 240;

// Serialize `t` plus the image version string into `out`.
//
Expand All @@ -68,7 +69,8 @@ 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,\"idle_s\":%lu}",
"\"total_out\":%lu,\"glitches\":%lu,\"idle_s\":%lu,"
"\"banks\":%u}",
static_cast<unsigned>(t.protocol_version),
fw_version ? fw_version : "",
static_cast<unsigned long>(t.uptime_s),
Expand All @@ -78,7 +80,8 @@ inline int buildMeasurementJson(char* out, unsigned capacity,
static_cast<unsigned long>(t.total_in),
static_cast<unsigned long>(t.total_out),
static_cast<unsigned long>(t.glitch_count),
static_cast<unsigned long>(t.idle_s));
static_cast<unsigned long>(t.idle_s),
static_cast<unsigned>(t.bank_mask));
if (length <= 0 || static_cast<unsigned>(length) >= capacity) return -1;
return length;
}
Expand Down
2 changes: 1 addition & 1 deletion Firmware/include/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
// and it is the only way to confirm afterwards that an update actually took.
//
// Bump this on every released image.
#define HIVETRAFFIC_FW_VERSION "0.2.0"
#define HIVETRAFFIC_FW_VERSION "0.3.0"
25 changes: 25 additions & 0 deletions Firmware/src/ble_link.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,28 @@ class ControlCallbacks : public NimBLECharacteristicCallbacks {
applyIdleRequest(0);
Serial.println(F("[BLE-CTRL] sensing resumed"));
break;
case CTRL_OP_SET_BANKS: {
if (value.size() != CTRL_SET_BANKS_LENGTH) {
Serial.printf("[BLE-CTRL] SET_BANKS ignored: %u bytes, expected %u\n",
(unsigned)value.size(),
(unsigned)CTRL_SET_BANKS_LENGTH);
return;
}
// Deliberately NOT refused during an OTA, where SET_IDLE is. A
// suspension armed under a transfer would outlive the reboot it
// cannot survive; a bank mask is a configuration HiveHub re-asserts
// every cycle regardless, and refusing it here would only delay it
// by one. The emitters are dark for the transfer either way.
const uint8_t granted = applyBankMask(data[1]);
if (granted != data[1]) {
Serial.printf("[BLE-CTRL] banks 0x%02X requested, 0x%02X in force\n",
(unsigned)data[1], (unsigned)granted);
} else {
Serial.printf("[BLE-CTRL] emitter banks set to 0x%02X\n",
(unsigned)granted);
}
break;
}
default:
Serial.printf("[BLE-CTRL] unknown opcode 0x%02X ignored\n",
(unsigned)data[0]);
Expand All @@ -308,6 +330,9 @@ class ControlCallbacks : public NimBLECharacteristicCallbacks {
value[2] = static_cast<uint8_t>(remaining >> 8);
value[3] = static_cast<uint8_t>(remaining >> 16);
value[4] = static_cast<uint8_t>(remaining >> 24);
// Appended in v5; a client that reads the first five bytes and stops
// sees exactly the value it saw before this byte existed.
value[5] = bankMask();
characteristic->setValue(value, sizeof(value));
}
};
Expand Down
Loading
Loading