From dcf863e5819c71da0bdf792d93eb87fc45f39b74 Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:20:59 +0000 Subject: [PATCH] [verified] fix: accept LXST profile and mode signals --- lib/lxst_audio/LXSTSignalParser.h | 129 +++++++++++++++++++++++ lib/tdeck_ui/UI/LXMF/UIManager.cpp | 99 ++++++++--------- lib/tdeck_ui/UI/LXMF/UIManager.h | 2 + tests/README.md | 1 + tests/interop/conftest.py | 26 ++--- tests/interop/test_wire_format.py | 10 ++ tests/native/test_lxst_signal_parser.cpp | 81 ++++++++++++++ tests/native/test_lxst_signal_parser.py | 26 +++++ tools/voice_test/sideband_e2e.py | 20 +++- 9 files changed, 331 insertions(+), 63 deletions(-) create mode 100644 lib/lxst_audio/LXSTSignalParser.h create mode 100644 tests/native/test_lxst_signal_parser.cpp create mode 100644 tests/native/test_lxst_signal_parser.py diff --git a/lib/lxst_audio/LXSTSignalParser.h b/lib/lxst_audio/LXSTSignalParser.h new file mode 100644 index 00000000..217fea6e --- /dev/null +++ b/lib/lxst_audio/LXSTSignalParser.h @@ -0,0 +1,129 @@ +#pragma once + +#include +#include +#include + +// Bounded MessagePack parser for LXST {FIELD_SIGNALLING: [signal, ...]} packets. +// Current LXST sends profile and duplex mode together, while older releases sent +// one signal per packet. Accept canonical and valid wider integer containers. +struct LXSTSignalParser { + static constexpr size_t MAX_SIGNALS = 8; + + static size_t parse(const uint8_t* data, size_t length, int* output, + size_t output_capacity) { + if (!data || !output || output_capacity == 0) return 0; + + size_t position = 0; + uint32_t map_count = 0; + if (!readContainerCount(data, length, position, 0x80, 0x8F, + 0xDE, 0xDF, map_count) || + map_count != 1) { + return 0; + } + + uint64_t field = 0; + if (!readNonNegativeInteger(data, length, position, field) || field != 0) { + return 0; + } + + uint32_t signal_count = 0; + if (!readContainerCount(data, length, position, 0x90, 0x9F, + 0xDC, 0xDD, signal_count) || + signal_count == 0 || signal_count > MAX_SIGNALS || + signal_count > output_capacity) { + return 0; + } + + int parsed[MAX_SIGNALS] = {}; + for (uint32_t index = 0; index < signal_count; ++index) { + uint64_t value = 0; + if (!readNonNegativeInteger(data, length, position, value) || + value > static_cast(INT_MAX)) { + return 0; + } + parsed[index] = static_cast(value); + } + + if (position != length) return 0; + for (uint32_t index = 0; index < signal_count; ++index) { + output[index] = parsed[index]; + } + return signal_count; + } + +private: + static bool take(const uint8_t* data, size_t length, size_t& position, + uint8_t& value) { + if (position >= length) return false; + value = data[position++]; + return true; + } + + static bool readBigEndian(const uint8_t* data, size_t length, + size_t& position, size_t bytes, + uint64_t& value) { + if (bytes > length - position) return false; + value = 0; + for (size_t index = 0; index < bytes; ++index) { + value = (value << 8) | data[position++]; + } + return true; + } + + static bool readContainerCount(const uint8_t* data, size_t length, + size_t& position, uint8_t fixed_min, + uint8_t fixed_max, uint8_t count16_marker, + uint8_t count32_marker, uint32_t& count) { + uint8_t marker = 0; + if (!take(data, length, position, marker)) return false; + if (marker >= fixed_min && marker <= fixed_max) { + count = marker & 0x0F; + return true; + } + + uint64_t wide_count = 0; + if (marker == count16_marker) { + if (!readBigEndian(data, length, position, 2, wide_count)) return false; + } else if (marker == count32_marker) { + if (!readBigEndian(data, length, position, 4, wide_count)) return false; + } else { + return false; + } + if (wide_count > UINT32_MAX) return false; + count = static_cast(wide_count); + return true; + } + + static bool readNonNegativeInteger(const uint8_t* data, size_t length, + size_t& position, uint64_t& value) { + uint8_t marker = 0; + if (!take(data, length, position, marker)) return false; + if (marker <= 0x7F) { + value = marker; + return true; + } + + size_t bytes = 0; + bool signed_integer = false; + switch (marker) { + case 0xCC: bytes = 1; break; + case 0xCD: bytes = 2; break; + case 0xCE: bytes = 4; break; + case 0xCF: bytes = 8; break; + case 0xD0: bytes = 1; signed_integer = true; break; + case 0xD1: bytes = 2; signed_integer = true; break; + case 0xD2: bytes = 4; signed_integer = true; break; + case 0xD3: bytes = 8; signed_integer = true; break; + default: return false; + } + if (!readBigEndian(data, length, position, bytes, value)) return false; + if (signed_integer) { + const uint64_t sign_bit = bytes == 8 + ? (UINT64_MAX / 2 + 1) + : (uint64_t{1} << (bytes * 8 - 1)); + if ((value & sign_bit) != 0) return false; + } + return true; + } +}; diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 1105a68e..7c99fdb5 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -14,6 +14,7 @@ #include "Tone.h" #include "../LVGL/LVGLLock.h" #include "lxst_audio.h" +#include "LXSTSignalParser.h" #include "ULBWVoiceProfilePolicy.h" #include #include @@ -1522,60 +1523,60 @@ void UIManager::call_on_packet(const Bytes& data) { uint8_t field = buf[1]; if (field == 0x00) { - // Signalling: {0x00: [signal]} - // fixarray(1) = 0x91, then signal is a msgpack integer: - // 0x00-0x7F = fixint (1 byte) - // 0xCC XX = uint8 (2 bytes) - // 0xCD XX XX = uint16 (3 bytes) - if (buf[2] != 0x91) return; - - int signal = -1; - if (buf[3] <= 0x7F) { - // fixint: value is the byte itself - signal = buf[3]; - } else if (buf[3] == 0xCC && data.size() >= 5) { - // uint8 - signal = buf[4]; - } else if (buf[3] == 0xCD && data.size() >= 6) { - // uint16 (big-endian) - signal = ((int)buf[4] << 8) | buf[5]; - } - - if (signal < 0) { - char dbg[64]; - snprintf(dbg, sizeof(dbg), "LXST: Unparseable signal (0x%02X), %d bytes", buf[3], (int)data.size()); - WARNING(dbg); + // Signalling: {0x00: [signal, ...]}. LXST 0.5.1 combines the + // preferred profile and duplex mode in one two-element array; older + // peers send one signal per packet. + int signals[LXSTSignalParser::MAX_SIGNALS] = {}; + const size_t signal_count = LXSTSignalParser::parse( + buf, data.size(), signals, LXSTSignalParser::MAX_SIGNALS); + if (signal_count == 0) { + WARNING("LXST: Unparseable signalling packet"); return; } - // Remote sends PREFERRED_PROFILE + profile_id to request a transmit - // profile. Pyxis is ULBW-only for LoRa: never adopt the request, and - // respond with ULBW so the peer switches its transmit pipeline to 700C. - if (signal >= LXST_PREFERRED_PROFILE) { - int remote_profile = signal - LXST_PREFERRED_PROFILE; - char dbg[80]; - snprintf(dbg, sizeof(dbg), - "LXST: Remote prefers profile 0x%02X, responding 0x%02X", - remote_profile, _preferred_profile); - INFO(dbg); - call_send_signal(ULBWVoiceProfilePolicy::preferredProfileSignal()); - return; - } + for (size_t i = 0; i < signal_count; ++i) { + const int signal = signals[i]; - { - char dbg[48]; - snprintf(dbg, sizeof(dbg), "LXST: Received signal 0x%02X (queued)", signal); - INFO(dbg); - } + // Remote sends PREFERRED_PROFILE + profile_id to request a + // transmit profile. Pyxis is ULBW-only for LoRa: never adopt the + // request, and respond with ULBW so the peer switches to 700C. + if (signal >= LXST_PREFERRED_PROFILE) { + const int remote_profile = signal - LXST_PREFERRED_PROFILE; + char dbg[80]; + snprintf(dbg, sizeof(dbg), + "LXST: Remote prefers profile 0x%02X, responding 0x%02X", + remote_profile, _preferred_profile); + INFO(dbg); + call_send_signal(ULBWVoiceProfilePolicy::preferredProfileSignal()); + continue; + } - // Enqueue for processing in call_update() under LVGL lock - uint8_t w = _call_signal_write; - uint8_t next_w = (w + 1) % SIGNAL_QUEUE_SIZE; - if (next_w != _call_signal_read) { // Not full - _call_signal_queue[w] = (uint8_t)signal; - _call_signal_write = next_w; - } else { - WARNING("LXST: Signal queue full, dropping signal!"); + // Pyxis voice is always full duplex. Accept FDX and explicitly + // counter an HDX request without treating mode as call status. + if (signal >= LXST_PREFERRED_MODE) { + const int remote_mode = signal - LXST_PREFERRED_MODE; + if (remote_mode != LXST_MODE_FULL_DUPLEX) { + INFO("LXST: Remote requested non-FDX mode, responding FDX"); + call_send_signal(LXST_PREFERRED_MODE + LXST_MODE_FULL_DUPLEX); + } + continue; + } + + { + char dbg[48]; + snprintf(dbg, sizeof(dbg), "LXST: Received signal 0x%02X (queued)", signal); + INFO(dbg); + } + + // Enqueue for processing in call_update() under LVGL lock. + uint8_t w = _call_signal_write; + uint8_t next_w = (w + 1) % SIGNAL_QUEUE_SIZE; + if (next_w != _call_signal_read) { // Not full + _call_signal_queue[w] = static_cast(signal); + _call_signal_write = next_w; + } else { + WARNING("LXST: Signal queue full, dropping signal!"); + } } } else if (field == 0x01) { diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 6689167a..e7f03da9 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -377,6 +377,8 @@ private: static constexpr uint8_t LXST_CODEC_CODEC2 = 0x02; // LXST profile negotiation + static constexpr int LXST_PREFERRED_MODE = 0xF0; + static constexpr int LXST_MODE_FULL_DUPLEX = 0x01; static constexpr int LXST_PREFERRED_PROFILE = 0xFF; static constexpr int LXST_PROFILE_ULBW = 0x10; // Codec2 700C; sole production profile static constexpr int LXST_PROFILE_VLBW = 0x20; // protocol value; unsupported locally diff --git a/tests/README.md b/tests/README.md index 9c7c195c..2ed4fe21 100644 --- a/tests/README.md +++ b/tests/README.md @@ -24,6 +24,7 @@ System Python 3.9 has pytest pre-installed; Homebrew Python does not. - `native/test_call_start_mailbox.{cpp,py}` — exact 16-byte LXST outgoing-call handoff, duplicate rejection, and reusable producer/consumer stress - `native/test_call_generation_guard.{cpp,py}` — atomic, generation-scoped call admission, stale-owner protection, and repeated two-thread reservation races - `native/test_call_link_ownership.{cpp,py}` — exact 128-bit LXST link ownership publication, mixed-ID publication stress, and generation-bound deferred-close concurrency +- `native/test_lxst_signal_parser.{cpp,py}` — bounded MessagePack parsing for legacy one-signal and current LXST profile+duplex signalling arrays ### Adding a new native C++ test diff --git a/tests/interop/conftest.py b/tests/interop/conftest.py index feb4cb42..fae62f24 100644 --- a/tests/interop/conftest.py +++ b/tests/interop/conftest.py @@ -228,9 +228,10 @@ def build_signal_packet(signal_value): """ Build a signalling packet matching all implementations. - Format: {0x00: [signal_value]} + Format: {0x00: [signal_value, ...]} """ - packet_data = {FIELD_SIGNALLING: [signal_value]} + signals = signal_value if isinstance(signal_value, list) else [signal_value] + packet_data = {FIELD_SIGNALLING: signals} return msgpack.packb(packet_data) @@ -256,17 +257,16 @@ def parse_pyxis_rx(wire_bytes): result = {"field": field, "frames": [], "signals": []} if field == FIELD_SIGNALLING: - # {0x00: [signal]} - if buf[2] != 0x91: - return {"error": f"expected fixarray(1), got 0x{buf[2]:02x}"} - if buf[3] <= 0x7F: - result["signals"].append(buf[3]) - elif buf[3] == 0xCC and len(buf) >= 5: - result["signals"].append(buf[4]) - elif buf[3] == 0xCD and len(buf) >= 6: - result["signals"].append((buf[4] << 8) | buf[5]) - else: - return {"error": f"unparseable signal 0x{buf[3]:02x}"} + # {0x00: [signal, ...]}; production C++ parsing is covered by the + # native LXSTSignalParser golden-vector test. + unpacked = msgpack.unpackb(wire_bytes) + signals = unpacked.get(FIELD_SIGNALLING) + if not isinstance(signals, list) or not 1 <= len(signals) <= 8: + return {"error": "invalid signalling list"} + if not all(isinstance(signal, int) and 0 <= signal <= 0x7FFFFFFF + for signal in signals): + return {"error": "invalid signal value"} + result["signals"] = signals elif field == FIELD_FRAMES: fmt = buf[2] diff --git a/tests/interop/test_wire_format.py b/tests/interop/test_wire_format.py index 453a2f19..748b3c26 100644 --- a/tests/interop/test_wire_format.py +++ b/tests/interop/test_wire_format.py @@ -260,6 +260,16 @@ class TestSignallingFormat: assert pyxis_result["signals"] == [signal] assert lxst_result["signals"] == [signal] + def test_lxst_0_5_1_profile_and_mode_signal_list(self): + """Current LXST combines MQ profile and FDX mode in one packet.""" + signals = [PREFERRED_PROFILE + PROFILE_MQ, 0xF0 + 0x01] + wire = build_signal_packet(signals) + + # Golden bytes from authoritative LXST 0.5.1 umsgpack output. + assert wire == bytes([0x81, 0x00, 0x92, 0xCD, 0x01, 0x3F, 0xCC, 0xF1]) + assert parse_pyxis_rx(wire)["signals"] == signals + assert parse_lxst_python_rx(wire)["signals"] == signals + def test_pyxis_manual_signal_construction(self): """ Verify Pyxis call_send_signal() manual msgpack matches standard msgpack. diff --git a/tests/native/test_lxst_signal_parser.cpp b/tests/native/test_lxst_signal_parser.cpp new file mode 100644 index 00000000..1aa33c37 --- /dev/null +++ b/tests/native/test_lxst_signal_parser.cpp @@ -0,0 +1,81 @@ +#include +#include +#include + +#include "../../lib/lxst_audio/LXSTSignalParser.h" + +namespace { +int passed = 0; +int failed = 0; +void expect(bool condition, const char* name) { + if (condition) ++passed; + else { ++failed; std::cerr << "FAIL: " << name << '\n'; } +} +} + +int main() { + int signals[LXSTSignalParser::MAX_SIGNALS] = {}; + + const uint8_t legacy[] = {0x81, 0x00, 0x91, 0xCD, 0x01, 0x0F}; + size_t count = LXSTSignalParser::parse(legacy, sizeof(legacy), signals, + LXSTSignalParser::MAX_SIGNALS); + expect(count == 1, "legacy one-signal packet accepted"); + expect(signals[0] == 0x10F, "legacy ULBW profile decoded"); + + // Authoritative LXST 0.5.1 caller vector: + // {FIELD_SIGNALLING: [PREFERRED_PROFILE+MQ, PREFERRED_MODE+FDX]} + const uint8_t current[] = { + 0x81, 0x00, 0x92, 0xCD, 0x01, 0x3F, 0xCC, 0xF1 + }; + count = LXSTSignalParser::parse(current, sizeof(current), signals, + LXSTSignalParser::MAX_SIGNALS); + expect(count == 2, "current LXST profile+mode packet accepted"); + expect(signals[0] == 0x13F, "MQ profile decoded"); + expect(signals[1] == 0xF1, "full-duplex mode decoded"); + + const uint8_t array16[] = { + 0x81, 0x00, 0xDC, 0x00, 0x02, 0xCC, 0xF1, 0xCD, 0x01, 0x0F + }; + count = LXSTSignalParser::parse(array16, sizeof(array16), signals, + LXSTSignalParser::MAX_SIGNALS); + expect(count == 2, "array16 signalling accepted"); + expect(signals[0] == 0xF1 && signals[1] == 0x10F, + "array16 values decoded in order"); + + const uint8_t truncated[] = {0x81, 0x00, 0x92, 0xCD, 0x01, 0x3F, 0xCC}; + expect(LXSTSignalParser::parse(truncated, sizeof(truncated), signals, + LXSTSignalParser::MAX_SIGNALS) == 0, + "truncated list rejected atomically"); + + const uint8_t noncanonical_uint32[] = {0x81, 0x00, 0x91, 0xCE, 0, 0, 1, 0}; + count = LXSTSignalParser::parse(noncanonical_uint32, + sizeof(noncanonical_uint32), signals, + LXSTSignalParser::MAX_SIGNALS); + expect(count == 1, "noncanonical uint32 signal accepted"); + expect(signals[0] == 0x100, "noncanonical uint32 value decoded"); + + const uint8_t noncanonical_int16[] = {0x81, 0x00, 0x91, 0xD1, 0x01, 0x0F}; + count = LXSTSignalParser::parse(noncanonical_int16, + sizeof(noncanonical_int16), signals, + LXSTSignalParser::MAX_SIGNALS); + expect(count == 1, "noncanonical positive int16 signal accepted"); + expect(signals[0] == 0x10F, "noncanonical positive int16 decoded"); + + expect(LXSTSignalParser::parse(current, sizeof(current), signals, 1) == 0, + "caller capacity is enforced"); + + const uint8_t too_many[] = { + 0x81, 0x00, 0x99, 0, 1, 2, 3, 4, 5, 6, 7, 8 + }; + expect(LXSTSignalParser::parse(too_many, sizeof(too_many), signals, + LXSTSignalParser::MAX_SIGNALS) == 0, + "peer-controlled signal count is bounded"); + + const uint8_t trailing[] = {0x81, 0x00, 0x91, 0x04, 0x00}; + expect(LXSTSignalParser::parse(trailing, sizeof(trailing), signals, + LXSTSignalParser::MAX_SIGNALS) == 0, + "trailing bytes rejected"); + + std::cout << passed << " passed, " << failed << " failed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/tests/native/test_lxst_signal_parser.py b/tests/native/test_lxst_signal_parser.py new file mode 100644 index 00000000..27f79d76 --- /dev/null +++ b/tests/native/test_lxst_signal_parser.py @@ -0,0 +1,26 @@ +"""Compile and execute LXST signalling-list parser regressions.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +TEST_SOURCE = HERE / "test_lxst_signal_parser.cpp" + + +def test_lxst_signal_parser(tmp_path): + cxx = shutil.which("c++") + if not cxx: + pytest.skip("no C++ compiler found") + binary = tmp_path / "test_lxst_signal_parser" + compiled = subprocess.run( + [cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", str(TEST_SOURCE), "-o", str(binary)], + capture_output=True, + text=True, + ) + assert compiled.returncode == 0, compiled.stderr + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + assert ran.returncode == 0, ran.stdout + ran.stderr + assert "15 passed, 0 failed" in ran.stdout diff --git a/tools/voice_test/sideband_e2e.py b/tools/voice_test/sideband_e2e.py index c78a25b4..b675fbf0 100644 --- a/tools/voice_test/sideband_e2e.py +++ b/tools/voice_test/sideband_e2e.py @@ -30,7 +30,10 @@ from LXST.Primitives.Telephony import Profiles from sideband.voice import ReticulumTelephone PORT = os.environ.get("PYXIS_SERIAL_PORT") or sorted(glob.glob("/dev/cu.usbmodem*"))[0] -PROFILE = Profiles.BANDWIDTH_ULTRA_LOW +# Deliberately originate at Sideband's MQ/Opus profile. Pyxis is ULBW-only and +# must parse the combined LXST 0.5.1 [profile, duplex-mode] signal and force the +# peer's transmit pipeline to Codec2-700C. +PROFILE = Profiles.QUALITY_MEDIUM RUN_SECONDS = float(os.environ.get("PYXIS_CALL_SECONDS", "7")) IDENTIFY_TIMEOUT_SECONDS = 15.0 IDENTIFY_TIMEOUT_MARGIN = float(os.environ.get("PYXIS_IDENTIFY_TIMEOUT_MARGIN", "3")) @@ -414,6 +417,18 @@ def run_audio(dev, label): val(qos,"decode_ok")>0 and val(qos,"decode_fail")==0 and val(qos,"pcm_n")>0) return ok,{"sideband":d,"qos":qos,"stats":stat,"rms":rms} +def assert_sideband_ulbw(phone, label, timeout=8): + deadline=time.monotonic()+timeout + seen=[] + while time.monotonic()