56 Commits
Author SHA1 Message Date
agessaman 6eeab56779 fix(ble): bound the write, and correct two reply-path edge cases
Follow-up to review of the two preceding commits.

The write lock added in 00135bb prevented concurrent writes from dropping the
link, but bounded nothing. CommandHandler's own timeout does not cover the
write: send() awaits _sender_func() and only afterwards arms
asyncio.wait(futures, timeout=...). So a stalled write -- observed on hardware
running to minutes -- held the lock indefinitely while every other command
queued behind it, with nothing logged, no error raised and no DISCONNECTED
event. Before the lock only the stalled command hung; the others went out and
could trip the error-19 disconnect, which at least recovered. The lock turned
a bounded, self-healing failure into an unbounded silent one, and also blocked
the post-reconnect CMD_APP_START behind the dead connection's holder.

The write (lock acquisition included) is now bounded by
BLEConnection.WRITE_TIMEOUT. On expiry the link is torn down rather than the
lock merely released: the underlying CoreBluetooth write may still be in
flight, and a second write racing it re-creates the overlap the lock exists to
prevent. Tearing down hands over to the reconnect path, which is bounded and
self-healing.

The lock is now a lazily-created property, mirroring _mesh_request_lock in
commands/base.py, so an instance built without __init__ still works. The two
BLE tests previously assigned _write_lock themselves, which meant deleting the
__init__ line left them green; there is now a test that __init__ provides it.

Also in send_anon_req:

- A failed change_contact_path() no longer proceeds. The device still has the
  contact as flood, so sendAnonReq() floods the request, and the server gates
  REGIONS/OWNER/BASIC behind isRouteDirect() and drops it -- the caller then
  waits out a full path-scaled timeout for a reply that cannot arrive. It now
  returns ERROR path_reset_failed.
- encode_reply_path() clamps to the server's 64-byte reply_path buffer, which
  MyMesh.cpp memcpys into with no length check, and rejects hash mode 3 (the
  4-byte hops Packet::isValidPathLen refuses). Not a regression -- the previous
  encoder overflowed identically -- but this function is the chokepoint and its
  comment claimed to bound the read.
- The hop count saturates at 63 instead of being masked with & 63, which would
  wrap a 64-hop path to zero hops, i.e. request a zero-hop reply from a distant
  node. Not reachable from a device-sourced contact (the reader caps the field
  at 63) but silent if it ever were.
- The suggested_timeout multiplier now scales by the hops actually emitted
  rather than the contact's claimed out_path_len, which can differ once the
  encoder clamps or truncates.

Correction to 30446ed's message: the claim that mode 0 is "byte-for-byte
identical" is wrong. Differentially, over 30000 randomised contact fields
restricted to what a device can actually emit, mode 0 diverges in 1177 of
10118 cases -- every one of them a path containing a 0x00 byte, and in every
one the old encoder was the wrong one. The accurate claim is "unchanged for
mode-0 paths containing no 0x00 byte".
2026-07-26 20:25:18 -07:00
agessaman 30446ed093 fix(anon-req): encode the reply path with its hash mode and hop order
An anon request tells the server how to route its answer back. The leading
byte of that reply path packs two fields, which the server unpacks as:

    reply_path_len       = byte & 63
    reply_path_hash_size = (byte >> 6) + 1

Three defects in producing it:

1. The hash mode was never written into the top two bits, so the server always
   read a hash size of 1 whatever the contact's real mode was.
2. The path was reversed byte-wise (out_path[::-1]) rather than hop-wise. A
   return path visits the same hops in reverse order with each hop's
   multi-byte hash intact.
3. reader.py built out_path by stripping every NUL from the fixed 64-byte
   field. That trims the padding but also eats a legitimate 0x00 inside a hop
   hash, shortening the path and shifting every hop after it. It now takes
   out_path_len * hash_size bytes, as PATH_DISCOVERY_RESPONSE already did.

Worked example at hash mode 2 (3 bytes per hop), for a contact two hops away
via aabbcc then ddeeff:

    before:  lenbyte 0x02, path ffeeddccbbaa
             -> server reads 2 hops of 1 byte, replies via ['ff', 'ee']
    after:   lenbyte 0x82, path ddeeffaabbcc
             -> server reads 2 hops of 3 bytes, replies via ['ddeeff', 'aabbcc']

The old form routes the response to hops that do not exist, so it is dropped
and the request times out.

At hash mode 0 both encodings are byte-identical -- the mode contributes
nothing to the high bits and byte-wise reversal equals hop-wise reversal for
single-byte hops -- which is why this stayed latent: mode 0 is the default.
Confirmed by the mode-0 and zero-hop tests passing unchanged against the old
code while the mode-1/mode-2 tests fail.

Scope: only anon requests routed direct to a contact with a known multi-hop
path. Flood requests are unaffected (the server answers via createPathReturn
and ignores the supplied reply path), as is login (handleLoginReq never sets
reply_path_len, so its reply always goes out flood). The neighbors zero-hop
probe is unaffected: length 0 makes hash size irrelevant.

Encoding is extracted into encode_reply_path() so it can be tested directly.
Verified on hardware only for the zero-hop case, which still works; the
multi-hop paths are covered by unit tests, as the test radio has no multi-hop
contacts to exercise on air.
2026-07-26 19:38:33 -07:00
agessaman 00135bbb95 fix(ble): serialise writes to the RX characteristic
Two overlapping write_gatt_char() calls on the same characteristic drop the
BLE link outright. Observed on macOS/CoreBluetooth as "BLE write failed: 19",
after which the connection is gone and the pending command never completes.

Nothing above the transport guaranteed callers were sequential: schedulers,
health checks, periodic status queries and user commands all issue commands
independently, so any unlucky overlap could take the radio down. The existing
_mesh_request_lock only guards a few binary-request helpers, not the transport.

Reproduced on a companion radio over BLE by issuing send_device_query() and
send_node_discover_req() concurrently:

  before:  BLE write failed: 19, connected=False, command hung >45s
  after:   both complete in 0.12s, connected=True

Issued sequentially the same two commands take 0.09s each and are fine, so it
is specifically the overlap. Ruled out as causes beforehand: notification load
(three discovers under a 91-packet firehose kept writes at 0.06-0.17s with the
link stable) and the discover command itself.

The lock is created lazily so it binds to the running loop, and is released on
the failure path so one failed write cannot wedge every later command.
2026-07-26 19:24:03 -07:00
agessaman 4ebde385dd fix(anon-req): allow requests to destinations that are not contacts
send_anon_req() refused to send whenever the destination pubkey was absent
from the client-side contact cache, returning ERROR contact_not_found.

The contact is consulted for one thing only: building the reply-path bytes
appended to the request. The companion firmware needs no contact of its own --
since FIRMWARE_VER_CODE 13 its CMD_SEND_ANON_REQ handler synthesises a
transient anon contact for an unknown pubkey with out_path_len = 0 (zero-hop
direct). Those entries live in a reserved slot ring, are hidden from
CMD_GET_CONTACTS and are never persisted, so nothing is polluted by them.

The client-side refusal therefore blocked a case the device supports, such as
asking a freshly discovered neighbour for its regions before it has ever been
added as a contact. Fall back to a zero-hop reply path instead.

Two smaller fixes in the same function:

- out_path_len is now read once into a local rather than re-read from the
  contact dict after the await. That dict is a live reference other commands
  mutate in place (send_msg_with_retry's flood fallback, reset_path); if it
  flipped to -1 mid-send the suggested_timeout multiplier became 4000 * 0 = 0,
  registering the binary request with a zero timeout so the response was
  dropped the moment it arrived.
- The value is clamped at 0. update_contact() normally reflects the change
  back onto the dict, but if it fails the dict stays -1 and the unsigned
  to_bytes raises OverflowError -- which skipped the reset_path at the end of
  the method and left the contact pinned to zero-hop on the device.

Verified against a companion radio on fw ver 13: without the change all five
discovered repeaters were refused client-side in 0.0s with no RF sent; with it
all three answered with their region scopes in ~1.1s.

test_send_anon_req_contact_not_found is replaced -- it codified the removed
limitation -- but its original regression (a TypeError on the NoneType
subscript) stays covered.
2026-07-26 19:23:32 -07:00
Matthew Wolter 1b1d66053c test: fix send_raw_data wrapper for 4-byte guard and path_len framing
Why: PR #86 (44b21be) reworked send_raw_data to frame as
0x19 | path_len(1) | path | payload and to reject payloads under 4
bytes, but left test_send_raw_data_wrapper unchanged. The test sent a
2-byte payload (now raising ValueError before any assertion) and
asserted captured_data[1:] == payload, which no longer holds because of
the inserted path_len byte. The firmware drops payloads under 4 bytes,
so the guard is correct; this fixes the test, not the guard.

Bumps the payload to 4 bytes and asserts the zero path_len byte plus the
payload at offset 2, validating the wire format #86 introduced.

Tests: tests/unit/test_protocol_surface_gaps.py -- full suite 156 passed
(test_send_raw_data_wrapper was the lone failure on current main).
2026-06-15 16:55:41 -07:00
Matthew Wolter 46288e4bef fix: decode trailing fields in AUTOADD_CONFIG/LOGIN_SUCCESS/ACK
Wire-format parity fixes in reader.py for trailing fields the
companion-radio firmware emits but the SDK decoder was dropping, plus an
over-read guard on DEFAULT_FLOOD_SCOPE. Each fix uses the existing
BATT_AND_STORAGE defensive-read pattern (up-front minimum-length check
plus per-field cumulative-length gates), so older firmware that doesn't
emit the trailing fields keeps decoding without raising. Every fix ships
a legacy-frame and modern-frame unit test pair.

AUTOADD_CONFIG (PacketType 25): companion-v1.14.0 firmware emits a
trailing max_hops byte (firmware commit 00566741); the SDK dropped it.
Adds a defensive `if len(data) >= 3` read. Pre-v1.14.0 frames decode to
`{"config": ...}` with no max_hops key.

LOGIN_SUCCESS (PacketType 0x85): the new-style RESP_SERVER_LOGIN_OK path
emits three trailing fields the SDK was dropping — server_timestamp (4B,
firmware commit 0e90b731), acl_permissions (1B, 7947e8a2), and
fw_ver_level (1B, 418ae08b), all first shipped in companion-v1.10.0.
Adds per-field length gates (>=12, >=13, >=14). Legacy 8-byte "OK"-path
frames decode unchanged.

ACK (PacketType 0x82): firmware emits a trailing 4-byte trip_time
(round-trip latency in ms) since companion-v1.0.0a (firmware commit
d9dc76f1, Jan 2025) — on the wire ~16 months but never surfaced by the
SDK. Adds an `if len(data) >= 9` read. Legacy 5-byte ACK frames decode
unchanged.

DEFAULT_FLOOD_SCOPE (PacketType 28): firmware emits a 48-byte populated
frame or a 1-byte sentinel when no scope is set. The SDK unconditionally
read 31+16 bytes, over-reading 47 bytes past the end of the sentinel
frame and dispatching `{"scope_name": "", "scope_key": ""}`. Adds an
`if len(data) >= 48` guard so the sentinel dispatches `{}`. Consumers
detecting "no scope" via `payload["scope_name"] == ""` should switch to
a key-presence check.

RAW_DATA: the payload-framing fix this branch originally carried landed
upstream first in PR #86 (commit 44b21be), with identical decode logic
(discard the reserved 0xFF byte, then read the remaining variable-length
payload). That redundant change is dropped here; this commit retains only
its regression test (test_raw_data_realistic_frame), which exercises the
upstream fix end-to-end — the upstream change shipped without a test.

CHANGELOG:
  - Decode max_hops trailing byte in AUTOADD_CONFIG (companion-v1.14.0+).
  - Decode server_timestamp / acl_permissions / fw_ver_level trailing
    fields in LOGIN_SUCCESS (companion-v1.10.0+).
  - Decode trip_time trailing field in ACK (companion-v1.0.0a+).
  - DEFAULT_FLOOD_SCOPE dispatches `{}` on the 1-byte sentinel frame
    instead of `{scope_name: "", scope_key: ""}`. Detect "no scope" via
    key presence.

Tests: 10 tests in tests/unit/test_protocol_surface_gaps.py (legacy +
modern frame pair per finding, including a RAW_DATA regression test for
the upstream fix); all 10 pass on the rebased upstream base.

Why: companion-radio firmware has been emitting these fields on the wire
for between 2 and 16 months; SDK consumers (integrations, bots,
dashboards) lose access to data that is on the wire today.
2026-06-15 08:56:39 -07:00
Matthew Wolter 3ed4ec3eab feat: add CHANNEL_DATA_RECV packet type and handler
Why: companion-radio firmware emits RESP_CODE_CHANNEL_DATA_RECV (27) for
group-channel binary data (PAYLOAD_TYPE_GRP_DATA), shipped in
companion-v1.15.0. The SDK had no PacketType value 27, no EventType, and
no reader handler, so these frames hit the unknown-packet-type
fallthrough and the payload was silently dropped.

This adds PacketType.CHANNEL_DATA_RECV = 27 (the previously-skipped enum
slot), EventType.CHANNEL_DATA_RECV, and a reader handler. The fixed
9-byte header (snr, reserved, channel_idx, path_len with the
sentinel/hash-mode encoding) reuses CHANNEL_MSG_RECV_V3's framing; the
typed tail decodes data_type (uint16 little-endian, widened from uint8
in firmware), data_len, and payload (hex string). An up-front length
gate mirrors the defensive-read pattern used by the other handlers.

data_type is exposed as an int (matching the txt_type convention) and
payload as a hex string (matching RAW_DATA's convention for binary data
of unknown encoding); attributes surface channel_idx and data_type so
subscribers can filter without unpacking the payload.

Tests: tests/unit/test_protocol_surface_gaps.py — 5 new tests (enum
slot present, direct-path frame, route-flood path_len bit-split,
under-minimum frame dropped, widened data_type round-trip). Full unit
suite: 146 passed.
2026-05-28 11:28:05 -07:00
fdlamotteandGitHub 2e178e83e7 Merge pull request #82 from jkingsman/add-repeater-error-count
Add repeater error count delivery in telemetry
2026-04-25 15:23:03 +02:00
fdlamotteandGitHub fda191d0a4 Merge branch 'main' into fix/standalone-bugs-and-cleanup 2026-04-25 15:21:16 +02:00
fdlamotteandGitHub b040656892 Merge branch 'main' into fix/reader-parser-crash-safety 2026-04-25 15:17:48 +02:00
fdlamotteandGitHub 173bba5a82 Merge pull request #80 from mwolter805/fix/protocol-surface-gaps
feat: add missing protocol handlers (CONTACT_DELETED, CONTACTS_FULL, TUNING_PARAMS) and command wrappers
2026-04-25 15:07:43 +02:00
fdlamotteandGitHub 5728463f21 Merge pull request #77 from mwolter805/fix/transport-symmetry
fix: symmetric disconnect signaling, serial timeout, BLE callback, oversize-frame recovery
2026-04-25 15:05:32 +02:00
fdlamotteandGitHub 2aff27e725 Merge pull request #76 from mwolter805/fix/error-response-handling
fix: add EventType.ERROR to command expected_events and guard error payloads
2026-04-25 15:04:30 +02:00
fdlamotteandGitHub 1c08569f07 Merge pull request #75 from mwolter805/fix/reconnect-path
fix: resolve reconnect storm — TCP Future return, missing appstart, task overwrite race
2026-04-25 15:02:30 +02:00
fdlamotteandGitHub df6cec1d0b Merge pull request #78 from mwolter805/fix/asyncio-lifecycle
fix: track background tasks, defer Queue/Lock construction, use get_running_loop
2026-04-25 15:00:44 +02:00
Jack Kingsman 5cf8150146 Add repeater error count delivery in telemetry 2026-04-22 17:31:18 -07:00
Matthew Wolter af886466d5 Remove finding IDs from test_standalone_fixes.py
Strip finding IDs (F13, F09, M03, M05, M07, R03, R05) from module
docstring, section comments, function names, and docstrings.
2026-04-12 07:58:44 -07:00
Matthew Wolter aa7f584ca0 Remove internal references from protocol surface gaps tests
Rename test_g6_protocol_surface_gaps.py to test_protocol_surface_gaps.py.
Strip G6 from module docstring, and finding IDs (N01, N02, N03, N05,
N09, R04) from docstrings and section comments.
2026-04-12 07:58:07 -07:00
Matthew Wolter 4fddbffa3d Remove internal references from asyncio lifecycle tests
Rename test_g5_asyncio_lifecycle.py to test_asyncio_lifecycle.py.
Strip G5 from module docstring, finding IDs (F05, F07, F08, F19)
from class names and docstrings.
2026-04-12 07:57:23 -07:00
Matthew Wolter b4b40718e9 Remove internal references from transport symmetry tests
Rename test_g4_transport_symmetry.py to test_transport_symmetry.py.
Strip G4 from module docstring, _g4_ from function names, and finding
IDs (F04, NEW-A, F18, M06, N04) from docstrings and section comments.
2026-04-12 07:56:48 -07:00
Matthew Wolter 578ac36ccd Remove internal references from error handling tests
Rename test_g2_error_handling.py to test_error_handling.py. Strip G2
prefix from module docstring, _g2_ from function names, and finding
IDs (F22, F21/M01, M02, M04, N06, F14) from docstrings and section
comments. Proposal cross-references removed.
2026-04-12 07:56:06 -07:00
Matthew Wolter 5a4960b268 Remove finding IDs from test_reader.py
Strip internal forensics finding references (F06, N07, NEW-C, R02)
from docstrings, comments, and assertion messages. Descriptive text
is preserved — only the ID prefixes are removed.
2026-04-12 07:55:03 -07:00
Matthew Wolter f3aa131019 Remove finding IDs from test_connection_manager.py
Strip internal forensics finding references (F01, F02, F03, N11)
from docstrings and section comments. The descriptive text is
preserved — only the ID prefixes are removed.
2026-04-12 07:54:02 -07:00
Matthew Wolter 9e2fc0d63e Remove internal G-numbering from test_connection_manager.py
Strip G3 from module docstring and _g3_ from function names.
Finding IDs (F01, F02, F03, N11) are preserved.
2026-04-12 07:53:10 -07:00
Matthew Wolter 66b4a532a1 Remove internal G-numbering from test_reader.py
Strip G1/ prefix from docstrings, _g1_ from function names,
and G1 from section comments. Finding IDs (F06, N07, etc.)
are preserved as they are meaningful standalone references.
2026-04-12 07:52:43 -07:00
Matthew Wolter 83cf65ec3c Rename test_g7_standalone_bugs_and_cleanup.py to test_standalone_fixes.py
Remove internal G-numbering from test filename and docstring
before upstream submission.
2026-04-12 07:44:33 -07:00
Matthew Wolter 74e349be97 Fix test_r03 resolving mock to include expected_ack payload
send_binary_req reads result.payload['expected_ack'] from MSG_SENT
events. The resolving subscribe must provide that key for MSG_SENT
event types to avoid KeyError.
2026-04-12 07:00:10 -07:00
Matthew Wolter 4c1e5f4fe2 Fix test_r03 mock to resolve events immediately
The test_r03_placeholder_registered_before_send test used a bare
MagicMock dispatcher whose subscribe never resolved event futures,
causing send() to block for DEFAULT_TIMEOUT (15s). Add a resolving
subscribe mock matching the pattern from the fixture fix on
fix/test-timeout-waste.
2026-04-12 06:58:30 -07:00
Matthew Wolter 75c4a58841 Fix test fixture to resolve events immediately instead of blocking
The mock_dispatcher fixture's fake_subscribe recorded event handlers
but never called them, causing asyncio.wait() to block for the full
DEFAULT_TIMEOUT (15s) on every test that passes expected_events to
send(). With 28 affected tests, the suite wasted ~8 minutes on dead
waits and required an undocumented pytest-timeout plugin to complete.

Add call_soon to the default fake_subscribe so futures resolve on the
next event loop iteration, matching the pattern already used by
setup_event_response(). Override with a non-resolving mock in
test_send_timeout to preserve timeout path coverage.

Suite now completes in <1 second with no --timeout flag.
2026-04-12 06:57:53 -07:00
Matthew Wolter 1a017709c5 G7: add verification tests for F13, F09, M03, M05, M07, R03, R05
Why: 15 tests covering all G7 findings — F13 req_mma removal confirmed,
F09 DEFAULT_TIMEOUT value and inheritance, M03 TypeError for bad scope types
plus regression checks for None/str/bytes, M05 dead shift removal via source
inspection, M07 timeout returns Error Event not None, R03 placeholder
registration before send, R05 annotation parity with EventDispatcher.

Refs: Forensics report findings F13, F09, M03, M05, M07, R03, R05
2026-04-12 04:52:38 -07:00
Matthew Wolter baa5494758 G6: add verification tests for N01, N02, N03, N05, N09, R04
Why: 16 tests covering all G6 findings — reader dispatch for
CONTACT_DELETED/CONTACTS_FULL/TUNING_PARAMS (including short-frame
guards), send_trace() padding behavior, all 5 new command wrappers
(send_raw_data, has_connection, get_tuning, get_contact_by_key,
factory_reset two-step), and GET_STATS enum existence + usage.

Refs: Forensics report findings N01, N02, N03, N05, N09, R04
2026-04-12 04:14:48 -07:00
Matthew Wolter 7b459aa6a5 G5: add verification tests for F05, F07, F08, F19
10 new tests in tests/unit/test_g5_asyncio_lifecycle.py:

- TestF05: _spawn_background retains tasks in TCP, Serial, and
  EventDispatcher; tracked tasks survive gc.collect(); TCP handle_rx
  and connection_lost use tracked dispatch.
- TestF07: stop() waits for in-flight async callbacks to complete.
- TestF08: EventDispatcher.queue is None before start(), created on
  start(), dispatch() before start() raises RuntimeError;
  CommandHandlerBase lock is None before access, created lazily.
- TestF19: send() calls get_running_loop (not get_event_loop).

Refs: Forensics report findings F05, F07, F08, F19
2026-04-12 03:57:35 -07:00
Matthew Wolter f6bc0908b0 G4: add verification tests for F04, NEW-A, F18, M06, F16, F17, N04
10 new tests in tests/unit/test_g4_transport_symmetry.py covering all
G4 findings:

- test_g4_tcp_send_write_error_fires_disconnect (F04): TCP write
  OSError fires _disconnect_callback.
- test_g4_serial_send_no_transport_fires_disconnect (NEW-A): serial
  send on None transport fires _disconnect_callback.
- test_g4_serial_send_write_error_fires_disconnect (F04): serial write
  OSError fires _disconnect_callback.
- test_g4_ble_send_no_client_fires_disconnect (F04): BLE send with no
  client fires _disconnect_callback.
- test_g4_serial_connect_timeout (F18): connect raises TimeoutError
  when connection_made never fires.
- test_g4_tcp_oversize_frame_empty_data_returns (M06): oversize header
  with empty trailing data returns without dispatch.
- test_g4_serial_oversize_frame_empty_data_returns (M06): same for
  serial transport.
- test_g4_tcp_receive_count_per_frame_not_per_segment (N04): 3 TCP
  segments carrying 1 frame yield _receive_count == 1.
- test_g4_tcp_multiple_frames_count_correctly (N04): 2 complete frames
  yield _receive_count == 2.

F16 and F17 are covered by the updated pre-existing test in
tests/test_ble_pin_pairing.py (committed with F17).

Refs: Forensics report findings F04, NEW-A, F18, M06, N04
2026-04-11 20:25:52 -07:00
Matthew Wolter 76e2e54157 G4: F17 — re-raise BLE pairing failure instead of swallowing
Why: When BLE pairing failed during connect(), the exception was caught
as a warning and connect() continued normally. This left the transport
in a half-usable state — the BLE link was up but the pairing handshake
never completed, so encrypted characteristics could silently fail.
Now the pairing exception disconnects the client and re-raises, giving
the caller a clean failure. Updated the pre-existing
test_ble_connection_with_pin_failed_pairing test to assert the re-raise
behavior instead of the old swallow-and-continue behavior.
Refs: Forensics report finding F17
2026-04-11 20:25:04 -07:00
Matthew Wolter 7293933582 G2: add verification tests for F22, F21/M01, M02, M04, N06, F14
12 new tests in tests/unit/test_g2_error_handling.py covering all
G2 findings:

- test_g2_event_is_error_true/false (F22): is_error() helper works.
- test_g2_send_msg_with_retry_error_no_keyerror (F21/M01): retry
  loop continues on ERROR instead of KeyError on missing expected_ack.
- test_g2_send_appstart_returns_error (M02): ERROR event returned
  immediately instead of hanging until timeout.
- test_g2_set_telemetry_mode_base/loc/env_error (M04): setters
  return ERROR instead of KeyError on appstart failure.
- test_g2_set_manual_add_contacts/advert_loc_policy/multi_acks_error
  (M04): remaining three setters return ERROR cleanly.
- test_g2_send_anon_req_contact_not_found (N06): returns ERROR
  instead of TypeError on NoneType subscript.
- test_g2_send_trace_unknown_path_hash_len (F14): returns ERROR
  instead of NameError on undefined 'e'.

Refs: Forensics report findings F22, F21, M01, M02, M04, N06, F14
2026-04-11 20:05:07 -07:00
Matthew Wolter 073fa26aa0 G3: add verification tests for F01, F02, F03, N11
Seven new tests in tests/unit/test_connection_manager.py covering all
four G3 findings:

- test_g3_tcp_connect_returns_plain_string (F01): CONNECTED event
  payload contains a plain string, not an asyncio.Future.
- test_g3_reconnect_loop_does_not_compound (F03): after max_attempts
  failures, exactly that many connect() calls are made — no fan-out.
- test_g3_disconnect_cancels_reconnect_loop (F03): disconnect()
  mid-loop cancels the single task cleanly.
- test_g3_reconnect_callback_called_after_reconnect (F02): callback
  is invoked after a successful reconnect.
- test_g3_reconnect_callback_failure_does_not_crash_loop (F02):
  callback exception is logged, reconnect still succeeds.
- test_g3_connect_none_is_soft_failure (N11): connect() returning
  None does not set _is_connected or emit CONNECTED.
- test_g3_no_reconnect_callback_is_noop (N11/F02): no callback
  provided — reconnect works, backwards-compatible.

Refs: Forensics report findings F01, F02, F03, N11
2026-04-11 19:48:02 -07:00
Matthew Wolter 2bf3f1b9dd fix: BATTERY handler drops frames shorter than 3 bytes (level field guard)
A BATTERY frame with len(data) < 3 caused dbuf.read(2) to return short
bytes; int.from_bytes(b"", ...) silently yielded 0, propagating a bogus
level=0 to HA sensors.  Same silent-zero class as N07 (storage fields).

Option B: early-return with debug log, matching the NEW-C pattern for
STATUS_RESPONSE.  No BATTERY event is dispatched for malformed frames.

Not in the original forensics report — discovered during G1 N07 work and
logged in issues_log.md.  Resolved here because no later branch touches
this handler.

Files changed:
- src/meshcore/reader.py: add `if len(data) < 3: return` guard before
  the level read in the BATTERY branch
- tests/unit/test_reader.py: add test_g1_battery_too_short_for_level —
  sends a 1-byte frame (type only), asserts no BATTERY event dispatched
  and debug log emitted
2026-04-11 19:24:26 -07:00
Matthew Wolter f3973151d6 G1: add verification tests for F06, N07, NEW-C, R02
Adds the four unit tests required by proposal §4.1 verification:

(a) test_g1_handle_rx_malformed_frame_logged_and_swallowed — F06.
    Sends a 4-byte CHANNEL_MSG_RECV_V3 frame missing the channel_idx
    byte. The handler raises IndexError on dbuf.read(1)[0]; the F06
    umbrella must catch it, log "handle_rx parse error" with a
    traceback, and return cleanly without dispatching any event.

(b) test_g1_battery_short_frame_omits_storage_fields — N07.
    Sends a 3-byte BATTERY frame (type + 2-byte level only). Verifies
    that exactly one BATTERY event is dispatched, the payload contains
    `level` but does NOT contain `used_kb` or `total_kb`. Pre-fix the
    `len(data) > 3` gate would have produced both fields with bogus
    silent zeros.

(c) test_g1_status_response_short_frame_skipped — NEW-C.
    Sends a 30-byte STATUS_RESPONSE push frame (well below the 60-byte
    minimum). Verifies that no STATUS_RESPONSE event is dispatched and
    the new "STATUS_RESPONSE push frame too short" debug log fires.

(d) test_g1_parse_packet_payload_txt_type_decodes_high_bits — R02.
    Sets up a synthetic channel with a known 16-byte AES key, encrypts
    a 16-byte plaintext where byte 4 = (5 << 2) | 1 = 0x15, builds the
    full pkt_payload (chan_hash + cipher_mac + ciphertext), wraps it in
    a minimal route header, calls parsePacketPayload directly, and
    asserts log_data["txt_type"] == 5 and log_data["attempt"] == 1.
    Pre-R02-fix `uncrypted[4:4]` was the empty slice, so txt_type was
    always 0 — this test would have failed with txt_type=0.

Adds a small _CapturingDispatcher helper class. Adds imports for
logging at the top of the file. The existing test_binary_response is
left untouched.

File: tests/unit/test_reader.py
2026-04-11 18:39:01 -07:00
Alex Wolden ed96df197a Fix 16 failing unit tests to match current source behavior
- Update mock dispatcher to use subscribe-before-send pattern matching
  the rewritten CommandHandler.send() method
- Use 32-byte pubkeys in tests for commands that now require
  prefix_length=32 (login, logout, statusreq, reset_path, share/export/remove contact)
- Fix send_trace test path format to match flags=1 (2-byte path hashes)
- Update LPP current test to expect signed wrap for values > 32.767
- Fix BinaryReqType import (moved from meshcore.parsing to meshcore.packets)
- Fix register_binary_request call signature (added pubkey_prefix param)
- Update timeout test to expect 'no_event_received' instead of 'timeout'
2026-04-05 18:38:16 -07:00
fdlamotteandGitHub 52ad5c201c Merge pull request #67 from jkingsman/respect-found-idx
Use the frame start once we've found it
2026-03-22 12:48:11 -04:00
Jack Kingsman 4df3655752 Use the frame start once we've found it 2026-03-21 21:08:04 -07:00
Jack Kingsman 3ad77d364d Fix three byte path packets 2026-03-18 17:31:17 -07:00
jkingsman 1ea32885a3 Add typing to send_chan_message with test 2025-12-23 18:40:59 -08:00
agessaman e0f71482c6 Add private key export support
- Add PRIVATE_KEY and DISABLED event types
- Add packet parsing for private key export responses
- Add export_private_key() method to DeviceCommands
- Add comprehensive unit tests
- Add BLE private key export example
- Update documentation with security notes
2025-10-12 18:23:32 -07:00
CopilotandGitHub 29003b94dc Implement BLE PIN pairing support for enhanced security
* Implement BLE pin pairing support with comprehensive tests and documentation
2025-09-24 00:21:30 +02:00
Alex Wolden ccb1d6eb9e Revert "Refactor command system to be queue based"
This reverts commit 28957a4b60.
2025-09-04 15:08:08 -07:00
fdlamotteandGitHub 4ce3a6fd9a Merge branch 'main' into feature/refactor 2025-08-06 10:56:24 +02:00
Alex Wolden 43e2cfc724 timing and test fixes 2025-08-05 23:08:17 -07:00
Alex Wolden 968e42c6c8 Add testing workflow 2025-08-05 13:21:30 -07:00
Ventz Petkov f4d3be1360 Fix: Improved BLE Connection Logic on macOS 2025-08-05 15:52:44 -04:00