From d02d7fa693b2f2c8804707ff573b57230de9bc33 Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Mon, 7 Sep 2026 20:57:06 -0500 Subject: [PATCH] feat: packet protocol for the state-report uplink/downlink (PR 9/10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Snips' own application-level payload format, defined fresh since Amidala has no existing schema for this controller type (tracked as thePunderWoman/Amidala#204) — this was split out of the original PR 8 once that grew large enough on its own (see PR 8, #43). - xbee_frame.h/.cpp gains Transmit Request (0x10) / Receive Packet (0x90) frame build/parse, symmetric to PR 8's AT Command support — generic XBee envelope logic, pure and round-trip tested. Defaults to addressing the coordinator (64-bit 0, 16-bit 0x0000 per Digi's "unknown 64-bit, route by 16-bit" convention) — flagged for real-hardware validation like the rest of this PR's protocol-level assumptions. - packet.h/.cpp: fixed-width, big-endian encode/decode for UplinkPacket (button mask, calibrated trigger/stick, battery %, charge state — 7 bytes) and DownlinkPacket (handedness + Left/Right slot label+value — 33 bytes). Pure, round-trip tested including truncation/padding edge cases on the string fields. - xbee_spi.cpp gains sendPacket()/pollForPacket(), thin wrappers using the new frame types; XbeeControl exposes both as passthroughs so it stays the single facade over the one physical XBee connection. - SnipsController.ino sends the uplink every 50ms (faster than the 1s human-readable Serial telemetry, which stays as a bring-up aid) and polls for downlink packets every tick. Nothing consumes handedness or the Left/Right label+value yet — that's the complications system, PR 10 — so for now a decoded downlink just gets logged to prove the round trip works. Co-Authored-By: Claude Sonnet 5 --- include/packet.h | 69 +++++++++++++ include/xbee_control.h | 12 +++ include/xbee_frame.h | 33 +++++++ include/xbee_spi.h | 14 +++ src/SnipsController.ino | 96 +++++++++++++----- src/packet.cpp | 84 ++++++++++++++++ src/xbee_frame.cpp | 44 +++++++++ src/xbee_spi.cpp | 39 ++++++++ test/test_packet/test_packet.cpp | 120 +++++++++++++++++++++++ test/test_xbee_frame/test_xbee_frame.cpp | 94 ++++++++++++++++++ 10 files changed, 582 insertions(+), 23 deletions(-) create mode 100644 include/packet.h create mode 100644 src/packet.cpp create mode 100644 test/test_packet/test_packet.cpp diff --git a/include/packet.h b/include/packet.h new file mode 100644 index 0000000..ca36f8d --- /dev/null +++ b/include/packet.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +#include "battery.h" + +// Pure encode/decode for Snips' own application-level payload — what goes +// inside an XBee Transmit Request (0x10) / Receive Packet (0x90), defined +// fresh since Amidala has no existing schema for this controller type +// (see thePunderWoman/Amidala#204). Knows nothing about XBee framing +// itself — see xbee_frame.h for that — or about what any of these values +// mean to the rest of the firmware or to Amidala. +// +// Field widths and the wire format (fixed-width, big-endian for +// multi-byte fields) are this rewrite's own choice, not dictated by any +// existing spec. + +// Controller -> Amidala, sent periodically. Raw per-button state (no +// gesture classification — see the rewrite plan's Context section) plus +// calibrated analog and battery/charge status. +struct UplinkPacket { + uint16_t buttonMask = 0; // bit i = Buttons::Index i is pressed + uint8_t triggerPercent = 0; // 0-100 + int8_t stickXPercent = 0; // -100..100 + int8_t stickYPercent = 0; // -100..100 + uint8_t batteryPercent = 0; // 0-100 + ChargeState chargeState = ChargeState::kDone; +}; + +// Amidala -> controller. Handedness is sent once at connect and is static +// for the session; the Left/Right slot label+value are generic — Amidala +// assigns their meaning (volume, throttle, or anything else) and echoes +// back whatever the current label/value should read. +struct DownlinkPacket { + enum class Handedness : uint8_t { kUnknown = 0, kLeft = 1, kRight = 2 }; + + static constexpr size_t kFieldLength = 8; + + Handedness handedness = Handedness::kUnknown; + char leftLabel[kFieldLength + 1] = {}; + char leftValue[kFieldLength + 1] = {}; + char rightLabel[kFieldLength + 1] = {}; + char rightValue[kFieldLength + 1] = {}; +}; + +namespace Packet { + +constexpr size_t kUplinkEncodedSize = 7; +constexpr size_t kDownlinkEncodedSize = + 1 + 4 * DownlinkPacket::kFieldLength; // 33 + +// Returns the number of bytes written (always kUplinkEncodedSize), or 0 +// if outCapacity is too small. +size_t encodeUplink(const UplinkPacket &packet, uint8_t *outBuf, + size_t outCapacity); + +// Returns false if length is too short to hold a full uplink packet. +bool decodeUplink(const uint8_t *buf, size_t length, UplinkPacket *out); + +// Returns the number of bytes written (always kDownlinkEncodedSize), or 0 +// if outCapacity is too small. +size_t encodeDownlink(const DownlinkPacket &packet, uint8_t *outBuf, + size_t outCapacity); + +// Returns false if length is too short to hold a full downlink packet. +bool decodeDownlink(const uint8_t *buf, size_t length, DownlinkPacket *out); + +} // namespace Packet diff --git a/include/xbee_control.h b/include/xbee_control.h index 3b6c3fa..a8fb808 100644 --- a/include/xbee_control.h +++ b/include/xbee_control.h @@ -26,6 +26,18 @@ class XbeeControl : public XbeeTransport { // query failure, leaving outHex untouched. bool querySerialLow(char *outHex, size_t outHexCapacity); + // Passthroughs to the owned XbeeSpi — see xbee_spi.h. XbeeControl is + // kept as the single facade over the one physical XBee connection + // rather than SnipsController.ino owning a second XbeeSpi instance. + void sendPacket(const uint8_t *payload, uint16_t payloadLength) { + spi_.sendPacket(payload, payloadLength); + } + bool pollForPacket(uint8_t *outPayload, uint16_t outPayloadCapacity, + uint16_t *outPayloadLength) { + return spi_.pollForPacket(outPayload, outPayloadCapacity, + outPayloadLength); + } + private: XbeeSpi spi_; }; diff --git a/include/xbee_frame.h b/include/xbee_frame.h index 1857124..64e1915 100644 --- a/include/xbee_frame.h +++ b/include/xbee_frame.h @@ -55,4 +55,37 @@ struct AtCommandResponse { bool parseAtCommandResponse(const uint8_t *frameData, uint16_t length, AtCommandResponse *out); +constexpr uint8_t kFrameTypeTransmitRequest = 0x10; +constexpr uint8_t kFrameTypeReceivePacket = 0x90; + +// The ZigBee coordinator's network address is always 0x0000; per Digi's +// convention, a 64-bit destination of all-zero paired with this means +// "route by 16-bit address, 64-bit unknown" — i.e. exactly "send to the +// coordinator" without needing to know its actual 64-bit address. Not yet +// validated against real hardware — flagged for this PR's bring-up. +constexpr uint64_t kCoordinatorAddress64 = 0; +constexpr uint16_t kCoordinatorAddress16 = 0x0000; + +// Builds a Transmit Request frame's data (type 0x10) into `outFrameData`, +// addressed to dest64/dest16 (default: the coordinator, see above). +// Returns the number of bytes written, or 0 if outCapacity is too small. +uint16_t buildTransmitRequestFrame(uint8_t *outFrameData, + uint16_t outCapacity, uint8_t frameId, + const uint8_t *payload, + uint16_t payloadLength, + uint64_t dest64 = kCoordinatorAddress64, + uint16_t dest16 = kCoordinatorAddress16); + +struct ReceivePacket { + uint64_t sourceAddress64; + const uint8_t *payload; // points into the buffer passed to parse() + uint16_t payloadLength; +}; + +// Parses a Receive Packet frame's data (type 0x90). `frameData` must stay +// valid as long as `out->payload` is used. Returns false if `frameData` +// isn't a recognized/well-formed Receive Packet. +bool parseReceivePacket(const uint8_t *frameData, uint16_t length, + ReceivePacket *out); + } // namespace XbeeFrame diff --git a/include/xbee_spi.h b/include/xbee_spi.h index 5187bfa..4a348f8 100644 --- a/include/xbee_spi.h +++ b/include/xbee_spi.h @@ -34,4 +34,18 @@ class XbeeSpi { uint8_t valueLength, uint8_t *outValue, uint8_t outValueCapacity, uint8_t *outValueLength, unsigned long timeoutMs = 200); + + // Sends our own application payload to the coordinator as a Transmit + // Request (0x10) — non-blocking, fire-and-forget (no response is + // expected or waited for; XBee's own ACK/retry handles reliability at + // the radio level). + void sendPacket(const uint8_t *payload, uint16_t payloadLength); + + // Non-blocking: drains any queued frames, returning the payload of the + // first Receive Packet (0x90) found (frames of any other type are + // silently discarded — this firmware only expects AT Command Responses, + // handled synchronously by sendAtCommand(), or Receive Packets here). + // Returns false if nothing was available this call. + bool pollForPacket(uint8_t *outPayload, uint16_t outPayloadCapacity, + uint16_t *outPayloadLength); }; diff --git a/src/SnipsController.ino b/src/SnipsController.ino index 8f1ae79..04c4e51 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -7,6 +7,7 @@ #include "droid_persistence.h" #include "menu.h" #include "oled.h" +#include "packet.h" #include "pin_assignment.h" #include "power_latch.h" #include "rgb_led.h" @@ -33,6 +34,8 @@ char deviceSerialLowBuf[9] = {}; // must outlive setup() — see its use below bool lastReportedPressed[Buttons::kCount] = {}; unsigned long lastTelemetryLogMs = 0; constexpr unsigned long kTelemetryLogIntervalMs = 1000; +unsigned long lastUplinkSendMs = 0; +constexpr unsigned long kUplinkSendIntervalMs = 50; MenuScreen previousMenuScreen = MenuScreen::kInactive; MainMenuItem previousMainMenuItem = MainMenuItem::kSwitchDroid; @@ -94,8 +97,7 @@ void setup() { // All buttons wire to GND with the internal pull-up enabled, so LOW = // pressed. No classification happens here — Amidala owns single/double/ // long-press and alt semantics centrally; this firmware only reports - // debounced raw press/release (packet protocol lands in a later PR, so - // for now state changes are just logged for bring-up). + // debounced raw press/release, via the uplink packet below. for (size_t i = 0; i < Buttons::kCount; ++i) { pinMode(Buttons::kPins[i], INPUT_PULLUP); } @@ -164,10 +166,10 @@ void loop() { if (powerOffDetector.update(powerButtonHeld, now)) { // The real graceful-shutdown sequence (notify Amidala, OLED message, - // then drive the latch pin low) lands in a later PR once the packet - // protocol and display exist. For now, just prove the hold is detected. + // then drive the latch pin low) lands in PR 10. For now, just prove + // the hold is detected. Serial.println( - "Power button held 3s - shutdown sequence would run here (PR 9)."); + "Power button held 3s - shutdown sequence would run here (PR 10)."); } // Read every tick (not just on the telemetry throttle below) — the menu @@ -262,27 +264,75 @@ void loop() { } } - // Packet protocol lands in PR 8 — for now, just log periodically (not - // every tick) so bring-up can confirm these readings look right. + const int rawVsys = analogRead(PinAssignment::kVsysSense); + const bool stat1High = digitalRead(PinAssignment::kChargeStat1) == HIGH; + const bool stat2High = digitalRead(PinAssignment::kChargeStat2) == HIGH; + + const int triggerPercent = + AnalogCalibration::calibrateTrigger(rawTrigger, calibrationData); + const int stickXPercent = AnalogCalibration::calibrateStickAxis( + rawStickX, calibrationData.stickXMin, calibrationData.stickXCenter, + calibrationData.stickXMax); + const int stickYPercent = AnalogCalibration::calibrateStickAxis( + rawStickY, calibrationData.stickYMin, calibrationData.stickYCenter, + calibrationData.stickYMax); + const int batteryPercent = batteryMonitor.percentFor(rawVsys); + const ChargeState chargeState = + batteryMonitor.chargeStateFor(stat1High, stat2High); + + // Uplink: sent periodically over the radio, faster than the + // human-readable Serial telemetry below — this is what Amidala + // actually sees. + if (now - lastUplinkSendMs >= kUplinkSendIntervalMs) { + lastUplinkSendMs = now; + + UplinkPacket uplink; + for (size_t i = 0; i < Buttons::kCount; ++i) { + if (buttonPanel.isPressed(i)) { + uplink.buttonMask |= static_cast(1u << i); + } + } + uplink.triggerPercent = static_cast(triggerPercent); + uplink.stickXPercent = static_cast(stickXPercent); + uplink.stickYPercent = static_cast(stickYPercent); + uplink.batteryPercent = static_cast(batteryPercent); + uplink.chargeState = chargeState; + + uint8_t uplinkBuf[Packet::kUplinkEncodedSize]; + const size_t uplinkLength = + Packet::encodeUplink(uplink, uplinkBuf, sizeof(uplinkBuf)); + if (uplinkLength > 0) { + xbeeControl.sendPacket(uplinkBuf, static_cast(uplinkLength)); + } + } + + // Downlink: non-blocking poll every tick. Nothing consumes handedness + // or the Left/Right label+value yet — that's the complications system, + // PR 10 — so for now this just proves the round trip works. + uint8_t downlinkBuf[Packet::kDownlinkEncodedSize]; + uint16_t downlinkLength = 0; + if (xbeeControl.pollForPacket(downlinkBuf, sizeof(downlinkBuf), + &downlinkLength)) { + DownlinkPacket downlink; + if (Packet::decodeDownlink(downlinkBuf, downlinkLength, &downlink)) { + Serial.print("Downlink: hand="); + Serial.print(static_cast(downlink.handedness)); + Serial.print(" L="); + Serial.print(downlink.leftLabel); + Serial.print(":"); + Serial.print(downlink.leftValue); + Serial.print(" R="); + Serial.print(downlink.rightLabel); + Serial.print(":"); + Serial.println(downlink.rightValue); + } + } + + // Human-readable Serial telemetry — not what Amidala sees, just a + // slower-cadence bring-up check that the values above look right. if (now - lastTelemetryLogMs >= kTelemetryLogIntervalMs) { lastTelemetryLogMs = now; - const int rawVsys = analogRead(PinAssignment::kVsysSense); - const bool stat1High = digitalRead(PinAssignment::kChargeStat1) == HIGH; - const bool stat2High = digitalRead(PinAssignment::kChargeStat2) == HIGH; - - const int triggerPercent = - AnalogCalibration::calibrateTrigger(rawTrigger, calibrationData); - const int stickXPercent = AnalogCalibration::calibrateStickAxis( - rawStickX, calibrationData.stickXMin, calibrationData.stickXCenter, - calibrationData.stickXMax); - const int stickYPercent = AnalogCalibration::calibrateStickAxis( - rawStickY, calibrationData.stickYMin, calibrationData.stickYCenter, - calibrationData.stickYMax); - const int batteryPercent = batteryMonitor.percentFor(rawVsys); - const ChargeState chargeState = - batteryMonitor.chargeStateFor(stat1High, stat2High); - Serial.print("Battery "); Serial.print(batteryPercent); Serial.print("% ("); diff --git a/src/packet.cpp b/src/packet.cpp new file mode 100644 index 0000000..b064c16 --- /dev/null +++ b/src/packet.cpp @@ -0,0 +1,84 @@ +#include "packet.h" + +namespace { + +// Copies a null-terminated string into a fixed-width field, truncating if +// too long and zero-padding if shorter (so the decode side's strings come +// back clean, terminated at the first zero either way). +void writeField(const char *text, uint8_t *outField, size_t fieldLength) { + size_t i = 0; + for (; i < fieldLength && text[i] != '\0'; i++) { + outField[i] = static_cast(text[i]); + } + for (; i < fieldLength; i++) { + outField[i] = 0; + } +} + +// Reads a fixed-width field back into a null-terminated buffer (which +// must be at least fieldLength + 1 bytes). +void readField(const uint8_t *field, size_t fieldLength, char *outText) { + for (size_t i = 0; i < fieldLength; i++) { + outText[i] = static_cast(field[i]); + } + outText[fieldLength] = '\0'; +} + +} // namespace + +size_t Packet::encodeUplink(const UplinkPacket &packet, uint8_t *outBuf, + size_t outCapacity) { + if (outCapacity < kUplinkEncodedSize) { + return 0; + } + outBuf[0] = static_cast(packet.buttonMask >> 8); + outBuf[1] = static_cast(packet.buttonMask & 0xFF); + outBuf[2] = packet.triggerPercent; + outBuf[3] = static_cast(packet.stickXPercent); + outBuf[4] = static_cast(packet.stickYPercent); + outBuf[5] = packet.batteryPercent; + outBuf[6] = static_cast(packet.chargeState); + return kUplinkEncodedSize; +} + +bool Packet::decodeUplink(const uint8_t *buf, size_t length, + UplinkPacket *out) { + if (length < kUplinkEncodedSize) { + return false; + } + out->buttonMask = (static_cast(buf[0]) << 8) | buf[1]; + out->triggerPercent = buf[2]; + out->stickXPercent = static_cast(buf[3]); + out->stickYPercent = static_cast(buf[4]); + out->batteryPercent = buf[5]; + out->chargeState = static_cast(buf[6]); + return true; +} + +size_t Packet::encodeDownlink(const DownlinkPacket &packet, uint8_t *outBuf, + size_t outCapacity) { + if (outCapacity < kDownlinkEncodedSize) { + return 0; + } + constexpr size_t kF = DownlinkPacket::kFieldLength; + outBuf[0] = static_cast(packet.handedness); + writeField(packet.leftLabel, outBuf + 1, kF); + writeField(packet.leftValue, outBuf + 1 + kF, kF); + writeField(packet.rightLabel, outBuf + 1 + 2 * kF, kF); + writeField(packet.rightValue, outBuf + 1 + 3 * kF, kF); + return kDownlinkEncodedSize; +} + +bool Packet::decodeDownlink(const uint8_t *buf, size_t length, + DownlinkPacket *out) { + if (length < kDownlinkEncodedSize) { + return false; + } + constexpr size_t kF = DownlinkPacket::kFieldLength; + out->handedness = static_cast(buf[0]); + readField(buf + 1, kF, out->leftLabel); + readField(buf + 1 + kF, kF, out->leftValue); + readField(buf + 1 + 2 * kF, kF, out->rightLabel); + readField(buf + 1 + 3 * kF, kF, out->rightValue); + return true; +} diff --git a/src/xbee_frame.cpp b/src/xbee_frame.cpp index b4a84e1..7871db3 100644 --- a/src/xbee_frame.cpp +++ b/src/xbee_frame.cpp @@ -47,3 +47,47 @@ bool XbeeFrame::parseAtCommandResponse(const uint8_t *frameData, out->value = out->valueLength > 0 ? frameData + kMinLength : nullptr; return true; } + +uint16_t XbeeFrame::buildTransmitRequestFrame(uint8_t *outFrameData, + uint16_t outCapacity, + uint8_t frameId, + const uint8_t *payload, + uint16_t payloadLength, + uint64_t dest64, + uint16_t dest16) { + constexpr uint16_t kHeaderLength = 14; + const uint16_t total = kHeaderLength + payloadLength; + if (total > outCapacity) { + return 0; + } + outFrameData[0] = kFrameTypeTransmitRequest; + outFrameData[1] = frameId; + for (int i = 0; i < 8; i++) { + outFrameData[2 + i] = + static_cast(dest64 >> (8 * (7 - i))); + } + outFrameData[10] = static_cast(dest16 >> 8); + outFrameData[11] = static_cast(dest16 & 0xFF); + outFrameData[12] = 0; // broadcast radius: 0 = max hops + outFrameData[13] = 0; // options: default + for (uint16_t i = 0; i < payloadLength; i++) { + outFrameData[kHeaderLength + i] = payload[i]; + } + return total; +} + +bool XbeeFrame::parseReceivePacket(const uint8_t *frameData, uint16_t length, + ReceivePacket *out) { + constexpr uint16_t kHeaderLength = 12; // type + src64(8) + src16(2) + options + if (length < kHeaderLength || frameData[0] != kFrameTypeReceivePacket) { + return false; + } + uint64_t source64 = 0; + for (int i = 0; i < 8; i++) { + source64 = (source64 << 8) | frameData[1 + i]; + } + out->sourceAddress64 = source64; + out->payloadLength = static_cast(length - kHeaderLength); + out->payload = out->payloadLength > 0 ? frameData + kHeaderLength : nullptr; + return true; +} diff --git a/src/xbee_spi.cpp b/src/xbee_spi.cpp index cc38947..3ab1bd2 100644 --- a/src/xbee_spi.cpp +++ b/src/xbee_spi.cpp @@ -140,3 +140,42 @@ bool XbeeSpi::sendAtCommand(const char *atCmd, const uint8_t *value, } return false; // timed out } + +void XbeeSpi::sendPacket(const uint8_t *payload, uint16_t payloadLength) { + constexpr uint8_t kFrameId = 0x02; // distinct from sendAtCommand's 0x01 + uint8_t frame[32]; + const uint16_t length = XbeeFrame::buildTransmitRequestFrame( + frame, sizeof(frame), kFrameId, payload, payloadLength); + if (length == 0) { + return; // payload too large for the frame buffer — drop it + } + writeFrame(frame, length); +} + +bool XbeeSpi::pollForPacket(uint8_t *outPayload, uint16_t outPayloadCapacity, + uint16_t *outPayloadLength) { + if (!frameAvailable()) { + return false; + } + + uint8_t frame[64]; + const int32_t length = readFrame(frame, sizeof(frame)); + if (length <= 0) { + return false; // no delimiter yet, or a bad frame was drained + } + + XbeeFrame::ReceivePacket packet; + if (!XbeeFrame::parseReceivePacket(frame, static_cast(length), + &packet)) { + return false; // some other frame type — not what we're looking for + } + + const uint16_t copyLength = packet.payloadLength < outPayloadCapacity + ? packet.payloadLength + : outPayloadCapacity; + for (uint16_t i = 0; i < copyLength; i++) { + outPayload[i] = packet.payload[i]; + } + *outPayloadLength = copyLength; + return true; +} diff --git a/test/test_packet/test_packet.cpp b/test/test_packet/test_packet.cpp new file mode 100644 index 0000000..472d7e5 --- /dev/null +++ b/test/test_packet/test_packet.cpp @@ -0,0 +1,120 @@ +#include +#include + +#include "packet.h" + +void setUp(void) {} +void tearDown(void) {} + +// ---- uplink ---------------------------------------------------------- + +void test_uplink_round_trip() { + UplinkPacket packet; + packet.buttonMask = 0xABCD; + packet.triggerPercent = 42; + packet.stickXPercent = -73; + packet.stickYPercent = 100; + packet.batteryPercent = 88; + packet.chargeState = ChargeState::kCharging; + + uint8_t buf[Packet::kUplinkEncodedSize]; + TEST_ASSERT_EQUAL_UINT(Packet::kUplinkEncodedSize, + Packet::encodeUplink(packet, buf, sizeof(buf))); + + UplinkPacket decoded; + TEST_ASSERT_TRUE(Packet::decodeUplink(buf, sizeof(buf), &decoded)); + TEST_ASSERT_EQUAL_UINT16(0xABCD, decoded.buttonMask); + TEST_ASSERT_EQUAL_UINT8(42, decoded.triggerPercent); + TEST_ASSERT_EQUAL_INT8(-73, decoded.stickXPercent); + TEST_ASSERT_EQUAL_INT8(100, decoded.stickYPercent); + TEST_ASSERT_EQUAL_UINT8(88, decoded.batteryPercent); + TEST_ASSERT_TRUE(ChargeState::kCharging == decoded.chargeState); +} + +void test_uplink_encode_fails_when_buffer_too_small() { + UplinkPacket packet; + uint8_t buf[3]; + TEST_ASSERT_EQUAL_UINT(0, Packet::encodeUplink(packet, buf, sizeof(buf))); +} + +void test_uplink_decode_fails_when_length_too_short() { + uint8_t buf[3] = {}; + UplinkPacket decoded; + TEST_ASSERT_FALSE(Packet::decodeUplink(buf, sizeof(buf), &decoded)); +} + +// ---- downlink ---------------------------------------------------------- + +void test_downlink_round_trip() { + DownlinkPacket packet; + packet.handedness = DownlinkPacket::Handedness::kLeft; + std::strncpy(packet.leftLabel, "Volume", sizeof(packet.leftLabel)); + std::strncpy(packet.leftValue, "42%", sizeof(packet.leftValue)); + std::strncpy(packet.rightLabel, "Throttle", sizeof(packet.rightLabel)); + std::strncpy(packet.rightValue, "88%", sizeof(packet.rightValue)); + + uint8_t buf[Packet::kDownlinkEncodedSize]; + TEST_ASSERT_EQUAL_UINT(Packet::kDownlinkEncodedSize, + Packet::encodeDownlink(packet, buf, sizeof(buf))); + + DownlinkPacket decoded; + TEST_ASSERT_TRUE(Packet::decodeDownlink(buf, sizeof(buf), &decoded)); + TEST_ASSERT_TRUE(DownlinkPacket::Handedness::kLeft == decoded.handedness); + TEST_ASSERT_EQUAL_STRING("Volume", decoded.leftLabel); + TEST_ASSERT_EQUAL_STRING("42%", decoded.leftValue); + TEST_ASSERT_EQUAL_STRING("Throttle", decoded.rightLabel); + TEST_ASSERT_EQUAL_STRING("88%", decoded.rightValue); +} + +void test_downlink_truncates_overly_long_field() { + DownlinkPacket packet; + std::strncpy(packet.leftLabel, "WayTooLongForEightChars", + sizeof(packet.leftLabel)); + packet.leftLabel[sizeof(packet.leftLabel) - 1] = '\0'; + + uint8_t buf[Packet::kDownlinkEncodedSize]; + Packet::encodeDownlink(packet, buf, sizeof(buf)); + + DownlinkPacket decoded; + Packet::decodeDownlink(buf, sizeof(buf), &decoded); + TEST_ASSERT_EQUAL_UINT(DownlinkPacket::kFieldLength, + std::strlen(decoded.leftLabel)); + TEST_ASSERT_EQUAL_STRING("WayTooLo", decoded.leftLabel); +} + +void test_downlink_short_field_is_clean_not_garbage() { + DownlinkPacket packet; + std::strncpy(packet.leftLabel, "Hi", sizeof(packet.leftLabel)); + + uint8_t buf[Packet::kDownlinkEncodedSize]; + Packet::encodeDownlink(packet, buf, sizeof(buf)); + + DownlinkPacket decoded; + Packet::decodeDownlink(buf, sizeof(buf), &decoded); + TEST_ASSERT_EQUAL_STRING("Hi", decoded.leftLabel); +} + +void test_downlink_encode_fails_when_buffer_too_small() { + DownlinkPacket packet; + uint8_t buf[5]; + TEST_ASSERT_EQUAL_UINT(0, Packet::encodeDownlink(packet, buf, sizeof(buf))); +} + +void test_downlink_decode_fails_when_length_too_short() { + uint8_t buf[5] = {}; + DownlinkPacket decoded; + TEST_ASSERT_FALSE(Packet::decodeDownlink(buf, sizeof(buf), &decoded)); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_uplink_round_trip); + RUN_TEST(test_uplink_encode_fails_when_buffer_too_small); + RUN_TEST(test_uplink_decode_fails_when_length_too_short); + RUN_TEST(test_downlink_round_trip); + RUN_TEST(test_downlink_truncates_overly_long_field); + RUN_TEST(test_downlink_short_field_is_clean_not_garbage); + RUN_TEST(test_downlink_encode_fails_when_buffer_too_small); + RUN_TEST(test_downlink_decode_fails_when_length_too_short); + return UNITY_END(); +} diff --git a/test/test_xbee_frame/test_xbee_frame.cpp b/test/test_xbee_frame/test_xbee_frame.cpp index a5f317b..4da4afe 100644 --- a/test/test_xbee_frame/test_xbee_frame.cpp +++ b/test/test_xbee_frame/test_xbee_frame.cpp @@ -102,6 +102,93 @@ void test_parse_at_command_response_rejects_wrong_frame_type() { XbeeFrame::parseAtCommandResponse(frame, sizeof(frame), &response)); } +// ---- buildTransmitRequestFrame ------------------------------------------- + +void test_build_transmit_request_frame_defaults_to_coordinator() { + uint8_t buf[32]; + const uint8_t payload[] = {0xAA, 0xBB}; + const uint16_t length = XbeeFrame::buildTransmitRequestFrame( + buf, sizeof(buf), 0x01, payload, sizeof(payload)); + TEST_ASSERT_EQUAL_UINT16(16, length); + TEST_ASSERT_EQUAL_UINT8(XbeeFrame::kFrameTypeTransmitRequest, buf[0]); + TEST_ASSERT_EQUAL_UINT8(0x01, buf[1]); + for (int i = 2; i < 10; i++) { + TEST_ASSERT_EQUAL_UINT8(0, buf[i]); // dest64 == kCoordinatorAddress64 + } + TEST_ASSERT_EQUAL_UINT8(0, buf[10]); // dest16 hi + TEST_ASSERT_EQUAL_UINT8(0, buf[11]); // dest16 lo + TEST_ASSERT_EQUAL_UINT8(0xAA, buf[14]); + TEST_ASSERT_EQUAL_UINT8(0xBB, buf[15]); +} + +void test_build_transmit_request_frame_with_explicit_destination() { + uint8_t buf[32]; + const uint8_t payload[] = {0x01}; + const uint16_t length = XbeeFrame::buildTransmitRequestFrame( + buf, sizeof(buf), 0x02, payload, sizeof(payload), + 0x0013A20041A7B3C2ULL, 0x1234); + TEST_ASSERT_EQUAL_UINT16(15, length); + TEST_ASSERT_EQUAL_UINT8(0x00, buf[2]); + TEST_ASSERT_EQUAL_UINT8(0x13, buf[3]); + TEST_ASSERT_EQUAL_UINT8(0xA2, buf[4]); + TEST_ASSERT_EQUAL_UINT8(0x00, buf[5]); + TEST_ASSERT_EQUAL_UINT8(0x41, buf[6]); + TEST_ASSERT_EQUAL_UINT8(0xA7, buf[7]); + TEST_ASSERT_EQUAL_UINT8(0xB3, buf[8]); + TEST_ASSERT_EQUAL_UINT8(0xC2, buf[9]); + TEST_ASSERT_EQUAL_UINT8(0x12, buf[10]); + TEST_ASSERT_EQUAL_UINT8(0x34, buf[11]); +} + +void test_build_transmit_request_frame_returns_zero_when_buffer_too_small() { + uint8_t buf[13]; // needs at least 14 for an empty payload + const uint16_t length = + XbeeFrame::buildTransmitRequestFrame(buf, sizeof(buf), 0x01, nullptr, 0); + TEST_ASSERT_EQUAL_UINT16(0, length); +} + +// ---- parseReceivePacket --------------------------------------------------- + +void test_parse_receive_packet_extracts_source_and_payload() { + const uint8_t frame[] = { + 0x90, // type + 0x00, 0x13, 0xA2, 0x00, 0x41, 0xA7, 0xB3, 0xC2, // source64 + 0x00, 0x00, // source16 + 0x01, // options + 0xDE, 0xAD, 0xBE, 0xEF, // payload + }; + XbeeFrame::ReceivePacket packet{}; + TEST_ASSERT_TRUE( + XbeeFrame::parseReceivePacket(frame, sizeof(frame), &packet)); + TEST_ASSERT_TRUE(0x0013A20041A7B3C2ULL == packet.sourceAddress64); + TEST_ASSERT_EQUAL_UINT16(4, packet.payloadLength); + TEST_ASSERT_EQUAL_UINT8(0xDE, packet.payload[0]); + TEST_ASSERT_EQUAL_UINT8(0xEF, packet.payload[3]); +} + +void test_parse_receive_packet_handles_empty_payload() { + const uint8_t frame[] = {0x90, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01}; + XbeeFrame::ReceivePacket packet{}; + TEST_ASSERT_TRUE( + XbeeFrame::parseReceivePacket(frame, sizeof(frame), &packet)); + TEST_ASSERT_EQUAL_UINT16(0, packet.payloadLength); + TEST_ASSERT_TRUE(packet.payload == nullptr); +} + +void test_parse_receive_packet_rejects_too_short_frame() { + const uint8_t frame[] = {0x90, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; // 11 bytes + XbeeFrame::ReceivePacket packet{}; + TEST_ASSERT_FALSE( + XbeeFrame::parseReceivePacket(frame, sizeof(frame), &packet)); +} + +void test_parse_receive_packet_rejects_wrong_frame_type() { + const uint8_t frame[] = {0x08, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + XbeeFrame::ReceivePacket packet{}; + TEST_ASSERT_FALSE( + XbeeFrame::parseReceivePacket(frame, sizeof(frame), &packet)); +} + int main(int argc, char **argv) { UNITY_BEGIN(); RUN_TEST(test_checksum_valid_round_trip); @@ -114,5 +201,12 @@ int main(int argc, char **argv) { RUN_TEST(test_parse_at_command_response_reports_error_status); RUN_TEST(test_parse_at_command_response_rejects_too_short_frame); RUN_TEST(test_parse_at_command_response_rejects_wrong_frame_type); + RUN_TEST(test_build_transmit_request_frame_defaults_to_coordinator); + RUN_TEST(test_build_transmit_request_frame_with_explicit_destination); + RUN_TEST(test_build_transmit_request_frame_returns_zero_when_buffer_too_small); + RUN_TEST(test_parse_receive_packet_extracts_source_and_payload); + RUN_TEST(test_parse_receive_packet_handles_empty_payload); + RUN_TEST(test_parse_receive_packet_rejects_too_short_frame); + RUN_TEST(test_parse_receive_packet_rejects_wrong_frame_type); return UNITY_END(); }