Grove and other Bosch modules strap SDO high, so the 0x76-only table
never initialized them. Add an alternate-address entry per Bosch sensor;
the bus scan still gates every probe, and all four drivers verify a chip
ID before claiming an address.
Each sensor type has a single static driver instance, so skip an entry
whose query is already active: the alternate address is a fallback, not
a second device.
Several table entries share an address and not every driver verifies a
chip ID: INA226::begin() only checks that the address ACKs, so an SHT4x
at 0x44 was also registered as an INA226 and reported junk current on a
second channel. Mark the address consumed once a driver initializes it
so later entries cannot re-claim the same device.
Grove and other Bosch modules strap SDO high, so the 0x76-only table
never initialized them. Add an alternate-address entry per Bosch sensor;
the bus scan still gates every probe, and all four drivers verify a chip
ID before claiming an address.
Each sensor type has a single static driver instance, so skip an entry
whose query is already active: the alternate address is a fallback, not
a second device.
Several table entries share an address and not every driver verifies a
chip ID: INA226::begin() only checks that the address ACKs, so an SHT4x
at 0x44 was also registered as an INA226 and reported junk current on a
second channel. Mark the address consumed once a driver initializes it
so later entries cannot re-claim the same device.
MAX_CLIENTS is defined in both src/helpers/ClientACL.h and examples/simple_repeater/MyMesh.h. Appears it was centralised in the former header file some time ago. Both are included in some places and, depending on which order they're in, either value can win.
This change drops the duplicate entry from the repeater firmware and bumps the central limit up to 32 (per the original repeater firmware value).
Changes:
- Remove MAX_CLIENTS from repeater MyMesh.h
- Increase MAX_CLIENTS limit in ClientACL.h to 32
boards/tracker-t1000-e.json set the application's usb_product to
"T1000-E-BOOT". PlatformIO's nordicnrf52 builder turns that key into
-DUSB_PRODUCT, which lands in the USB device descriptor, so a running
node enumerates as "T1000-E-BOOT" while the Seeed bootloader enumerates
as "T1000-E". The firmware that is not the bootloader is the one wearing
the bootloader's name.
That misidentification is load-bearing in the web flasher.
flasher.meshcore.io calls navigator.serial.requestPort() with no filters,
so the row label in the browser's port chooser -- which the browser builds
from the USB iProduct string -- is the only signal the user has. The nRF52
flow prompts twice, once for the 1200-baud DFU touch and again for the
flash itself, with no re-enumeration watcher in between, and at that
second prompt the two candidate rows read:
T1000-E-BOOT (239a:8029) <- the application
T1000-E (2886:0057) <- the actual bootloader
which is backwards at the moment the user has to tell them apart.
The same define is also the Adafruit core's CFG_DEFAULT_NAME, so it is
what a companion advertises over BLE whenever the intended name is too
long for the GAP device-name attribute and setName() silently fails
(#1769). The string is user-visible over Bluetooth as well.
Name the application after the firmware it runs rather than after the
board. This deliberately differs from the hardware-name convention in the
other nRF52 board files ("WisCore RAK4631 Board", "Seeed Wio Tracker L1")
because the string's job here is to distinguish the running application
from a bootloader that will never claim to be MeshCore; "Seeed T1000-E"
would sit one word away from the bootloader's "T1000-E".
Upload and flashing are unaffected -- PlatformIO's port autodetect matches
on hwids (VID:PID), not on this string. On Linux the /dev/serial/by-id
symlink does change, from usb-Seeed_Studio_T1000-E-BOOT_<serial>-if00 to
usb-Seeed_Studio_MeshCore_T1000-E_<serial>-if00, so anything pinned to the
old path needs updating.
Cleanup before the commit phase ignored whether the temp was actually
removed. On a fresh install a complete, byte-verified temp that failed
schema verification (or whose read-back failed in finish()) could survive
a failed remove() with no primary to outrank it, and boot recovery then
promoted the very value the CLI had just reported as rolled back.
Both cleanups now return a disposition: success means the temp is gone or
an existing primary is authoritative. A false disposition maps to the new
CleanupIndeterminate, which latches the same indeterminate reply as a
commit that could not be rolled back. A short write is excluded, since it
leaves structurally incomplete JSON that recovery classifies as invalid.
Recovery also stops spending transaction state on an opaque backup: an
uncertain temp beside a FutureUsable or uncertain backup now holds both
names and runs defaults instead of promoting the candidate into the
authoritative name, where the "any primary owns the name" rule would keep
it even after a later boot proved it corrupt.
Recovery preserved a FutureClaimed or Indeterminate temp, but published the
backup into the primary name to run that boot. That spent the one piece of
state saying the candidate had already passed the backup rename: the next
boot saw an ordinary usable primary beside a stray temp, and deleted the
temp precisely when more heap or newer firmware finally made it readable.
The OOM path needed no future firmware to hit it — power cut after the
backup rename, one boot short of classification scratch, and a verified
new image was gone.
Answer an uncertain temp with UseBackupHeld instead: rename nothing, read
the last committed image straight out of /mqtt.json.bak, and hold writes.
The filenames then still describe the interrupted transaction, so a later
boot promotes the candidate through the ordinary temp rule, or falls back
to the backup once the candidate proves definitively corrupt.
Tests: two-boot sequences for Indeterminate and FutureClaimed candidates
that later classify as Usable or FutureUsable, the invalid-candidate
fallback, and the no-usable-backup case where the candidate still takes
the authoritative name.
Publishing moves the old primary to .bak before the verified temp takes
that name, so a failed second rename left the new image exactly where
boot recovery promotes it — while the observer setter told the operator
the change had been rolled back. The refused value came back at the next
reset.
Restore the backup and discard the temp on that path, and distinguish
CommitIndeterminate from CommitFailed when the filesystem cannot be put
back, so the CLI reply says the flash state is unresolved rather than
claiming the change is gone. The indeterminate condition latches for the
boot: the artifact left behind also makes every later transaction fail to
begin, so it cannot clear itself.
Also state the version-first rule the future-version probe depends on.
The probe reads the root version with this firmware's grammar, so a newer
file that introduces unknown syntax ahead of that field reads as corrupt
rather than future and loses its preservation guarantee.
Tests: publish-failure rollback and the indeterminate outcome against a
SPIFFS-shaped store fake; the version-first writer invariant and the cost
of violating it; /prefs.json coverage for the strict shape checks
(deployed-shape file, unknown nested groups, torn files, mismatches).
Found on hardware while validating the SNTP fix. `set mqtt.ntp bogus.invalid`
reported SUCCESS with a correct epoch, in 4 s, with no retry and without ever
reaching the SNTP fallback:
[E] hostByName(): DNS Failed for bogus.invalid
[E] beginPacket(): could not get host from dns: 11
MQTT: Time synced: 1786764354 (via bogus.invalid)
Three pieces compose it. WiFiUDP::beginPacket(const char*, port) returns 0 on a
DNS failure and leaves remote_ip/remote_port at their previous values.
NTPClient::sendNTPPacket() discards that return and calls endPacket() regardless.
endPacket() sends to whatever remote_ip still holds. So the request went to the
pool address resolved at boot, that server answered with a genuine timestamp, and
the loop recorded ntp_server_used as the name that had never been contacted.
This sits one layer above the fallback that b1ceaf01 made honest — control never
reaches it — so `set mqtt.ntp <typo>`, whose whole purpose is to fail fast, still
reported OK and the fleet kept a server name it had never spoken to.
The DNS pre-check was already here and only logged a warning. Make it decide:
skip a name that does not resolve rather than attempt a send that cannot go where
it claims. IP literals are unaffected — hostByName() returns them via
fromString() without a lookup — and the lookup already ran, so no latency is
added. Moved setPoolServerName() below it so the client is never pointed at a
server being skipped.
Residual, narrower window: our lookup succeeds and NTPClient's own
gethostbyname() then fails, which needs the entry to leave the lwIP cache between
two calls microseconds apart. Closing it properly needs the resolved IP handed to
NTPClient, and this version exposes no setPoolServerIP(); the constructor is the
only way in.
Not host-testable — NTPClient and WiFiUDP both. Verified by inspection of both
library sources plus the captured hardware trace above.
The usable-clock fallback asked libc only, which does not answer for the case it
was written to cover. On a cold boot ESP32RTCClock::begin() stamps libc with a
2024 placeholder on power-on; AutoDiscoverRTCClock::begin() probes the chip but
never copies its time across, and getCurrentTime() reads the chip directly. So a
Station G3 or T-Beam Supreme that knows exactly what time it is, on a network
with UDP/123 blocked, still failed the plausibility test, left _ntp_synced false,
and brought up no slots — precisely the deployment the fallback exists for.
Ask the RTC when libc is below the floor. libc still wins when it is usable: a
clock SNTP set recently outranks a chip that may have drifted. Accepting the RTC
value then flows through the same block, so settimeofday() repairs libc and the
epoch is written back to the chip.
The choice is chooseFallbackClock() in MQTTConnectionPolicy, host-tested across
the four states including the power-on placeholder and the exact floor. Also
corrects the previous commit's claim that configTime() is called only when a
server replied — the fallback necessarily points it at each server before knowing
that; it is the post-acceptance call that is now conditional.
The reset was on the wrong side of configTime(). configTime() configures the
server, calls sntp_init(), and returns — the new request is live before it comes
back — so a fast reply could set SNTP_SYNC_STATUS_COMPLETED inside that call, and
the reset immediately after would erase it. The following ten seconds of polling
would then see nothing and reject a server that had in fact answered. On the
`set mqtt.ntp` path that surfaces as a good server failing validation.
Stop any running session first, discard its status, then start the new one, so
the only completion observable is the one being waited for.
Requiring a real SNTP completion took away something the plausible-clock test was
doing by accident. _ntp_synced gates slot setup outright (:1386, :2894), so a
device that cannot reach NTP now brings up no slots at all — and a network that
blocks UDP/123 while allowing 443 is an ordinary firewall configuration, not a
corner case. An RTC-backed observer there used to stay synced and keep minting
JWTs against a perfectly good clock.
Accept the existing clock explicitly when every server has failed, logged as what
it is rather than as a claim about a server that never replied. Excluded from the
`set mqtt.ntp` validation path, where the question is whether that server works
and the clock cannot answer it. configTime() is now called only when a server did
answer, since otherwise there is nothing new to point SNTP at.
The corrected-clock path reconnected a disconnected slot whether or not
createSlotAuthToken() had produced anything, which re-presented the credentials
the correction had just invalidated. Minting fails for recoverable reasons —
allocation pressure is treated as recoverable elsewhere in this file — so the
path is reachable, and the reconnect it spends is one that cannot succeed.
Move the decision into MQTTConnectionPolicy as classifyStaleToken(), where the
four outcomes are named and host-tested rather than spelled out in nested
conditions: Defer on a failed mint, Reconnect a client that is down, Bounce a
live session whose broker enforces exp, KeepAlive one whose broker does not.
Deferring leaves the slot to the backoff ladder, which mints again on its next
attempt.
Covers the reviewer's first four cases. The other two — that a completed SNTP
sync is required, and that time(nullptr) reflects the accepted epoch before
_ntp_synced flips — are inside MQTTBridge.cpp, which the native env does not
compile; locking those down needs a seam around the IDF calls that does not
exist yet.
syncTimeWithNTP() read an epoch over UDP, called configTime(), set _ntp_synced,
and then had the stale-token test and createSlotAuthToken() read time(nullptr) —
without anything having put the accepted epoch there. configTime() restarts SNTP
and returns; the clock lands whenever a packet does.
_rtc->setCurrentTime() looks like it covers this and does not.
AutoDiscoverRTCClock::setCurrentTime() writes a detected DS3231/RV3028/PCF8563/
RX8130CE *instead of* delegating to its fallback, and only that fallback
(ESP32RTCClock) calls settimeofday(). So on every board carrying an RTC chip —
T-Beam Supreme and Station G3 both compile this bridge and both instantiate
AutoDiscoverRTCClock — libc kept the pre-correction time, and the correction path
tested staleness and minted iat claims against exactly the clock it had just
proven wrong. Boards without a chip take the fallback and were unaffected, which
is why the soak rig (Heltec V3/V4, no RTC) never showed it.
settimeofday() with the accepted epoch first, so the invariant downstream code
already assumes actually holds: once _ntp_synced is true, time(nullptr) returns
the epoch we accepted. configTime() still follows, to keep future syncs running.