[verified] fix: accept LXST profile and mode signals

This commit is contained in:
torlando-agent[bot]
2026-08-03 01:20:59 +00:00
parent 7cc368f34b
commit dcf863e581
9 changed files with 331 additions and 63 deletions
+129
View File
@@ -0,0 +1,129 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <climits>
// 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<uint64_t>(INT_MAX)) {
return 0;
}
parsed[index] = static_cast<int>(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<uint32_t>(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;
}
};
+50 -49
View File
@@ -14,6 +14,7 @@
#include "Tone.h"
#include "../LVGL/LVGLLock.h"
#include "lxst_audio.h"
#include "LXSTSignalParser.h"
#include "ULBWVoiceProfilePolicy.h"
#include <microReticulum/Packet.h>
#include <microReticulum/Transport.h>
@@ -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<uint8_t>(signal);
_call_signal_write = next_w;
} else {
WARNING("LXST: Signal queue full, dropping signal!");
}
}
} else if (field == 0x01) {
+2
View File
@@ -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
+1
View File
@@ -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
+13 -13
View File
@@ -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]
+10
View File
@@ -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.
+81
View File
@@ -0,0 +1,81 @@
#include <cstddef>
#include <cstdint>
#include <iostream>
#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;
}
+26
View File
@@ -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
+19 -1
View File
@@ -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()<deadline:
profile=phone.telephone.active_profile
seen.append(profile)
if profile==Profiles.BANDWIDTH_ULTRA_LOW:
print(f"{label} SIDEBAND_PROFILE=ULBW",flush=True)
return
time.sleep(.2)
raise AssertionError(f"{label}: Sideband remained non-ULBW; profiles={seen}")
def main():
print(f"PORT={PORT}",flush=True)
print(f"RNS_VERSION={RNS.__version__}",flush=True)
@@ -565,6 +580,7 @@ def main():
deadline=time.monotonic()+15
while not phone.is_in_call and time.monotonic()<deadline: time.sleep(.2)
assert phone.is_in_call,"Sideband did not become active after identification timeout"
assert_sideband_ulbw(phone,"TEST_IDENTIFY_TIMEOUT")
ok,data=run_audio(dev,"TEST_IDENTIFY_TIMEOUT")
phone.hangup()
idle_ok,hangup_states=dev.wait_state("IDLE",15)
@@ -590,6 +606,7 @@ def main():
deadline=time.monotonic()+15
while not phone.is_in_call and time.monotonic()<deadline: time.sleep(.2)
assert phone.is_in_call,"Sideband did not become active on incoming call"
assert_sideband_ulbw(phone,"TEST1")
ok,data=run_audio(dev,"TEST1")
results.append(("pyxis_to_sideband_bidirectional",ok,data))
dev.cmd("T:CALL_HANGUP"); dev.wait_state("IDLE",15); time.sleep(1)
@@ -605,6 +622,7 @@ def main():
deadline=time.monotonic()+15
while not phone.is_in_call and time.monotonic()<deadline: time.sleep(.2)
assert phone.is_in_call,"Sideband did not become active"
assert_sideband_ulbw(phone,"TEST2")
ok,data=run_audio(dev,"TEST2")
results.append(("sideband_to_pyxis_bidirectional",ok,data))
phone.hangup(); dev.wait_state("IDLE",15); time.sleep(1)