From f70d546a3065503d01590890228d1b01927ddd5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 09:44:00 +0000 Subject: [PATCH 1/2] Advertise the counter's address in its BLE name Every counter advertised the identical local name "BeeCounter", so an apiary with several in range produced a scan list of identical rows: picking the right one for pairing, or working out which of three on a bench is being flashed, meant opening a scanner's detail view and reading addresses. The advertised name is now built at boot as "HiveTraffic-AB:12", where the suffix is the last two bytes of the counter's own BLE address rendered the way a scanner prints them. NimBLE settles its own-address type before init() returns and both the reported address and the advertised packets follow it, so the suffix is literally the tail of the address shown beside the entry. On the ESP32-C6 that address is the factory eFuse MAC: unique per unit and stable across reboots, reflashes and OTA updates, with nothing to provision per device. The same string is pushed to the GAP Device Name characteristic, so a client that connects and reads it agrees with the scan list. The base name changes from "BeeCounter" to the product's own name in the same move; the former was a leftover from the easy-bee-counter days. Nothing on the wire keys off either: HiveHub connects by the MAC paired in its portal and never matches on the name, the measurement document is untouched, and the protocol revision does not move. The service UUID and the beecounter_proto namespace keep their names, being wire identifiers rather than display text. The string itself lives in a new Arduino-free include/device_name.h, pinned by test/test_device_name/ on a host compiler, because every way of getting it wrong is quiet: a reversed suffix, a dropped leading zero or a truncation all produce a name that still looks like a name while pointing at the wrong device, and noticing on hardware requires already knowing the address you were looking for. The header static_asserts its longest form against the 31-byte legacy scan response, which the name now fills 19 of. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VXfRikN5G4jyYqkSLeo9nf --- Firmware/README.md | 16 +- Firmware/include/device_name.h | 110 +++++++++++ Firmware/include/version.h | 2 +- Firmware/src/ble_link.cpp | 71 ++++++- Firmware/test/run_tests.sh | 11 +- .../test_device_name/test_device_name.cpp | 186 ++++++++++++++++++ README.md | 5 +- docs/ble-mode.md | 32 ++- 8 files changed, 417 insertions(+), 16 deletions(-) create mode 100644 Firmware/include/device_name.h create mode 100644 Firmware/test/test_device_name/test_device_name.cpp diff --git a/Firmware/README.md b/Firmware/README.md index 4f3946f..7dcb1b7 100644 --- a/Firmware/README.md +++ b/Firmware/README.md @@ -263,9 +263,9 @@ Each MCP23017 is tracked at runtime rather than only at boot: ## Tests -Two pure headers carry logic that is worth testing without hardware, so both are -deliberately free of Arduino, I2C and NimBLE dependencies and both run on a host -compiler: +Several pure headers carry logic that is worth testing without hardware, so each +is deliberately free of Arduino, I2C and NimBLE dependencies and all of them run +on a host compiler: ``` ./test/run_tests.sh @@ -301,6 +301,14 @@ No ESP32, no MCP23017 and no bee required. 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. +* **`include/device_name.h`** — the advertised BLE name, `HiveTraffic-AB:12`. + The suffix is the last two bytes of the counter's own address, and every way + of getting it wrong produces a name that still looks like a name while + pointing at the wrong device: the suite pins the byte order (NimBLE stores + addresses little-endian, so the two bytes are `val[1]`, `val[0]`), the + two-digit uppercase rendering a scanner matches against, the fallback when no + address can be read, and the refusal to truncate into a buffer that cannot + hold the whole name. Everything hardware-facing stays in `src/main.cpp` and is still verified on the bench with the IR-sensor console above. @@ -319,7 +327,7 @@ Easy Bee Counter 2026 — firmware booting (BLE/GATT link) [MCP] U2 (gates 00..07) @ 0x20: OK [MCP] U3 (gates 10..17) @ 0x21: OK [MCP] U4 (gates 20..27) @ 0x22: OK -[BLE] HiveTraffic 0.1.0 advertising for HiveHub +[BLE] HiveTraffic-AB:12 0.3.1 advertising for HiveHub [SETUP] Entering normal counting loop (pulsed IR) ``` diff --git a/Firmware/include/device_name.h b/Firmware/include/device_name.h new file mode 100644 index 0000000..c08c892 --- /dev/null +++ b/Firmware/include/device_name.h @@ -0,0 +1,110 @@ +// ============================================================================ +// device_name.h — the advertised BLE name, "HiveTraffic-AB:12" +// ============================================================================ +// +// Every counter used to advertise the identical local name, so an apiary with +// several in range produced a scan list of identical rows and the only way to +// tell one entry from another was to open a scanner's detail view and read the +// address. The name now carries the last two bytes of the counter's own BLE +// address, formatted the way a scanner prints them, so the list is +// self-identifying: +// +// HiveTraffic-AB:12 <- the node whose address ends ...:AB:12 +// HiveTraffic-4F:9C +// +// Nothing on the wire keys off the name. HiveHub connects by the MAC paired in +// its portal (docs/ble-mode.md), so this is a display string for whoever is +// standing at the hive with a phone — during pairing, or when working out +// which of three counters on a bench is the one being flashed. +// +// Arduino-free, like gate_logic.h and bank_state.h, and pinned by +// test/test_device_name/ for the same reason: the failure mode is quiet. A +// wrong byte order or an off-by-one in the buffer produces a name that still +// looks plausible in a scan list while pointing at the wrong device, and the +// only way to notice on hardware is to already know the address you were +// looking for. src/ble_link.cpp owns the radio and calls in here for the +// string. +// +// Byte order +// ---------- +// NimBLE stores an address little-endian — val[5] is the byte a scanner prints +// first, val[0] the last — so the two bytes wanted here are val[1] and val[0], +// in that order. Getting this backwards is the mistake this header exists to +// prevent: it yields a suffix that is a real part of the address, just +// reversed, so it passes a glance and fails exactly when someone tries to +// match it against what the scanner shows. +// +// Which address ends up in the name +// --------------------------------- +// The one the counter advertises with. NimBLE settles its own-address type +// while starting up and both the address it reports and the packets it sends +// follow it, so the two always agree. In practice that is the controller's +// public address — on the ESP32-C6 the factory eFuse MAC: unique per unit and +// stable across reboots, reflashes and OTA updates, with nothing to provision +// per device. The suffix is therefore literally the tail of the address the +// scanner shows beside the entry. +// ============================================================================ + +#pragma once + +#include +#include + +namespace devicename { + +// The product half of the name, without the address suffix. Also the fallback +// if the address cannot be read. +constexpr char BASE[] = "HiveTraffic"; + +// "-AB:12": a separator, two hex digits, a colon, two more. +constexpr size_t SUFFIX_LENGTH = 6; + +// Longest name this header produces, NUL included. sizeof(BASE) already counts +// the terminator. +constexpr size_t CAPACITY = sizeof(BASE) + SUFFIX_LENGTH; + +// A legacy scan response holds 31 bytes, of which an AD structure spends two on +// its length and type. ble_link.cpp puts the name in the scan response alone, +// so this is the whole budget it has to fit in — checked here rather than in a +// comment there, because the name is what would grow. +static_assert(CAPACITY - 1 + 2 <= 31, + "the advertised name does not fit a legacy scan response"); + +// Build the advertised name into `out`, returning its length (excluding the +// NUL) or 0 if the buffer is too small to hold the complete name. +// +// `addr_val` is a NimBLE-order (little-endian) six-byte address, or nullptr +// when none could be read — in which case the bare product name is used. An +// unsuffixed name is a far better failure than no name, or than a plausible +// "HiveTraffic-00:00" that several counters would then share. +// +// Refusing to truncate is deliberate: a half-written suffix is a name that +// identifies the wrong device, which is worse than one that identifies no +// device in particular. +inline size_t build(char* out, size_t capacity, const uint8_t* addr_val) { + static const char HEX[] = "0123456789ABCDEF"; + + if (out == nullptr) return 0; + + const size_t base_length = sizeof(BASE) - 1; + const size_t length = + base_length + (addr_val != nullptr ? SUFFIX_LENGTH : 0); + if (capacity < length + 1) return 0; + + size_t i = 0; + for (; i < base_length; ++i) out[i] = BASE[i]; + + if (addr_val != nullptr) { + out[i++] = '-'; + out[i++] = HEX[addr_val[1] >> 4]; + out[i++] = HEX[addr_val[1] & 0x0F]; + out[i++] = ':'; + out[i++] = HEX[addr_val[0] >> 4]; + out[i++] = HEX[addr_val[0] & 0x0F]; + } + + out[i] = '\0'; + return i; +} + +} // namespace devicename diff --git a/Firmware/include/version.h b/Firmware/include/version.h index 06ca43f..27210d2 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.3.0" +#define HIVETRAFFIC_FW_VERSION "0.3.1" diff --git a/Firmware/src/ble_link.cpp b/Firmware/src/ble_link.cpp index 4c8d81e..85cfedb 100644 --- a/Firmware/src/ble_link.cpp +++ b/Firmware/src/ble_link.cpp @@ -7,13 +7,20 @@ #include #include "counter_protocol.h" +#include "device_name.h" #include "measurement_json.h" #include "version.h" namespace ble { namespace { -constexpr char BLE_DEVICE_NAME[] = "BeeCounter"; +// The advertised local name, "HiveTraffic-AB:12", built once in begin() from +// this counter's own BLE address so several in range are distinguishable in a +// scan list (include/device_name.h). File scope rather than a local in begin() +// because the log line below reads it too, and because it is the one place the +// name exists — nothing should reconstruct it. +char deviceName[devicename::CAPACITY]; + 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 @@ -352,6 +359,49 @@ class ServerCallbacks : public NimBLEServerCallbacks { } }; +// Fill deviceName with "HiveTraffic-AB:12", the product name suffixed with the +// last two bytes of this counter's own BLE address. +// +// Must run AFTER NimBLEDevice::init(): the address comes from the controller, +// and init() is what starts it — it blocks until the host and controller have +// synced, so the address is readable the moment it returns, but not one line +// earlier. +// +// The address NimBLE reports here is the one it goes on to advertise with: +// both read the own-address type that init() settled, which is the controller's +// public address whenever it has one — on the ESP32-C6 the factory eFuse MAC. +// The suffix is therefore literally the tail of the address a scanner shows +// beside the entry, needs no provisioning, and survives reboots, reflashes and +// OTA updates. +// +// If the address cannot be read, the bare product name is advertised. That is +// a far better failure than not advertising at all: a counter HiveHub cannot +// see is invisible to the measurement read AND to the OTA relay, and HiveHub +// finds it by the paired MAC regardless of what the name says. +void buildDeviceName() { + const NimBLEAddress address = NimBLEDevice::getAddress(); + const bool haveAddress = !address.isNull(); + + if (!devicename::build(deviceName, sizeof(deviceName), + haveAddress ? address.getVal() : nullptr)) { + // Only reachable if CAPACITY and the name it sizes ever disagree, which + // the header's static_assert and test/test_device_name/ both rule out. + // Left as a hard fallback rather than an assert: an unnamed counter + // still counts bees and still relays firmware. + deviceName[0] = '\0'; + } + if (!haveAddress) { + Serial.println(F("[BLE] no address available; advertising unsuffixed name")); + } + + // Keep the GAP Device Name characteristic in step with the advertised one, + // so a client that connects and reads it — rather than trusting the scan + // response — sees the same identity. + if (!NimBLEDevice::setDeviceName(deviceName)) { + Serial.println(F("[BLE] GAP device name not updated")); + } +} + // NimBLE stores these pointers for the lifetime of the server and never frees // them: NimBLECharacteristic::setCallbacks() takes no ownership at all, so the // old `new X(), true` form both fails to compile against NimBLE 2.5.x (the @@ -367,7 +417,13 @@ ServerCallbacks serverCallbacks; } // namespace void begin() { - NimBLEDevice::init(BLE_DEVICE_NAME); + // init() takes a name because it must set one before the GATT server + // exists; the address it is built from is only available once init() has + // synced the host and controller, so the suffixed name is applied + // immediately afterwards by buildDeviceName(). + NimBLEDevice::init(devicename::BASE); + buildDeviceName(); + NimBLEServer* server = NimBLEDevice::createServer(); // false: never delete a statically allocated callback object. server->setCallbacks(&serverCallbacks, false); @@ -404,7 +460,7 @@ void begin() { // // flags 3 (added by NimBLE at start()) // 128-bit service UUID 18 (2 + 16) - // "BeeCounter" 12 (2 + 10) -> 33 > 31 + // "HiveTraffic-AB:12" 19 (2 + 17) -> 40 > 31 // // NimBLE 2.x leaves scan response DISABLED by default and does not silently // relocate the name, so setting all three on the advertisement overflows and @@ -412,10 +468,11 @@ void begin() { // not advertise is invisible to BOTH the measurement read and the OTA relay, // which locates it by a scan first (HiveHub ble_sensor.cpp::otaBegin). // Splitting them keeps the advertisement at 21 bytes and the scan response - // at 12, with room to spare on each. + // at 19, with room to spare on each. The name is the element that grows, so + // device_name.h static_asserts its own longest form against that 31. advertising->addServiceUUID(service->getUUID()); NimBLEAdvertisementData scanResponse; - scanResponse.setName(BLE_DEVICE_NAME); + scanResponse.setName(deviceName); advertising->setScanResponseData(scanResponse); advertising->enableScanResponse(true); advertising->setMinInterval(ADV_INTERVAL_UNITS); @@ -426,7 +483,9 @@ void begin() { Serial.println(F("[BLE] ERROR: advertising failed to start")); return; } - Serial.printf("[BLE] HiveTraffic %s advertising for HiveHub\n", + // The name is logged, not just the version: it is what someone comparing + // the serial console with a scan list on their phone needs to match up. + Serial.printf("[BLE] %s %s advertising for HiveHub\n", deviceName, HIVETRAFFIC_FW_VERSION); } diff --git a/Firmware/test/run_tests.sh b/Firmware/test/run_tests.sh index bab5260..bf94617 100755 --- a/Firmware/test/run_tests.sh +++ b/Firmware/test/run_tests.sh @@ -13,7 +13,9 @@ # the millis() rollover and the "HiveHub stopped # re-arming" case; # * include/bank_state.h — the emitter-bank enable mask, whose mistakes -# are eight gates that silently stop counting. +# are eight gates that silently stop counting; +# * include/device_name.h — the advertised BLE name, whose address suffix +# is how a person tells two counters apart. # # Everything hardware-facing stays in src/main.cpp and is still verified on the # bench (see the IR_DEBUG console in the README). @@ -56,3 +58,10 @@ trap 'rm -rf "$OUT"' EXIT -o "$OUT/test_bank_state" "$OUT/test_bank_state" + +"$CXX" -std=c++11 -Wall -Wextra -Werror \ + -I include \ + test/test_device_name/test_device_name.cpp \ + -o "$OUT/test_device_name" + +"$OUT/test_device_name" diff --git a/Firmware/test/test_device_name/test_device_name.cpp b/Firmware/test/test_device_name/test_device_name.cpp new file mode 100644 index 0000000..9ecf7f1 --- /dev/null +++ b/Firmware/test/test_device_name/test_device_name.cpp @@ -0,0 +1,186 @@ +// ============================================================================ +// Host-side tests for include/device_name.h +// ============================================================================ +// +// The advertised name is how a person standing at a hive tells one counter +// from another, and every way of getting it wrong is quiet: a reversed suffix, +// a dropped leading zero or a truncated buffer all produce a name that looks +// like a name and points at the wrong device. Nothing on the hardware would +// flag it — you would have to already know the address you were looking for — +// so the exact bytes are pinned here. +// +// c++ -std=c++11 -I include +// test/test_device_name/test_device_name.cpp -o /tmp/t && /tmp/t +// +// or via test/run_tests.sh, which builds every host test. +// ============================================================================ + +#include "device_name.h" + +#include +#include +#include + +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) + +#define CHECK_NAME(actual, expected) \ + do { \ + if (std::strcmp((actual), (expected)) != 0) { \ + std::printf(" FAIL %s:%d [%s] got \"%s\", want \"%s\"\n", \ + __FILE__, __LINE__, g_case, (actual), (expected)); \ + ++g_failures; \ + } \ + } while (0) + +// A NimBLE-order address: val[5] is the byte a scanner prints first. This one +// is displayed as A4:C1:38:9F:AB:12, so its name must end "AB:12". +static const uint8_t ADDR[6] = {0x12, 0xAB, 0x9F, 0x38, 0xC1, 0xA4}; + +static void test_name_ends_with_the_displayed_address() { + g_case = "the suffix is the tail of the address, in display order"; + char name[devicename::CAPACITY]; + const size_t length = devicename::build(name, sizeof(name), ADDR); + CHECK_NAME(name, "HiveTraffic-AB:12"); + CHECK(length == std::strlen(name)); + // The bytes are the LAST two a scanner prints, not the first two of the + // little-endian array: "12:AB" here would be the same two bytes reversed, + // which is exactly the failure that survives a code read. + CHECK(std::strcmp(name, "HiveTraffic-12:AB") != 0); +} + +static void test_leading_zeros_are_kept() { + // "HiveTraffic-0:5" would be a different length for every device and would + // not line up with anything a scanner prints. Both digits, always. + g_case = "each byte is two hex digits"; + const uint8_t addr[6] = {0x05, 0x00, 0x00, 0x00, 0x00, 0x00}; + char name[devicename::CAPACITY]; + devicename::build(name, sizeof(name), addr); + CHECK_NAME(name, "HiveTraffic-00:05"); +} + +static void test_hex_is_uppercase() { + // Scanners print addresses in uppercase; a lowercase suffix reads as a + // different value at a glance when matching name against address. + g_case = "hex digits are uppercase"; + const uint8_t addr[6] = {0xEF, 0xCD, 0x00, 0x00, 0x00, 0x00}; + char name[devicename::CAPACITY]; + devicename::build(name, sizeof(name), addr); + CHECK_NAME(name, "HiveTraffic-CD:EF"); +} + +static void test_every_byte_value_round_trips() { + // Both nibbles of both bytes, across the whole range — the table lookup + // has no arithmetic in it, but a wrong shift or mask would show here. + g_case = "all 65536 suffixes render as the address does"; + for (unsigned high = 0; high <= 0xFF; ++high) { + for (unsigned low = 0; low <= 0xFF; ++low) { + const uint8_t addr[6] = { + (uint8_t)low, (uint8_t)high, 0x00, 0x00, 0x00, 0x00}; + char name[devicename::CAPACITY]; + char want[devicename::CAPACITY]; + devicename::build(name, sizeof(name), addr); + std::snprintf(want, sizeof(want), "%s-%02X:%02X", + devicename::BASE, high, low); + if (std::strcmp(name, want) != 0) { + std::printf(" FAIL %s:%d [%s] got \"%s\", want \"%s\"\n", + __FILE__, __LINE__, g_case, name, want); + ++g_failures; + return; // one report is enough; 65536 would not help + } + } + } +} + +static void test_no_address_falls_back_to_the_product_name() { + // What ble_link.cpp does when NimBLE cannot hand it an identity address. + // An unsuffixed name is a far better failure than no advertising, and than + // a plausible "HiveTraffic-00:00" that every affected unit would share. + g_case = "without an address the bare product name is used"; + char name[devicename::CAPACITY]; + const size_t length = devicename::build(name, sizeof(name), nullptr); + CHECK_NAME(name, "HiveTraffic"); + CHECK(length == sizeof(devicename::BASE) - 1); +} + +static void test_a_short_buffer_is_refused_not_truncated() { + // A truncated suffix identifies the WRONG device, which is worse than one + // that identifies none. The caller gets 0 and can fall back. + g_case = "too small a buffer yields nothing"; + char name[devicename::CAPACITY]; + std::memset(name, 'x', sizeof(name)); + // One short: room for the name but not its terminator. + CHECK(devicename::build(name, devicename::CAPACITY - 1, ADDR) == 0); + CHECK(name[0] == 'x'); // untouched, not half-written + CHECK(devicename::build(name, 0, ADDR) == 0); + CHECK(devicename::build(nullptr, devicename::CAPACITY, ADDR) == 0); + + // The unsuffixed form needs less room, and is still written when only that + // much is available. + CHECK(devicename::build(name, sizeof(devicename::BASE), nullptr) == + sizeof(devicename::BASE) - 1); + CHECK_NAME(name, "HiveTraffic"); + CHECK(devicename::build(name, sizeof(devicename::BASE) - 1, nullptr) == 0); +} + +static void test_capacity_matches_the_longest_name() { + // CAPACITY sizes every buffer in ble_link.cpp. If the product name ever + // changes, this is the line that says so before the scan response + // overflows. + g_case = "CAPACITY is exactly the suffixed name plus its NUL"; + char name[devicename::CAPACITY]; + const size_t length = devicename::build(name, sizeof(name), ADDR); + CHECK(length + 1 == devicename::CAPACITY); + CHECK(devicename::CAPACITY == 18); // "HiveTraffic-AB:12" + NUL + // The scan response budget the static_assert in the header guards: two + // bytes of AD header plus the name, inside 31. + CHECK(2 + length <= 31); +} + +static void test_two_counters_get_two_names() { + // The entire point: distinct hardware, distinct rows in the scan list. + g_case = "different addresses give different names"; + const uint8_t other[6] = {0x9C, 0x4F, 0x9F, 0x38, 0xC1, 0xA4}; + char a[devicename::CAPACITY]; + char b[devicename::CAPACITY]; + devicename::build(a, sizeof(a), ADDR); + devicename::build(b, sizeof(b), other); + CHECK(std::strcmp(a, b) != 0); + CHECK_NAME(b, "HiveTraffic-4F:9C"); + // Only the last two bytes are in the name, so units that share them are + // not told apart by it. This is a known and accepted limit — 1 in 65536 + // for factory-assigned addresses — recorded here so it is a decision + // rather than a surprise. + const uint8_t twin[6] = {0x12, 0xAB, 0x11, 0x22, 0x33, 0x44}; + char c[devicename::CAPACITY]; + devicename::build(c, sizeof(c), twin); + CHECK(std::strcmp(a, c) == 0); +} + +int main() { + std::printf("device_name tests\n"); + test_name_ends_with_the_displayed_address(); + test_leading_zeros_are_kept(); + test_hex_is_uppercase(); + test_every_byte_value_round_trips(); + test_no_address_falls_back_to_the_product_name(); + test_a_short_buffer_is_refused_not_truncated(); + test_capacity_matches_the_longest_name(); + test_two_counters_get_two_names(); + + 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/README.md b/README.md index 2547656..5cca098 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,10 @@ Add solar for indefinite runtime. The counter is a connectable BLE peripheral. The canonical definition of the 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. +- Advertises as `HiveTraffic-AB:12` — the product name suffixed with the last + two bytes of the counter's own BLE address, so several counters in range are + distinct rows in a scan list. HiveHub connects by the MAC paired in its + portal and never matches on the name. - One service, `8e8b0101-7a1c-4b9e-9a2f-1d6e0b9c1a01`, holding a READ measurement characteristic, a READ/WRITE control characteristic (night mode and emitter-bank enables) and three OTA characteristics. diff --git a/docs/ble-mode.md b/docs/ble-mode.md index c71447f..6ff88a5 100644 --- a/docs/ble-mode.md +++ b/docs/ble-mode.md @@ -15,7 +15,9 @@ pio run -t upload Build HiveHub with `ENABLE_WIRELESS_BEECOUNTER=1`, then pair the counter's BLE MAC as a **HiveTraffic counter** in HiveHub's provisioning portal. The device -advertises as `BeeCounter`, but HiveHub connects by the paired MAC. +advertises as `HiveTraffic-AB:12`, where the four hex digits are the last two +bytes of that MAC — so the scan list tells you which counter you are pairing. +HiveHub itself connects by the paired MAC and never matches on the name. ## HiveHub GATT contract @@ -76,15 +78,39 @@ The name is carried in the **scan response**, not the advertisement: | PDU | Contents | Bytes | | --- | --- | --- | | Advertisement | flags + the 128-bit service UUID | 21 / 31 | -| Scan response | complete local name `BeeCounter` | 12 / 31 | +| Scan response | complete local name `HiveTraffic-AB:12` | 19 / 31 | -All three elements together are 33 bytes and do not fit one legacy 31-byte +All three elements together are 40 bytes and do not fit one legacy 31-byte advertising PDU. NimBLE 2.x defaults scan response off and does not relocate an overflowing name on its own, so they are split explicitly in `begin()`. Keep them split when adding anything else to the advertisement — a counter that fails to advertise is invisible to the measurement read *and* to the OTA relay, which locates it by scan before connecting. +### The name and its address suffix + +The advertised name is not the bare product name: it is `HiveTraffic-AB:12`, +where the suffix is the last two bytes of the counter's own BLE address, +rendered the way a scanner prints them. Several counters in range therefore +appear as distinct rows, and a row is matched to a physical box by reading the +tail of the address shown beside it — rather than by opening a detail view for +each identical `BeeCounter` entry, which is what this replaced. + +NimBLE advertises with its default public address, which on the ESP32-C6 is the +factory eFuse MAC. The suffix is therefore unique per unit, stable across +reboots, reflashes and OTA updates, and needs nothing provisioned per device. +The same string is written to the GAP Device Name characteristic, so a client +that connects and reads it agrees with the scan list. + +Only the last two bytes are in the name, so two counters whose addresses share +them are not told apart by it — 1 in 65536 for factory addresses, and the +address itself still distinguishes them. If the address cannot be read at all, +the bare `HiveTraffic` is advertised rather than nothing. + +None of this is on the wire contract: `fw` does not move, HiveHub connects by +the paired MAC, and no client matches on the name. The string is built by +`include/device_name.h` and pinned by `test/test_device_name/`. + ## Counting and interval semantics `total_in` and `total_out` are monotonic lifetime counters until reboot or From 94f0ad3f857d80b77f944762e57ec8225da9f0c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 09:52:13 +0000 Subject: [PATCH 2/2] fix: keep the hex table out of Arduino's macro namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name builder used a `static const char HEX[]` lookup table, and Print.h defines `HEX` as 16. Every host check passed and the firmware build failed with "expected unqualified-id before numeric constant" — the same collision class this repo already hit once, where an enumerator named DISABLED met esp32-hal-gpio.h's `#define DISABLED 0x00`. The table is now `hex_digits`. Lower case is the actual fix: Arduino's macros are all-caps, so a lower-case identifier cannot collide with one no matter which of its few hundred macros a future header pulls in. test/test_device_name/ grows the macro block test_idle_state.cpp already carries, extended with the number-base macros (DEC/HEX/OCT/BIN) that are the ones to bite a header doing string formatting. With them defined ahead of the include, this exact failure now surfaces on a host compiler in under a second instead of two and a half minutes into a firmware build. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VXfRikN5G4jyYqkSLeo9nf --- Firmware/include/device_name.h | 13 +++++---- .../test_device_name/test_device_name.cpp | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/Firmware/include/device_name.h b/Firmware/include/device_name.h index c08c892..44c2bbd 100644 --- a/Firmware/include/device_name.h +++ b/Firmware/include/device_name.h @@ -82,7 +82,10 @@ static_assert(CAPACITY - 1 + 2 <= 31, // identifies the wrong device, which is worse than one that identifies no // device in particular. inline size_t build(char* out, size_t capacity, const uint8_t* addr_val) { - static const char HEX[] = "0123456789ABCDEF"; + // Lower-case deliberately: this header is compiled after Arduino.h, whose + // Print.h defines `HEX` as 16. An all-caps name here is not a style choice + // but a build break — see the macro block in test/test_device_name/. + static const char hex_digits[] = "0123456789ABCDEF"; if (out == nullptr) return 0; @@ -96,11 +99,11 @@ inline size_t build(char* out, size_t capacity, const uint8_t* addr_val) { if (addr_val != nullptr) { out[i++] = '-'; - out[i++] = HEX[addr_val[1] >> 4]; - out[i++] = HEX[addr_val[1] & 0x0F]; + out[i++] = hex_digits[addr_val[1] >> 4]; + out[i++] = hex_digits[addr_val[1] & 0x0F]; out[i++] = ':'; - out[i++] = HEX[addr_val[0] >> 4]; - out[i++] = HEX[addr_val[0] & 0x0F]; + out[i++] = hex_digits[addr_val[0] >> 4]; + out[i++] = hex_digits[addr_val[0] & 0x0F]; } out[i] = '\0'; diff --git a/Firmware/test/test_device_name/test_device_name.cpp b/Firmware/test/test_device_name/test_device_name.cpp index 9ecf7f1..b1991a0 100644 --- a/Firmware/test/test_device_name/test_device_name.cpp +++ b/Firmware/test/test_device_name/test_device_name.cpp @@ -15,6 +15,35 @@ // or via test/run_tests.sh, which builds every host test. // ============================================================================ +// --------------------------------------------------------------------------- +// Arduino's macro soup, reproduced before the include on purpose. +// --------------------------------------------------------------------------- +// The same guard test_idle_state.cpp carries, and for the same reason: these +// headers are built twice, 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. +// +// This header was written with a `static const char HEX[]` lookup table and +// met Print.h's `#define HEX 16` — the build failed with "expected +// unqualified-id before numeric constant" after every host check had passed. +// The number-base macros below are what bite a string-formatting header; +// the GPIO ones are inherited from the existing guard. Deliberately NOT +// #undef'd. +#define DEC 10 +#define HEX 16 +#define OCT 8 +#define BIN 2 +#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 "device_name.h" #include