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..44c2bbd --- /dev/null +++ b/Firmware/include/device_name.h @@ -0,0 +1,113 @@ +// ============================================================================ +// 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) { + // 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; + + 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_digits[addr_val[1] >> 4]; + out[i++] = hex_digits[addr_val[1] & 0x0F]; + out[i++] = ':'; + out[i++] = hex_digits[addr_val[0] >> 4]; + out[i++] = hex_digits[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..b1991a0 --- /dev/null +++ b/Firmware/test/test_device_name/test_device_name.cpp @@ -0,0 +1,215 @@ +// ============================================================================ +// 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. +// ============================================================================ + +// --------------------------------------------------------------------------- +// 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 +#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