From ff0e6553a02fac3b3f1606400e8303e93db34297 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:34:26 +0200 Subject: [PATCH] kiss updates --- .github/workflows/build.yml | 9 + AGENTS.md | 10 +- docs/ARCHITECTURE.md | 14 +- docs/BUILDING.md | 6 +- releasenotes/RELEASE_NOTES_1.18.0-zephcore.md | 23 +- tests/CMakeLists.txt | 2 + tests/README.md | 3 +- tests/catalog.json | 25 ++ tests/support/identity_utils_boundary.cpp | 11 + tests/support/zephyr/kernel.h | 6 +- tests/unit/kiss_modem_tests.cpp | 254 ++++++++++++++++++ zephcore/Kconfig | 10 +- zephcore/boards/common/kiss_modem.conf | 12 +- zephcore/boards/example_board/README.md | 1 + zephcore/boards/example_board/board.overlay | 3 + zephcore/src/main_kiss_modem.cpp | 61 ++++- 16 files changed, 427 insertions(+), 23 deletions(-) create mode 100644 tests/unit/kiss_modem_tests.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e13cc20..3aa9e47 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -164,6 +164,11 @@ jobs: - name: build firmwares run: bash build.sh nrf + # Roles no manifest publishes still have to compile. Built, not packaged: + # nothing here reaches firmware/. + - name: compile-only roles + run: west build -b rak4631 zephcore --pristine -- -DEXTRA_CONF_FILE="boards/common/kiss_modem.conf" + # Hit rate is the entire point of the per-job ccache namespace above, but the # action only prints stats at SETUP time — it runs `ccache -z -s -vv`, which # reports the restored cache and then zeroes the counters. Without a stats @@ -372,6 +377,10 @@ jobs: - name: build firmwares run: bash build.sh esp32 companions + # Roles no manifest publishes still have to compile (see build-nrf). + - name: compile-only roles + run: west build -b xiao_esp32c3 zephcore --pristine -- -DEXTRA_CONF_FILE="boards/common/kiss_modem.conf" + - name: ccache stats if: always() run: ccache -s -vv diff --git a/AGENTS.md b/AGENTS.md index 8f616b3..ec4f928 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,12 +56,18 @@ touch before writing code. Decisions are in `docs/adr/`. - Build with `--pristine` when the board or role changes; ESP32 S3/C-series repeaters need `--sysbuild`. - Host tests: `python3 tests/run.py run --profile quick` (Linux or WSL). A new case needs a `tests/catalog.json` entry. A failing pinned test is a finding: do not edit the pinned values to make it pass. +- Logic that runs without hardware (a protocol parser, a state machine, a timing rule, a `// ZEPHCORE:` change + to upstream behaviour) comes with host tests that pin it. - PR CI runs the host tests only. Build every board you touched and one you did not, and run each role you - changed. Releases build from `zephcore.yml` alone, so nothing may depend on a hand-passed fragment. + changed. Releases build from `zephcore.yml` alone, so nothing may depend on a hand-passed fragment. A role or + fragment no `zephcore.yml` row builds goes into the "compile-only roles" step of `.github/workflows/build.yml`. +- A new role or composition root puts every part the board has into a defined state, used or not: GPS parked, + LEDs off, display blanked, as `src/server_main_common.cpp` does. - New board: `zephcore.yml` without `release:`, then `python zephcore/scripts/board_manifest.py check` and `... docs`. - Docs in the same PR: a clause in `docs/ARCHITECTURE.md` when behaviour, an interface or a limit changes; - `docs/CLI_commands.md` for any CLI change; an ADR when a decision is taken or reversed. + `docs/CLI_commands.md` for any CLI change; an ADR when a decision is taken or reversed; the next release's + `releasenotes/RELEASE_NOTES_-zephcore.md` for anything a user sees. ## PR description diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cbd8c2..ad0d8de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -749,9 +749,12 @@ Listen-only node (ESP32 only): receives LoRa packets and publishes them to an MQ Upstream's `examples/kiss_modem` (protocol: [kiss_modem_protocol.md](kiss_modem_protocol.md)). `app/KissModem.cpp/h` keep upstream's text, with ZephCore changes fenced; `src/main_kiss_modem.cpp` is the composition root. No `Mesh` or `Dispatcher`: the modem drives `LoRaRadio` directly, and the main loop runs the radio's maintenance (noise floor, -CAD, AGC/drift) as the Dispatcher would. The host serial is the CDC-ACM port, else the console UART, through -`adapters/transport/ZephyrUartStream` (an Arduino `Stream` over interrupt-driven RX/TX rings); the console driver -is off in this role so nothing else writes to the port. Event-driven: serial input, LoRa RX and TX-done, host +CAD, AGC/drift) as the Dispatcher would. The host serial is the chosen `zephcore,kiss-uart` node (upstream's +`KISS_UART_RX/TX`: header pins to an SBC, baud rate from its `current-speed`), else the CDC-ACM port, else the +console UART, through `adapters/transport/ZephyrUartStream` (an Arduino `Stream` over interrupt-driven RX/TX rings). +USB is brought up in every case, so the CDC port keeps the 1200-baud DFU touch. The console driver is off in this +role so nothing else writes to the port; with a `zephcore,kiss-uart` other than the console it may be turned back +on, and a build assert rejects a console on the KISS port. Event-driven: serial input, LoRa RX and TX-done, host output drained, and a one-shot for the sooner of `KissModem::msUntilNextTx()` and the radio's maintenance deadline. Differences from upstream: - An LBT refusal from `startSendRaw()` returns to the CSMA slot wait until the wait-clear bound (1.5 × the @@ -760,7 +763,10 @@ deadline. Differences from upstream: - `SetRadio` outside the `ZC_RADIO_*` ranges (NodePrefs.h) is refused with `HW_ERR_INVALID_PARAM`. - No periodic AGC reset: the radio's own deaf-aware maintenance replaces it, as in every role. Radio settings live in RAM only (upstream: the same); the identity is `/lfs/repeater/_main.id`, shared with the -server roles. Build with `boards/common/kiss_modem.conf`. +server roles. No UI: at boot the LEDs are driven off and a display is blanked (`mc_display_off()`, or +`oled_sleep()` without the display UI), as a server leaves them after its auto-off. Host tests: `UNIT-KISS-*` +(`tests/unit/kiss_modem_tests.cpp`). Not released from `zephcore.yml`; the release workflow compiles it for rak4631 +and xiao_esp32c3. Build with `boards/common/kiss_modem.conf`. ### 6.6 CommonCLI Commands diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 85f6129..b541992 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -42,7 +42,7 @@ west build -b zephcore --pristine -- -DEXTRA_CONF_FILE="boards/common/ [!NOTE] > A normal upgrade keeps your identity, settings, contacts and phone pairing. @@ -39,6 +39,25 @@ back. Now only satellite-solved positions count, five in a row. A fix takes abou This applies to every board with a GPS. Thanks to **Innocent Killer 101** for the report and the measurements. +## New: KISS modem role + +A ZephCore node can now act as a plain LoRa modem for a program on a computer, as upstream +MeshCore's `kiss_modem` does. The program sends and receives raw packets over USB, sets the +radio, and uses the node's key to sign, verify, encrypt and decrypt. There is no mesh, no +Bluetooth and no CLI on the node in this role. + +- It speaks upstream's protocol ([kiss_modem_protocol.md](../docs/kiss_modem_protocol.md)), so + host software written for upstream's modem works with it. +- When the channel is busy, a packet waits for it to clear instead of failing at once. +- Radio settings outside the ranges ZephCore supports are refused. +- The modem can also talk on a UART of your choice, for example header pins wired to a Raspberry + Pi, named `zephcore,kiss-uart` in the devicetree; USB then stays free for updates. +- No prebuilt image yet: build it with `boards/common/kiss_modem.conf` + ([BUILDING.md](../docs/BUILDING.md)). It keeps the identity of a repeater, room server or + observer on the same node; flashed over a companion, it erases the companion's data. + +Thanks to **recrof** for the port. + ## From upstream MeshCore 1.18.0 - **Acknowledgements on direct routes are relayed after a short random delay**, like other direct diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 49df4c7..f645870 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(zephcore_unit unit/prefs_json_tests.cpp unit/lpp_tests.cpp unit/telemetry_text_tests.cpp + unit/kiss_modem_tests.cpp support/hash_capture.cpp support/identity_utils_boundary.cpp ${PRODUCTION}/src/Dispatcher.cpp @@ -37,6 +38,7 @@ add_executable(zephcore_unit ${PRODUCTION}/helpers/PrefsJson.cpp ${PRODUCTION}/helpers/ConfigSerializer.cpp ${PRODUCTION}/helpers/ui/telemetry_text.cpp + ${PRODUCTION}/app/KissModem.cpp ) target_compile_features(zephcore_unit PRIVATE cxx_std_17) target_compile_definitions(zephcore_unit PRIVATE CONFIG_ZEPHCORE_PACKET_LOGGING=0 diff --git a/tests/README.md b/tests/README.md index a653bb5..c0e2f09 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,7 +7,7 @@ python tests/run.py run --profile quick ``` This first implementation builds **actual production C++ sources** and executes -the named native cases listed in `catalog.json` (114 on 2026-09-25) plus the Python +the named native cases listed in `catalog.json` (113 on 2026-10-15) plus the Python harness self-tests. No boards, Zephyr workspace, west, network access or third-party Python packages are required. This is the first subset of the [regression plan](../docs/REGRESSION_TEST_PLAN.md), not completion of @@ -73,6 +73,7 @@ and rejects missing, unexpected, renamed or duplicate cases. | `include/mesh/Maintenance.h` | Due/past/future deadlines, wraparound and idle composition | | `src/Dispatcher.cpp` | Immediate wakes, scheduled TX, completion/timeout ownership, priority-preserving refusal, LBT starvation across retry gaps, final RX gate, busy recovery, budget refill/admin exception, RX draining/hold/retransmit, maintenance, truncated headers, stats reset, clock wrap and 4,096 generated parser inputs | | `src/Identity.cpp` and bundled Monocypher | Two RFC8032 Ed25519 known-answer vectors, tampered signatures/messages, wrong signer, canonical/legacy/private-only storage, failed-read state preservation and bidirectional ECDH | +| `app/KissModem.cpp` | KISS escaping both ways and oversized-frame recovery, LBT refusal back to CSMA until the wait-clear bound, TX timeout only after the radio concludes, SetRadio range refusal, `msUntilNextTx()` per state | The catalog maps stable case IDs to their test source files. Production paths above are relative to `zephcore/`. Generated-case seeds are fixed and documented in the diff --git a/tests/catalog.json b/tests/catalog.json index 8f024ee..a149814 100644 --- a/tests/catalog.json +++ b/tests/catalog.json @@ -541,6 +541,31 @@ "id": "UNIT-UTF8-001", "name": "Complete codepoints only and invalid UTF-8 rejection", "source": "tests/unit/advert_tests.cpp" + }, + { + "id": "UNIT-KISS-001", + "name": "Host frames are unescaped to the radio and received packets are escaped with RX metadata", + "source": "tests/unit/kiss_modem_tests.cpp" + }, + { + "id": "UNIT-KISS-002", + "name": "An LBT refusal returns to CSMA and fails only at the wait-clear bound", + "source": "tests/unit/kiss_modem_tests.cpp" + }, + { + "id": "UNIT-KISS-003", + "name": "A transmit times out only once the radio has concluded it", + "source": "tests/unit/kiss_modem_tests.cpp" + }, + { + "id": "UNIT-KISS-004", + "name": "SetRadio outside the ZephCore preset ranges is refused", + "source": "tests/unit/kiss_modem_tests.cpp" + }, + { + "id": "UNIT-KISS-005", + "name": "msUntilNextTx reports each timed state and nothing while idle", + "source": "tests/unit/kiss_modem_tests.cpp" } ] } diff --git a/tests/support/identity_utils_boundary.cpp b/tests/support/identity_utils_boundary.cpp index a5b3479..3e47ae4 100644 --- a/tests/support/identity_utils_boundary.cpp +++ b/tests/support/identity_utils_boundary.cpp @@ -39,3 +39,14 @@ void mesh::Utils::secureZeroize(void*, size_t) { bool mesh::Utils::constantTimeEqual(const void*, const void*, size_t) { throw std::runtime_error("Identity validation requires real Utils integration"); } +// KissModem's hash and encrypt/decrypt commands (not exercised by its tests; +// the one-fragment sha256 is hash_capture.cpp's). +void mesh::Utils::sha256(uint8_t*, size_t, const uint8_t*, int, const uint8_t*, int) { + throw std::runtime_error("KISS hash command requires real Utils integration"); +} +int mesh::Utils::encryptThenMAC(const uint8_t*, uint8_t*, const uint8_t*, int) { + throw std::runtime_error("KISS encrypt command requires real Utils integration"); +} +int mesh::Utils::MACThenDecrypt(const uint8_t*, uint8_t*, const uint8_t*, int) { + throw std::runtime_error("KISS decrypt command requires real Utils integration"); +} diff --git a/tests/support/zephyr/kernel.h b/tests/support/zephyr/kernel.h index e52bb92..d193a8f 100644 --- a/tests/support/zephyr/kernel.h +++ b/tests/support/zephyr/kernel.h @@ -1,4 +1,8 @@ #pragma once #include -// The time-sync target uses only uptime, not a simulated Zephyr scheduler. +// Uptime only, not a simulated Zephyr scheduler: the time-sync target uses +// k_uptime_get(), the KISS modem k_uptime_get_32() and k_msleep(); each test +// file that needs one defines it over its own clock. extern "C" int64_t k_uptime_get(); +extern "C" uint32_t k_uptime_get_32(); +extern "C" int32_t k_msleep(int32_t ms); diff --git a/tests/unit/kiss_modem_tests.cpp b/tests/unit/kiss_modem_tests.cpp new file mode 100644 index 0000000..b66d69e --- /dev/null +++ b/tests/unit/kiss_modem_tests.cpp @@ -0,0 +1,254 @@ +#include "dispatcher_fixture.h" +#include +#include +#include + +// KissModem (app/KissModem.cpp) against a scripted host stream and radio: KISS +// framing both ways, and the ZephCore changes to its TX state machine (LBT +// refusal back to CSMA, timeout only once the radio has concluded, SetRadio range +// check, msUntilNextTx()). + +static uint32_t kiss_now = 0; +extern "C" uint32_t k_uptime_get_32() { return kiss_now; } +extern "C" int32_t k_msleep(int32_t ms) { kiss_now += uint32_t(ms); return 0; } + +namespace { +struct HostStream : Stream { + std::deque in; + std::vector out; + int available() override { return int(in.size()); } + int read() override { + if (in.empty()) return -1; + uint8_t b = in.front(); in.pop_front(); return b; + } + int peek() override { return in.empty() ? -1 : in.front(); } + size_t write(uint8_t c) override { out.push_back(c); return 1; } + int availableForWrite() override { return 4096; } +}; +struct ZeroRng : mesh::RNG { + void random(uint8_t *dest, size_t sz) override { memset(dest, 0, sz); } +}; +struct Board : mesh::MainBoard { + uint16_t getBattMilliVolts() override { return 4000; } + const char *getManufacturerName() const override { return "test"; } + void reboot() override {} + uint8_t getStartupReason() const override { return 0; } +}; + +std::vector escaped(uint8_t type, const std::vector& data) { + std::vector f{KISS_FEND}; + auto put = [&](uint8_t b) { + if (b == KISS_FEND) { f.push_back(KISS_FESC); f.push_back(KISS_TFEND); } + else if (b == KISS_FESC) { f.push_back(KISS_FESC); f.push_back(KISS_TFESC); } + else f.push_back(b); + }; + put(type); + for (uint8_t b : data) put(b); + f.push_back(KISS_FEND); + return f; +} + +// Splits the host output into unescaped frames (type byte first). +std::vector> frames(const std::vector& out) { + std::vector> result; + std::vector cur; + bool esc = false; + for (uint8_t b : out) { + if (b == KISS_FEND) { + if (!cur.empty()) result.push_back(cur); + cur.clear(); esc = false; + } else if (b == KISS_FESC) { + esc = true; + } else { + cur.push_back(esc ? (b == KISS_TFEND ? KISS_FEND : KISS_FESC) : b); + esc = false; + } + } + return result; +} + +struct Modem { + HostStream host; + test_support::Radio radio; + ZeroRng rng; + Board board; + SensorManager sensors; + mesh::LocalIdentity identity; + KissModem modem{host, identity, rng, radio, board, sensors}; + + Modem() { kiss_now = 1000; radio.airtime = 1000; modem.begin(); } + void send(uint8_t type, const std::vector& data) { + for (uint8_t b : escaped(type, data)) host.in.push_back(b); + } + // As the event loop: several passes per wake, so every step made due is taken. + void pass() { for (int i = 0; i < 4; i++) modem.loop(); } + void advance(uint32_t ms) { kiss_now += ms; pass(); } + // Result byte of the TX_DONE notification, or -1 if none has been sent. + int txDone() { + for (auto& f : frames(host.out)) { + if (f.size() == 3 && f[0] == KISS_CMD_SETHARDWARE && f[1] == HW_RESP_TX_DONE) return f[2]; + } + return -1; + } + // Runs the modem in slot-sized steps until TX_DONE or `limit` ms have passed. + int runUntilDone(uint32_t limit) { + for (uint32_t t = 0; t <= limit && txDone() < 0; t += 10) advance(10); + return txDone(); + } +}; +} + +TEST(kiss_framing_round_trip, "UNIT-KISS-001", + "Host frames are unescaped to the radio and received packets are escaped with RX metadata") { + Modem m; + const std::vector payload{0x01, KISS_FEND, 0x02, KISS_FESC, 0x03}; + m.send(KISS_CMD_TXDELAY, {0}); + m.send(KISS_CMD_DATA, payload); + m.pass(); + CHECK(m.radio.sent.size() == 1); + CHECK(m.radio.sent[0] == payload); + + m.host.out.clear(); + m.modem.onPacketReceived(-9, -100, payload.data(), uint16_t(payload.size())); + m.pass(); + auto out = frames(m.host.out); + CHECK(out.size() == 2); + std::vector data{KISS_CMD_DATA}; + data.insert(data.end(), payload.begin(), payload.end()); + CHECK(out[0] == data); + CHECK((out[1] == std::vector{KISS_CMD_SETHARDWARE, HW_RESP_RX_META, uint8_t(-9), uint8_t(-100)})); + // Every byte between the frame delimiters is escaped: no bare FEND inside a frame. + CHECK(std::count(m.host.out.begin(), m.host.out.end(), KISS_FEND) == 4); + + // An oversized frame without a closing FEND is dropped and the parser recovers. + m.host.in.push_back(KISS_FEND); + for (int i = 0; i < KISS_MAX_FRAME_SIZE + 10; i++) m.host.in.push_back(0x00); + m.send(KISS_CMD_SETHARDWARE, {HW_CMD_PING}); + m.host.out.clear(); + m.pass(); + out = frames(m.host.out); + CHECK(out.size() == 1); + CHECK(out[0][0] == KISS_CMD_SETHARDWARE && out[0][1] == HW_RESP(HW_CMD_PING)); +} + +TEST(kiss_lbt_refusal_back_to_csma, "UNIT-KISS-002", + "An LBT refusal returns to CSMA and fails only at the wait-clear bound") { + Modem m; + m.radio.accept = false; + m.send(KISS_CMD_TXDELAY, {0}); + m.send(KISS_CMD_DATA, {0xAA}); + const uint32_t start = kiss_now; + m.pass(); + CHECK(m.txDone() < 0); + CHECK(m.modem.msUntilNextTx() == KISS_DEFAULT_SLOTTIME * 10); + + // Bound: 1.5 x the airtime of a full packet (1000 ms in this radio) from the start of CSMA. + CHECK(m.runUntilDone(2000) == 0x00); + CHECK(kiss_now - start >= 1500); + CHECK(kiss_now - start < 1500 + KISS_DEFAULT_SLOTTIME * 10 + 20); + CHECK(m.radio.attempts > 5); + CHECK(!m.modem.isTxBusy()); + + // The channel clears inside the bound: the frame goes out and completes. + Modem n; + n.radio.accept = false; + n.send(KISS_CMD_TXDELAY, {0}); + n.send(KISS_CMD_DATA, {0xBB}); + n.pass(); + n.advance(300); + n.radio.accept = true; + n.runUntilDone(200); + CHECK(n.radio.sent.size() == 1); + CHECK(n.modem.isActuallyTransmitting()); + n.radio.completion = true; + n.pass(); + CHECK(n.txDone() == 0x01); + CHECK(n.radio.finished == 1); +} + +TEST(kiss_tx_timeout_waits_for_radio, "UNIT-KISS-003", + "A transmit times out only once the radio has concluded it") { + Modem m; + m.send(KISS_CMD_TXDELAY, {0}); + m.send(KISS_CMD_DATA, {0xCC}); + m.pass(); + CHECK(m.modem.isActuallyTransmitting()); + CHECK(m.modem.msUntilNextTx() == 1500); + + // Past the airtime bound while the radio still transmits: no verdict, no timed wake. + m.advance(10000); + CHECK(m.txDone() < 0); + CHECK(m.radio.finished == 0); + CHECK(m.modem.msUntilNextTx() == UINT32_MAX); + + // The radio concludes without a completion: the timeout fires on the next pass. + m.radio.active = false; + m.pass(); + CHECK(m.txDone() == 0x00); + CHECK(m.radio.finished == 1); + CHECK(!m.modem.isTxBusy()); + CHECK(m.modem.msUntilNextTx() == UINT32_MAX); +} + +static float set_freq, set_bw; +static uint8_t set_sf, set_cr; +static int set_calls; +static void on_set_radio(float freq, float bw, uint8_t sf, uint8_t cr) { + set_freq = freq; set_bw = bw; set_sf = sf; set_cr = cr; set_calls++; +} + +TEST(kiss_set_radio_ranges, "UNIT-KISS-004", + "SetRadio outside the ZephCore preset ranges is refused") { + Modem m; + m.modem.setRadioCallback(on_set_radio); + set_calls = 0; + auto setRadio = [&](uint32_t hz, uint32_t bw_hz, uint8_t sf, uint8_t cr) { + std::vector d{HW_CMD_SET_RADIO}; + for (int i = 0; i < 4; i++) d.push_back(uint8_t(hz >> (8 * i))); + for (int i = 0; i < 4; i++) d.push_back(uint8_t(bw_hz >> (8 * i))); + d.push_back(sf); d.push_back(cr); + m.host.out.clear(); + m.send(KISS_CMD_SETHARDWARE, d); + m.pass(); + auto out = frames(m.host.out); + CHECK(out.size() == 1); + return out[0]; + }; + const std::vector ok{KISS_CMD_SETHARDWARE, HW_RESP_OK}; + const std::vector refused{KISS_CMD_SETHARDWARE, HW_RESP_ERROR, HW_ERR_INVALID_PARAM}; + + CHECK(setRadio(869525000, 62500, 7, 5) == ok); + CHECK(set_calls == 1 && set_sf == 7 && set_cr == 5); + CHECK(set_freq > 869.52f && set_freq < 869.53f && set_bw == 62.5f); + + CHECK(setRadio(100000000, 62500, 7, 5) == refused); // below 150 MHz + CHECK(setRadio(869525000, 5000, 7, 5) == refused); // below 7 kHz + CHECK(setRadio(869525000, 62500, 4, 5) == refused); // SF below 5 + CHECK(setRadio(869525000, 62500, 13, 5) == refused); // SF above 12 + CHECK(setRadio(869525000, 62500, 7, 9) == refused); // CR above 4/8 + CHECK(set_calls == 1); +} + +TEST(kiss_wake_deadlines, "UNIT-KISS-005", + "msUntilNextTx reports each timed state and nothing while idle") { + Modem m; + CHECK(m.modem.msUntilNextTx() == UINT32_MAX); + + // Default TXDELAY (50 x 10 ms) after a clear channel. + m.send(KISS_CMD_DATA, {0xDD}); + m.pass(); + CHECK(m.modem.msUntilNextTx() == KISS_DEFAULT_TXDELAY * 10); + m.advance(200); + CHECK(m.modem.msUntilNextTx() == KISS_DEFAULT_TXDELAY * 10 - 200); + m.advance(KISS_DEFAULT_TXDELAY * 10 - 200); + CHECK(m.modem.isActuallyTransmitting()); + + // Busy channel: rechecked once per slot. + Modem n; + n.radio.receiving = true; + n.send(KISS_CMD_DATA, {0xEE}); + n.pass(); + CHECK(n.modem.msUntilNextTx() == KISS_DEFAULT_SLOTTIME * 10); + n.advance(30); + CHECK(n.modem.msUntilNextTx() == KISS_DEFAULT_SLOTTIME * 10 - 30); +} diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 5727265..3509ae9 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -164,10 +164,12 @@ config ZEPHCORE_ROLE_ROOM_SERVER config ZEPHCORE_ROLE_KISS_MODEM bool "KISS modem (raw LoRa packets to a host over serial)" help - KISS TNC on the USB/console serial port, as upstream MeshCore's - kiss_modem: the host sends and receives raw packets, configures - the radio and uses the node identity through the SetHardware - extensions (docs/kiss_modem_protocol.md). No mesh, no BLE, no CLI. + KISS TNC on a serial port, as upstream MeshCore's kiss_modem: the + host sends and receives raw packets, configures the radio and uses + the node identity through the SetHardware extensions + (docs/kiss_modem_protocol.md). No mesh, no BLE, no CLI. The port is + the chosen `zephcore,kiss-uart` node, else USB CDC-ACM, else the + console. endchoice diff --git a/zephcore/boards/common/kiss_modem.conf b/zephcore/boards/common/kiss_modem.conf index 42fa507..9925106 100644 --- a/zephcore/boards/common/kiss_modem.conf +++ b/zephcore/boards/common/kiss_modem.conf @@ -1,7 +1,10 @@ -# KISS modem role: raw LoRa packets to a host over the USB/console serial port -# (upstream MeshCore examples/kiss_modem, protocol in docs/kiss_modem_protocol.md). +# KISS modem role: raw LoRa packets to a host over a serial port (upstream +# MeshCore examples/kiss_modem, protocol in docs/kiss_modem_protocol.md). # # Build with: -DEXTRA_CONF_FILE="boards/common/kiss_modem.conf" +# +# The host port is the chosen `zephcore,kiss-uart` node if the devicetree names +# one (upstream's KISS_UART_RX/TX), else the USB CDC-ACM port, else the console. CONFIG_ZEPHCORE_ROLE_KISS_MODEM=y @@ -10,6 +13,9 @@ CONFIG_BT=n CONFIG_ENTROPY_GENERATOR=y # The port carries the binary KISS stream: the console driver must not also -# write to it, or boot and printk bytes land in the stream. +# write to it, or boot and printk bytes land in the stream. With a +# `zephcore,kiss-uart` other than the console, a build may turn the console back +# on (-DCONFIG_UART_CONSOLE=y, plus debug.conf for logs); src/main_kiss_modem.cpp +# fails the build if the console is the KISS port. CONFIG_UART_CONSOLE=n CONFIG_SHELL=n diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 6b368cf..8882c95 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -152,6 +152,7 @@ Only `lora0` is required. Everything else enables its feature by being present. | `sensors-i2c.dtsi`, `rtc-i2c.dtsi` | I2C sensors (probed at runtime) and a battery-backed RTC (opt-in) | | `nordic,qspi-nor` + `qspi-ext.dtsi` | `/ext` on external flash for contacts, channels and blobs | | chosen `zephcore,companion-uart` | Companion protocol on a UART, for boards without Bluetooth | +| chosen `zephcore,kiss-uart` | KISS modem role on its own UART (for example header pins to an SBC) instead of USB or the console; baud rate from the node's `current-speed` | | `zephcore,poweroff-gpios` | Pins released at shutdown on boards with a latched supply rail | `board.overlay` has a worked example for each row. diff --git a/zephcore/boards/example_board/board.overlay b/zephcore/boards/example_board/board.overlay index 0c88048..fd1914d 100644 --- a/zephcore/boards/example_board/board.overlay +++ b/zephcore/boards/example_board/board.overlay @@ -84,6 +84,9 @@ * zephcore,gps-power regulator that powers the GPS (section 6) * zephcore,companion-uart companion protocol on a plain UART, for * boards without Bluetooth (lora_e5_mini) + * zephcore,kiss-uart KISS modem role on its own UART (header pins + * to a host SBC); the baud rate is the node's + * current-speed * ================================================================== */ / { aliases { diff --git a/zephcore/src/main_kiss_modem.cpp b/zephcore/src/main_kiss_modem.cpp index 968638d..0de1f19 100644 --- a/zephcore/src/main_kiss_modem.cpp +++ b/zephcore/src/main_kiss_modem.cpp @@ -3,9 +3,9 @@ * ZephCore - KISS modem * * Composition root of the KISS modem role (upstream examples/kiss_modem): raw - * LoRa packets to and from a host over the USB/console serial port, plus - * MeshCore's SetHardware extensions (KissModem). No mesh, no prefs: the host - * configures the radio, the identity is the server roles' one. + * LoRa packets to and from a host over a serial port, plus MeshCore's + * SetHardware extensions (KissModem). No mesh, no prefs: the host configures + * the radio, the identity is the server roles' one. * * Event loop: serial input, LoRa RX/TX-done, host output drained and a one-shot * for the soonest modem or radio-maintenance deadline each wake KissModem::loop(). @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -25,6 +26,24 @@ LOG_MODULE_REGISTER(zephcore_kiss_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #endif +/* The host port: the chosen `zephcore,kiss-uart`, else the CDC-ACM UART, else + * the console. */ +#if DT_HAS_CHOSEN(zephcore_kiss_uart) +#define KISS_HOST_NODE DT_CHOSEN(zephcore_kiss_uart) +#elif ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \ + !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) +#define KISS_HOST_NODE DT_COMPAT_GET_ANY_STATUS_OKAY(zephyr_cdc_acm_uart) +#else +#define KISS_HOST_NODE DT_CHOSEN(zephyr_console) +#endif + +/* The console driver writing to the host port would corrupt the KISS stream. */ +#if DT_HAS_CHOSEN(zephyr_console) +BUILD_ASSERT(!IS_ENABLED(CONFIG_UART_CONSOLE) || + !DT_SAME_NODE(KISS_HOST_NODE, DT_CHOSEN(zephyr_console)), + "CONFIG_UART_CONSOLE must be off while the console UART is the KISS port"); +#endif + #include #include #include @@ -34,6 +53,18 @@ LOG_MODULE_REGISTER(zephcore_kiss_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include "../adapters/datastore/ZephyrFsFormat.h" +#include "oled_power.h" +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) +#include "display.h" +#endif + +/* With a PWM LED, led0 is the same pin and belongs to the PWM driver. */ +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !IS_ENABLED(CONFIG_ZEPHCORE_LED_PWM) +static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios); +#endif +#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) +static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); +#endif #define MESH_EVENT_HOST_TX_IDLE BIT(MESH_EVENT_ROLE_BASE) /* host output drained */ #define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | \ @@ -136,12 +167,32 @@ int main(void) { board.clearBootloaderMagic(); + /* USB comes up even when the host is on another UART: the CDC port keeps + * the 1200-baud DFU touch and any console there. */ #if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \ !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) zephcore_usbd_init(); - const struct device *host_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart); +#endif + const struct device *host_dev = DEVICE_DT_GET(KISS_HOST_NODE); + + /* No UI in this role: LEDs off, the display blanked as a server leaves + * it after its auto-off (the panel otherwise keeps its boot state). */ +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !IS_ENABLED(CONFIG_ZEPHCORE_LED_PWM) + if (gpio_is_ready_dt(&led0)) { + gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE); + } +#endif +#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) + if (gpio_is_ready_dt(&led1)) { + gpio_pin_configure_dt(&led1, GPIO_OUTPUT_INACTIVE); + } +#endif +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) + if (mc_display_init() == 0) { + mc_display_off(); + } #else - const struct device *host_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console)); + oled_sleep(); #endif if (!data_store.hasRoleData()) {