Changelog
Notable changes to meshcore-ts, newest first. Versions follow
semantic versioning; pre-1.0 minor bumps may still
carry behaviour changes.
Syncing contacts stops being quadratic, and consumers get per-contact deltas.
contactUpsertedandcontactRemoved— per-contact delta events, so a consumer can maintain its own map instead of re-rendering the whole list on every change.contactUpsertedcarries the mergedContact, not the raw wire record, so the library’s path/GPS/favourite merge rules aren’t something you have to reimplement.contactsSynced— fires when aGET_CONTACTSiteration completes, carryingContactsSyncedSummary { count, mostRecentLastmod }.mostRecentLastmodwas previously decoded and discarded, leaving no way to obtain the value an incremental re-sync needs.SessionState.getContact(key)— O(1) lookup by contact key.
Changed
Section titled “Changed”contactsanddiscoveredare coalesced during a contact sync. They previously fired once perRESP_CONTACT; they now fire once, at the end of the iteration, immediately beforecontactsSynced. Syncing N contacts emits 2 full-list events instead of 2N. If you were relying on the list growing incrementally mid-sync, subscribe tocontactUpsertedinstead — andsyncProgressstill reports done/total throughout.- Contact ingest is O(1) per record rather than O(N):
SessionStateis backed by aMapandDiscoveredStore.list()is memoized behind a dirty flag. A full sync drops from O(N² log N) to O(N log N).getContacts()andDiscoveredStore.list()now return memoized arrays — treat them as immutable, asgetContacts()already required. - Placeholder-to-full-key reconciliation now removes the placeholder before
upserting the real contact, not after. The placeholder is the synthesised
stand-in a DM from an unknown sender creates until an advert supplies the
full public key; reconciling it outside a coalesced sync window — for
example,
addContactToRadiocommitting a discovered contact that already has an outstanding placeholder — now fires onecontactssnapshot where the contact is absent entirely, immediately followed by a second snapshot with the real contact. Both emits land in the same synchronous call, so batching frameworks (React 18+) never render the gap, but code that reacts tocontactsdirectly will see the transient.
Heard repeater relays are attributed to the send they actually belong to.
- Channel relay chips landed on the wrong message. Sending two channel
messages inside the retention window, where the first was never relayed,
credited every relay of the second send to the first one — the first
bubble showed
✓ ×2while the second stayedsent. Attribution matched on arrival order rather than on identity: the first pending entry whose channel hash matched and which had not yet locked a ciphertext claimed the observation, with no content, causality, or ownership check. Three distinct failures came out of that one rule, all now fixed:- relays of a later send credited to an earlier unheard one;
- a late relay of the earlier send then credited to the later one, swapping the two permanently;
- a stranger’s message on the same channel claiming a pending entry, after which the sender’s own relay no longer matched at all.
- Expiry only ever inspected the oldest entry. Eviction walked the head of the buffer and stopped at the first live entry, so anything behind a longer-lived entry survived indefinitely. It now scans the whole buffer.
- Stale sends survived a reconnect. Pending sends and recorded mesh
observations are now dropped on
session.stop()and on transport disconnect, so the first relay heard after reconnecting cannot be claimed by a send from the previous link.
Protocol.decryptGrpTxt(secretHex, macAndCipher). Verifies the 2-byte HMAC-SHA256 MAC on a channel packet and decrypts the AES-128-ECB body, returning{ timestampUnix, flags, body }. Validated against a real captured#bachelorettepacket.sendChannelTextreturnstimestampUnix— the timestamp the radio encrypts into the outgoing packet.registerChannelSendacceptstimestampUnix. Supplying it upgrades relay attribution from a heuristic to an exact match: the timestamp inside a heard packet identifies which send it is a relay of, and the MAC check rejects packets from a foreign channel whose one-byte hash collides with yours. Existing callers that omit it keep the previous (improved) heuristic behaviour.
A repeater CLI console API: its own send-state events, per-call timeouts, cancellation, and fire-and-forget commands.
cliSendStateevent. A repeater CLI command travels as a direct message on the wire but is not conversation traffic, so it no longer lands in the message store. Its lifecycle surfaces oncliSendState({ id, contactKey, state }) instead.cliUnmatchedevent. A CLI reply that arrives with no awaiter waiting for it — a late answer, or one the console already gave up on — is emitted ascliUnmatched({ contactKey, body }) rather than dropped or forced into the message store.repeaterSendCli(contactKey, command, opts)options:timeoutMs— per-call override of the wait.signal— anAbortSignal; the promise rejects withsignal.reason. The command may already be on the air, so aborting drops our awaiter and frees the per-repeater slot, it does not recall the send.expectReply: false— for commands the firmware never answers (reboot,poweroff,clkreboot,start ota): the handler reboots or powers down instead of writing a reply, and the firmware only transmits one when the reply is non-empty. Such a send registers no awaiter, resolves''as soon as the radio confirms the send, and rejects as soon as the send definitively fails. Its timer bounds that confirmation rather than a reply, so the default drops fromCLI_REPLY_TIMEOUT_MSto the much shorterADMIN_SENT_TIMEOUT_MS.
- Timeout defaults exported on
Models:CLI_REPLY_TIMEOUT_MS(30s),ADMIN_REPLY_TIMEOUT_MS(20s),ADMIN_SENT_TIMEOUT_MS(5s).
Changed
Section titled “Changed”- CLI sends no longer emit
messageStateor enter the message store. Consumers driving a repeater console listen oncliSendState; anything that correlated CLI traffic throughmessageStatehas to move.
- Unhandled rejection when a CLI send was aborted mid-write.
repeaterSendCliawaitedctx.writeFramebefore anything was attached to its wait promise, so an abort, supersede, or short timeout landing inside that window rejected a promise nobody was holding — fatal in the consumer’s process under Node’s default--unhandled-rejections=throw, and not catchable by the caller, since the rejection was detected beforereturn waitever chained it. The headline case is a console user clicking Cancel while a BLE GATT write drains. The write is now fired off and the wait returned in the same synchronous turn. - Fire-and-forget sends reported the wrong failure reason. A send with
expectReply: falseregisters no pending entry, so a disconnect or a radio rejection left it pending until its timeout, which then blamed “send was not confirmed” rather than what actually happened.
Tagged v0.4.1 but never published to npm — no GitHub Release was cut, so the
publish workflow never ran. Shipped as part of 0.5.0.
repeaterLoginmislabelled a direct neighbour as flood. Theeffectivelabel derived reachability fromoutPathHex, so a known 0-hop route (emptyout_path, butout_path_len != 0xFF) read as flood. The label now derives fromcontact.hops, matching meshcore_py: undefined (out_path_len0xFF, py’s-1) is the only flood case,0is direct, and>= 1is a routed path.sendAnonReq’s zero-hop-direct routing already mirrored meshcore_py and is unchanged.
Changed
Section titled “Changed”- Sends nobody has relayed yet expire after 30s rather than riding out the full 90s retention. An unclaimed entry is the one that can mis-claim a passing packet, so it should not linger; 30s still comfortably covers a one-way relay. Sends whose relay has been heard keep the full 90s window so late extra hops still attribute.
Guest logins, and repeater logins now route over the mesh.
- Guest login.
session.repeaterLogin(contactKey, '')now performs a guest login with an empty password. This is the bootstrap a public repeater expects before it will answer login-gated requests: the flooded login reply installs the contact’sout_pathand adds you to the repeater’s ACL.
Changed
Section titled “Changed”- Repeater logins always dispatch via
CMD_SEND_LOGIN(0x1a). The radio routes the frame for us — direct when the contact’sout_pathis known, flood when it isn’t — so the one command covers Direct / Flood / N-hop. Previously onlypreferDirectcontacts usedCMD_SEND_LOGIN; mesh logins went out as an anonymous request (CMD_SEND_ANON_REQ,0x39) that rejected empty passwords, so a guest login was impossible. repeaterLogin’s signature and return shape are unchanged (repeaterLogin(contactKey, password) → LoginSuccess & { mode, effective });mode/effectiveremain UI labels derived from the contact’s path state.
Removed
Section titled “Removed”Protocol.buildAnonLogin. It only existed to wrap a password as anon-request data for the old mesh-login path, and it could not build a valid guest frame (an empty anon request is rejected by the firmware). Login framing now goes throughProtocol.buildSendLogin, which accepts an empty password.
Developed after 0.3.1 as the 0.3.2-dev series and shipped as part of 0.4.0.
- Public owner-info and telemetry now work without a login. These requests
previously targeted the wrong companion command families, so a repeater serving
them publicly never answered (while
meshcore_pydid).session.repeaterRequestOwnerInfo(contactKey)now uses the public anon OWNER request and parses[now][name\nowner]; it returnsOwnerInfo | null(firmwareVersionis empty — the anon response carries no version).session.sendTelemetryReq(contactKey)now uses the binary TELEMETRY request and decodes the tagged CayenneLPP payload, re-emitting the samerepeaterTelemetrysnapshot. The legacyPUSH_TELEMETRY_RESPONSEhandler is retained for self/legacy devices.
- Multi-byte path-hash sizes are handled correctly. The
out_pathlength byte is now packed/parsed as MeshCore’s((hashSize - 1) << 6) | hopCount, and routes are reversed by hop, so hash sizes larger than one byte are no longer mangled (previously the code assumed 1-byte hashes).
- Low-level public anon request access:
session.sendAnonReq(contactKey, anonType)(anonTypefromProtocol.ANON_REQ_TYPE). - Typed public anon wrappers:
session.repeaterRequestRegions(contactKey)(region-name listing) andsession.repeaterRequestClock(contactKey)(the repeater’s RTC clock, unix seconds).