diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 9450f8990..a6663a11d 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -7,6 +7,7 @@ #include "memGet.h" #include "mesh/generated/meshtastic/mesh.pb.h" #include +#include #include #include #include @@ -20,6 +21,22 @@ #if HAS_NETWORKING extern meshtastic::Syslog syslog; #endif + +namespace +{ +std::atomic serialHalLogSuppressed{false}; +} + +void RedirectablePrint::setSerialHalLogSuppressed(bool suppressed) +{ + serialHalLogSuppressed.store(suppressed); +} + +bool RedirectablePrint::isSerialHalLogSuppressed() +{ + return serialHalLogSuppressed.load(); +} + void RedirectablePrint::rpInit() { #ifdef HAS_FREE_RTOS @@ -277,6 +294,10 @@ meshtastic_LogRecord_Level RedirectablePrint::getLogLevel(const char *logLevel) void RedirectablePrint::log(const char *logLevel, const char *format, ...) { + if (isSerialHalLogSuppressed()) { + return; + } + // append \n to format size_t len = strlen(format); auto newFormat = std::unique_ptr(new char[len + 2]); diff --git a/src/RedirectablePrint.h b/src/RedirectablePrint.h index 8535933fc..cd1a00658 100644 --- a/src/RedirectablePrint.h +++ b/src/RedirectablePrint.h @@ -24,6 +24,11 @@ class RedirectablePrint : public Print public: explicit RedirectablePrint(Print *_dest) : dest(_dest) {} + /// Suppress all log output while a SerialHal transaction is in progress. + // Unclear if this is necessary, but it seems to help with response speeds. + static void setSerialHalLogSuppressed(bool suppressed); + static bool isSerialHalLogSuppressed(); + /** * Set a new destination */ diff --git a/src/main.cpp b/src/main.cpp index 6f78c0b96..5cabeb087 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -951,8 +951,12 @@ void setup() #endif #endif - auto rIf = initLoRa(); - + std::unique_ptr rIf; + if (!config.lora.serial_hal_only) { + rIf = initLoRa(); + } else { + LOG_INFO("skipping LoRa radio init, for serialHal"); + } lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation) #if !MESHTASTIC_EXCLUDE_MQTT @@ -996,10 +1000,9 @@ void setup() // Start airtime logger thread. airTime = new AirTime(); - - if (!rIf) + if (!rIf && !config.lora.serial_hal_only) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO); - else { + else if (rIf) { // Log bit rate to debug output LOG_DEBUG("LoRA bitrate = %f bytes / sec", (float(meshtastic_Constants_DATA_PAYLOAD_LEN) / (float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) * diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index b7b672d8b..472aff75e 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -25,6 +25,7 @@ #ifdef ARCH_PORTDUINO #include "platform/portduino/PortduinoGlue.h" +#include "platform/portduino/SerialHal.h" #include "platform/portduino/SimRadio.h" #include "platform/portduino/USBHal.h" #endif @@ -292,6 +293,9 @@ std::unique_ptr initLoRa() portduino_config.lora_spi_dev.c_str()); if (portduino_config.lora_spi_dev == "ch341") { RadioLibHAL = ch341Hal; + } else if (portduino_config.lora_spi_dev == "serial") { + RadioLibHAL = new SerialHal(portduino_config.lora_serial_device, portduino_config.lora_serial_baud, + (uint32_t)portduino_config.lora_serial_timeout_ms); } else { if (RadioLibHAL != nullptr) { delete RadioLibHAL; diff --git a/src/mesh/SerialHalDevice.cpp b/src/mesh/SerialHalDevice.cpp new file mode 100644 index 000000000..6260941e4 --- /dev/null +++ b/src/mesh/SerialHalDevice.cpp @@ -0,0 +1,380 @@ +#include "mesh/SerialHalDevice.h" +#include "NodeDB.h" +#include "SPILock.h" +#include "concurrency/Periodic.h" +#include "configuration.h" +#include "mesh/StreamAPI.h" +#include "mesh/generated/meshtastic/config.pb.h" +#include +#include +#include +#include +#include + +#if defined(ARCH_ESP32) && defined(HW_SPI1_DEVICE) +extern SPIClass SPI1; +#endif + +namespace +{ +constexpr uint32_t SERIAL_PI_RISING = 1; +constexpr uint32_t SERIAL_PI_FALLING = 2; +constexpr uint32_t SERIAL_PI_INPUT = 0; +constexpr uint32_t SERIAL_PI_OUTPUT = 1; +constexpr size_t MAX_INTERRUPT_SLOTS = 8; +constexpr int32_t INTERRUPT_POLL_MS = 5; + +struct InterruptSlot { + bool used = false; + uint32_t pin = 0; + uint32_t mode = 0; + volatile bool pending = false; +}; + +std::mutex interruptMutex; +InterruptSlot interruptSlots[MAX_INTERRUPT_SLOTS]; +StreamAPI *interruptStreamApi = nullptr; +concurrency::Periodic *interruptEmitter = nullptr; + +int findSlotByPinLocked(uint32_t pin) +{ + for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) { + if (interruptSlots[i].used && interruptSlots[i].pin == pin) { + return (int)i; + } + } + return -1; +} + +int allocateSlotLocked() +{ + for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) { + if (!interruptSlots[i].used) { + return (int)i; + } + } + return -1; +} + +#ifdef ARCH_PORTDUINO +PinStatus toInterruptMode(uint32_t serialMode) +{ + if (serialMode == SERIAL_PI_RISING) { + return PinStatus::RISING; + } + if (serialMode == SERIAL_PI_FALLING) { + return PinStatus::FALLING; + } + return PinStatus::CHANGE; +} +#else +int toInterruptMode(uint32_t serialMode) +{ + if (serialMode == SERIAL_PI_RISING) { + return RISING; + } + if (serialMode == SERIAL_PI_FALLING) { + return FALLING; + } + return CHANGE; +} +#endif + +int32_t pumpInterruptEvents(); + +void ensureInterruptEmitter() +{ + if (!interruptEmitter) { + interruptEmitter = new concurrency::Periodic("SerialHalIrqEmitter", pumpInterruptEvents); + } +} + +void emitInterruptEvent(uint32_t pin, StreamAPI *streamApi) +{ + if (streamApi == nullptr) { + return; + } + + meshtastic_SerialHalResponse event = meshtastic_SerialHalResponse_init_zero; + event.transaction_id = 0; // asynchronous interrupt notification + event.result = meshtastic_SerialHalResponse_Result_OK; + event.value = pin; // host-side SerialHal treats value as interrupt pin + SerialHalDevice::emitResponse(event, streamApi); +} + +void markPendingBySlot(size_t slot) +{ + if (slot < MAX_INTERRUPT_SLOTS && interruptSlots[slot].used) { + interruptSlots[slot].pending = true; + } +} + +void isr0() +{ + markPendingBySlot(0); +} +void isr1() +{ + markPendingBySlot(1); +} +void isr2() +{ + markPendingBySlot(2); +} +void isr3() +{ + markPendingBySlot(3); +} +void isr4() +{ + markPendingBySlot(4); +} +void isr5() +{ + markPendingBySlot(5); +} +void isr6() +{ + markPendingBySlot(6); +} +void isr7() +{ + markPendingBySlot(7); +} + +void (*const isrTable[MAX_INTERRUPT_SLOTS])() = {isr0, isr1, isr2, isr3, isr4, isr5, isr6, isr7}; + +int32_t pumpInterruptEvents() +{ + uint32_t toEmit[MAX_INTERRUPT_SLOTS] = {0}; + size_t emitCount = 0; + StreamAPI *streamApi = nullptr; + + { + std::lock_guard lock(interruptMutex); + streamApi = interruptStreamApi; + for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) { + if (interruptSlots[i].used && interruptSlots[i].pending) { + interruptSlots[i].pending = false; + toEmit[emitCount++] = interruptSlots[i].pin; + } + } + } + + for (size_t i = 0; i < emitCount; ++i) { + emitInterruptEvent(toEmit[i], streamApi); + } + + return INTERRUPT_POLL_MS; +} +} // namespace + +// Helper to safely set response result +static inline void setResponseError(meshtastic_SerialHalResponse &response, meshtastic_SerialHalResponse_Result result, + const char *error = nullptr) +{ + response.result = result; + if (error != nullptr) { + snprintf(response.error, sizeof(response.error), "%s", error); + } +} + +void SerialHalDevice::handleCommand(const uint8_t *buf, size_t len, StreamAPI *streamApi) +{ + if (buf == nullptr || streamApi == nullptr) { + return; + } + + // Validate role - SerialHal commands only handled when config.lora.serial_hal_only + /*if (!config.lora.serial_hal_only) { + meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero; + response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED; + snprintf(response.error, sizeof(response.error), "SerialHal not enabled for this role"); + emitResponse(response, streamApi); + return; + }*/ + + // Decode the command + meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero; + if (!pb_decode_from_bytes(buf, len, &meshtastic_SerialHalCommand_msg, &cmd)) { + meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero; + response.result = meshtastic_SerialHalResponse_Result_BAD_REQUEST; + snprintf(response.error, sizeof(response.error), "Failed to decode SerialHalCommand"); + emitResponse(response, streamApi); + return; + } + + // Initialize response with matching transaction_id + meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero; + response.transaction_id = cmd.transaction_id; + response.result = meshtastic_SerialHalResponse_Result_OK; + + // Dispatch to operation handler + switch (cmd.type) { + case meshtastic_SerialHalCommand_Type_PIN_MODE: + handlePinMode(cmd, response); + break; + case meshtastic_SerialHalCommand_Type_DIGITAL_WRITE: + handleDigitalWrite(cmd, response); + break; + case meshtastic_SerialHalCommand_Type_DIGITAL_READ: + handleDigitalRead(cmd, response); + break; + case meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT: + handleAttachInterrupt(cmd, response); + break; + case meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT: + handleDetachInterrupt(cmd, response); + break; + case meshtastic_SerialHalCommand_Type_SPI_TRANSFER: + handleSpiTransfer(cmd, response); + break; + case meshtastic_SerialHalCommand_Type_NOOP: + // NOOP: just return OK + break; + default: + response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED; + snprintf(response.error, sizeof(response.error), "Unknown SerialHal operation type"); + break; + } + + emitResponse(response, streamApi); +} + +void SerialHalDevice::handlePinMode(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response) +{ + // LOG_DEBUG("SerialHalDevice: pinMode pin=%u mode=%u", cmd.pin, cmd.mode); + if (cmd.mode == SERIAL_PI_INPUT) { + pinMode((int)cmd.pin, INPUT); + } else if (cmd.mode == SERIAL_PI_OUTPUT) { + pinMode((int)cmd.pin, OUTPUT); + } else { + setResponseError(response, meshtastic_SerialHalResponse_Result_BAD_REQUEST, "Unsupported pin mode"); + } +} + +void SerialHalDevice::handleDigitalWrite(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response) +{ + // LOG_DEBUG("SerialHalDevice: digitalWrite pin=%u value=%u", cmd.pin, cmd.value); + digitalWrite((int)cmd.pin, cmd.value ? HIGH : LOW); +} + +void SerialHalDevice::handleDigitalRead(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response) +{ + // LOG_DEBUG("SerialHalDevice: digitalRead pin=%u", cmd.pin); + response.value = (uint32_t)digitalRead((int)cmd.pin); +} + +void SerialHalDevice::handleAttachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response) +{ + // LOG_DEBUG("SerialHalDevice: attachInterrupt pin=%u mode=%u", cmd.pin, cmd.mode); + + ensureInterruptEmitter(); + + int slot = -1; + { + std::lock_guard lock(interruptMutex); + slot = findSlotByPinLocked(cmd.pin); + if (slot < 0) { + slot = allocateSlotLocked(); + } + + if (slot >= 0) { + interruptSlots[slot].used = true; + interruptSlots[slot].pin = cmd.pin; + interruptSlots[slot].mode = cmd.mode; + interruptSlots[slot].pending = false; + } + } + + if (slot < 0) { + setResponseError(response, meshtastic_SerialHalResponse_Result_ERROR, "No interrupt slots available"); + return; + } + + ::attachInterrupt((int)cmd.pin, isrTable[slot], toInterruptMode(cmd.mode)); +} + +void SerialHalDevice::handleDetachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response) +{ + // LOG_DEBUG("SerialHalDevice: detachInterrupt pin=%u", cmd.pin); + + ::detachInterrupt((int)cmd.pin); + + { + std::lock_guard lock(interruptMutex); + const int slot = findSlotByPinLocked(cmd.pin); + if (slot >= 0) { + interruptSlots[slot] = InterruptSlot{}; + } + } +} + +void SerialHalDevice::handleSpiTransfer(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response) +{ + if (cmd.data.size == 0) { + return; + } + +#if defined(ARCH_ESP32) + if (spiLock == nullptr) { + setResponseError(response, meshtastic_SerialHalResponse_Result_ERROR, "SPI lock not initialized"); + return; + } + +#if defined(HW_SPI1_DEVICE) + SPIClass &spiBus = SPI1; +#else + SPIClass &spiBus = SPI; +#endif + + response.data.size = cmd.data.size; + + { + concurrency::LockGuard guard(spiLock); + spiBus.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0)); + spiBus.transferBytes(cmd.data.bytes, response.data.bytes, cmd.data.size); + spiBus.endTransaction(); + } +#else + // SPI wiring is board/radio-specific; keep this explicit for now. + response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED; + snprintf(response.error, sizeof(response.error), "SPI not supported on this platform"); +#endif +} + +void SerialHalDevice::emitResponse(const meshtastic_SerialHalResponse &response, StreamAPI *streamApi) +{ + if (streamApi == nullptr) { + return; + } + + // Encode the response + uint8_t encoded[meshtastic_SerialHalResponse_size] = {0}; + const size_t responseLen = + pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_SerialHalResponse_msg, static_cast(&response)); + + if (responseLen == 0 || responseLen > 0xFFFF) { + LOG_ERROR("SerialHalDevice: Failed to encode response (len=%zu)", responseLen); + return; + } + + // Build frame with StreamAPI framing: START1 SERIALHAL_MAGIC LEN_H LEN_L [payload] + constexpr uint8_t START1 = 0x94; + constexpr uint8_t SERIALHAL_MAGIC = 0xA5; + + uint8_t hdr[4]; + hdr[0] = START1; + hdr[1] = SERIALHAL_MAGIC; + hdr[2] = (uint8_t)((responseLen >> 8) & 0xFF); // LEN_H + hdr[3] = (uint8_t)(responseLen & 0xFF); // LEN_L + + // Emit via StreamAPI (this uses the internal txBuf + framing) + streamApi->emitSerialHalResponse(hdr, sizeof(hdr), encoded, responseLen); + + // Keep a recent stream instance so async interrupt events can be emitted. + { + std::lock_guard lock(interruptMutex); + interruptStreamApi = streamApi; + } +} diff --git a/src/mesh/SerialHalDevice.h b/src/mesh/SerialHalDevice.h new file mode 100644 index 000000000..6d95d8d21 --- /dev/null +++ b/src/mesh/SerialHalDevice.h @@ -0,0 +1,90 @@ +#pragma once + +#include "mesh/generated/meshtastic/serial_hal.pb.h" +#include + +/** + * @brief Device-side handler for SerialHal GPIO/SPI operations over StreamAPI framing. + * + * This module decodes SerialHalCommand protobufs received from a host and executes + * the requested GPIO (pinMode, digitalWrite, digitalRead, attach/detachInterrupt) or + * SPI operations, then returns results via SerialHalResponse. + * + * Usage: + * 1. Override StreamAPI::handleSerialHalCommand() in a subclass + * 2. Call SerialHalDevice::handleCommand(buf, len, streamApi) + * 3. SerialHalDevice will decode, execute, and emit the response + * + * The handler is only active when config.lora.serial_hal_only is true. + */ + +class StreamAPI; // forward declaration + +class SerialHalDevice +{ + public: + /** + * @brief Process a SerialHalCommand and emit a response. + * + * Decodes the protobuf, validates the operation, executes it on the device, + * and writes the response back via the StreamAPI instance. + * + * @param buf Pointer to the encoded SerialHalCommand protobuf payload (not including framing) + * @param len Length of the encoded payload + * @param streamApi Pointer to the StreamAPI instance (used for emitting responses) + */ + static void handleCommand(const uint8_t *buf, size_t len, StreamAPI *streamApi); + + /** + * @brief Emit a SerialHalResponse back to the host via StreamAPI framing. + * + * Encodes the response protobuf and sends it with proper framing (START1 SERIALHAL_MAGIC LEN_H LEN_L payload). + * + * @param response The response to send + * @param streamApi Pointer to the StreamAPI instance + */ + static void emitResponse(const meshtastic_SerialHalResponse &response, StreamAPI *streamApi); + + private: + /** + * @brief Execute a GPIO pinMode operation. + * @param cmd Decoded SerialHalCommand with PIN_MODE type + * @param response Response object to fill with result + */ + static void handlePinMode(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response); + + /** + * @brief Execute a GPIO digitalWrite operation. + * @param cmd Decoded SerialHalCommand with DIGITAL_WRITE type + * @param response Response object to fill with result + */ + static void handleDigitalWrite(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response); + + /** + * @brief Execute a GPIO digitalRead operation. + * @param cmd Decoded SerialHalCommand with DIGITAL_READ type + * @param response Response object to fill with result (value field contains read result) + */ + static void handleDigitalRead(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response); + + /** + * @brief Execute an attachInterrupt operation. + * @param cmd Decoded SerialHalCommand with ATTACH_INTERRUPT type + * @param response Response object to fill with result + */ + static void handleAttachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response); + + /** + * @brief Execute a detachInterrupt operation. + * @param cmd Decoded SerialHalCommand with DETACH_INTERRUPT type + * @param response Response object to fill with result + */ + static void handleDetachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response); + + /** + * @brief Execute an SPI transfer operation. + * @param cmd Decoded SerialHalCommand with SPI_TRANSFER type and data to send + * @param response Response object to fill with result (data field contains received bytes) + */ + static void handleSpiTransfer(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response); +}; diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index 2c3362877..2c2d1a410 100644 --- a/src/mesh/StreamAPI.cpp +++ b/src/mesh/StreamAPI.cpp @@ -1,12 +1,15 @@ #include "StreamAPI.h" #include "PowerFSM.h" #include "RTC.h" +#include "RedirectablePrint.h" +#include "SerialHalDevice.h" #include "Throttle.h" #include "concurrency/LockGuard.h" #include "configuration.h" #define START1 0x94 #define START2 0xc3 +#define SERIALHAL_MAGIC 0xa5 // second framing byte for SerialHal frames (START1 SH_MAGIC LEN_H LEN_L PAYLOAD) #define HEADER_LEN 4 int32_t StreamAPI::runOncePart() @@ -79,18 +82,29 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen) if (ptr == 0) { // looking for START1 if (c != START1) rxPtr = 0; // failed to find framing - } else if (ptr == 1) { // looking for START2 - if (c != START2) - rxPtr = 0; // failed to find framing + } else if (ptr == 1) { // discriminate frame type on second byte + if (c == START2) { + rxIsSerialHal = false; // standard ToRadio frame + serialHalRxActive.store(false); + RedirectablePrint::setSerialHalLogSuppressed(false); + } else if (c == SERIALHAL_MAGIC) { + rxIsSerialHal = true; // SerialHal command frame + serialHalRxActive.store(true); + RedirectablePrint::setSerialHalLogSuppressed(true); + } else { + rxPtr = 0; // unrecognised second byte — not our frame + serialHalRxActive.store(false); + RedirectablePrint::setSerialHalLogSuppressed(false); + } } else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing // console->printf("len %d\n", len); if (ptr == HEADER_LEN - 1) { - // we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid - // protobuf also) - if (len > MAX_TO_FROM_RADIO_SIZE) + // we _just_ finished our 4 byte header, validate length now + uint32_t maxLen = rxIsSerialHal ? (uint32_t)meshtastic_SerialHalCommand_size : MAX_TO_FROM_RADIO_SIZE; + if (len > maxLen) rxPtr = 0; // length is bogus, restart search for framing } @@ -98,8 +112,16 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen) if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload? rxPtr = 0; // start over again on the next packet - // If we didn't just fail the packet and we now have the right # of bytes, parse it - handleToRadio(rxBuf + HEADER_LEN, len); + // Dispatch based on which frame type we identified at byte 1 + if (rxIsSerialHal) + handleSerialHalCommand(rxBuf + HEADER_LEN, len); + else + handleToRadio(rxBuf + HEADER_LEN, len); + + if (rxIsSerialHal) + serialHalRxActive.store(false); + if (rxIsSerialHal) + RedirectablePrint::setSerialHalLogSuppressed(false); } } } @@ -114,7 +136,12 @@ int32_t StreamAPI::readStream() if (!stream->available()) { // Nothing available this time, if the computer has talked to us recently, poll often, otherwise let CPU sleep a long time bool recentRx = Throttle::isWithinTimespanMs(lastRxMsec, 2000); - return recentRx ? 5 : 250; + if (!recentRx) + return 250; // Sleep a long time if we haven't heard from the computer in a while + if (serialHalRxActive.load()) + return 0; // If we are in the middle of a SerialHal transaction, don't sleep at all because we want to be as + // responsive as possible to incoming SerialHal bytes + return 5; // Otherwise, poll frequently for new data } else { while (stream->available()) { // Currently we never want to block int cInt = stream->read(); @@ -135,18 +162,30 @@ int32_t StreamAPI::readStream() if (ptr == 0) { // looking for START1 if (c != START1) rxPtr = 0; // failed to find framing - } else if (ptr == 1) { // looking for START2 - if (c != START2) - rxPtr = 0; // failed to find framing + } else if (ptr == 1) { // discriminate frame type on second byte + if (c == START2) { + rxIsSerialHal = false; // standard ToRadio frame + serialHalRxActive.store(false); + RedirectablePrint::setSerialHalLogSuppressed(false); + } else if (c == SERIALHAL_MAGIC) { + rxIsSerialHal = true; // SerialHal command frame + serialHalRxActive.store(true); + RedirectablePrint::setSerialHalLogSuppressed(true); + LOG_WARN("StreamAPI: Detected SerialHal command frame"); + } else { + rxPtr = 0; // unrecognised second byte — not our frame + serialHalRxActive.store(false); + RedirectablePrint::setSerialHalLogSuppressed(false); + } } else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing // console->printf("len %d\n", len); if (ptr == HEADER_LEN - 1) { - // we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid - // protobuf also) - if (len > MAX_TO_FROM_RADIO_SIZE) + // we _just_ finished our 4 byte header, validate length now + uint32_t maxLen = rxIsSerialHal ? (uint32_t)meshtastic_SerialHalCommand_size : MAX_TO_FROM_RADIO_SIZE; + if (len > maxLen) rxPtr = 0; // length is bogus, restart search for framing } @@ -154,8 +193,16 @@ int32_t StreamAPI::readStream() if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload? rxPtr = 0; // start over again on the next packet - // If we didn't just fail the packet and we now have the right # of bytes, parse it - handleToRadio(rxBuf + HEADER_LEN, len); + // Dispatch based on which frame type we identified at byte 1 + if (rxIsSerialHal) + handleSerialHalCommand(rxBuf + HEADER_LEN, len); + else + handleToRadio(rxBuf + HEADER_LEN, len); + + if (rxIsSerialHal) + serialHalRxActive.store(false); + if (rxIsSerialHal) + RedirectablePrint::setSerialHalLogSuppressed(false); } } } @@ -199,6 +246,10 @@ void StreamAPI::emitRebooted() void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg) { + if (serialHalRxActive.load()) { + return; + } + // IMPORTANT: do NOT touch `fromRadioScratch` or `txBuf` here — those // belong to the main packet-emission path and a LOG_ firing during // `writeStream()` would corrupt an in-flight encode. We keep a @@ -249,4 +300,31 @@ void StreamAPI::onConnectionChanged(bool connected) // received a packet in a while powerFSM.trigger(EVENT_SERIAL_DISCONNECTED); } +} + +void StreamAPI::handleSerialHalCommand(const uint8_t *buf, size_t len) +{ + // Default implementation: dispatch to SerialHalDevice for GPIO/SPI handling + SerialHalDevice::handleCommand(buf, len, this); +} + +void StreamAPI::emitSerialHalResponse(const uint8_t *hdr, size_t hdrLen, const uint8_t *payload, size_t payloadLen) +{ + if (hdr == nullptr || hdrLen != 4 || payload == nullptr || payloadLen > meshtastic_SerialHalResponse_size) { + LOG_ERROR("StreamAPI: Invalid SerialHal response parameters"); + return; + } + + // Build complete frame in a temporary buffer + uint8_t frame[4 + meshtastic_SerialHalResponse_size]; + memcpy(frame, hdr, hdrLen); + memcpy(frame + hdrLen, payload, payloadLen); + + size_t totalLen = hdrLen + payloadLen; + + // Serialize stream writes against other emit operations via streamLock + concurrency::LockGuard guard(&streamLock); + stream->write(frame, totalLen); + stream->flush(); + LOG_WARN("StreamAPI: Emitted SerialHal response frame (len=%zu)", totalLen); } \ No newline at end of file diff --git a/src/mesh/StreamAPI.h b/src/mesh/StreamAPI.h index d3ad9ba79..a02196fb0 100644 --- a/src/mesh/StreamAPI.h +++ b/src/mesh/StreamAPI.h @@ -4,10 +4,15 @@ #include "Stream.h" #include "concurrency/Lock.h" #include "concurrency/OSThread.h" +#include "generated/meshtastic/serial_hal.pb.h" +#include #include -// A To/FromRadio packet + our 32 bit header -#define MAX_STREAM_BUF_SIZE (MAX_TO_FROM_RADIO_SIZE + sizeof(uint32_t)) +// Buffer sized for the larger of a full ToRadio/FromRadio payload or a full SerialHalCommand payload, plus header. +#define MAX_STREAM_PAYLOAD_SIZE \ + (MAX_TO_FROM_RADIO_SIZE > (int)meshtastic_SerialHalCommand_size ? MAX_TO_FROM_RADIO_SIZE \ + : (int)meshtastic_SerialHalCommand_size) +#define MAX_STREAM_BUF_SIZE (MAX_STREAM_PAYLOAD_SIZE + (int)sizeof(uint32_t)) /** * A version of our 'phone' API that talks over a Stream. So therefore well suited to use with serial links @@ -39,6 +44,8 @@ class StreamAPI : public PhoneAPI uint8_t rxBuf[MAX_STREAM_BUF_SIZE] = {0}; size_t rxPtr = 0; + bool rxIsSerialHal = false; ///< true when the current in-progress frame is a SerialHal frame (START1 SH_MAGIC ...) + std::atomic serialHalRxActive{false}; /// time of last rx, used, to slow down our polling if we haven't heard from anyone uint32_t lastRxMsec = 0; @@ -56,6 +63,17 @@ class StreamAPI : public PhoneAPI /// Check the current underlying physical link to see if the client is currently connected virtual bool checkIsConnected() override = 0; + /** + * Emit a SerialHal response frame with proper framing (START1 SERIALHAL_MAGIC LEN_H LEN_L payload). + * Called by SerialHalDevice to send responses back to the host. + * + * @param hdr 4-byte header (START1 SERIALHAL_MAGIC LEN_H LEN_L) + * @param hdrLen Length of header (should be 4) + * @param payload Encoded SerialHalResponse protobuf payload + * @param payloadLen Length of payload + */ + void emitSerialHalResponse(const uint8_t *hdr, size_t hdrLen, const uint8_t *payload, size_t payloadLen); + private: /** * Read any rx chars from the link and call handleToRadio @@ -75,6 +93,12 @@ class StreamAPI : public PhoneAPI */ void emitRebooted(); + /** + * Called when a complete SerialHal-framed packet has been received. + * Default implementation dispatches to SerialHalDevice for GPIO/SPI handling. + */ + virtual void handleSerialHalCommand(const uint8_t *buf, size_t len); + virtual void onConnectionChanged(bool connected) override; /** diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 0f5b10a07..7da4405ed 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -561,7 +561,8 @@ void portduinoSetup() for (const auto *i : portduino_config.all_pins) { // In the case of a ch341 Lora device, we don't want to touch the system GPIO lines for Lora // Those GPIO are handled in our usermode driver instead. - if (i->config_section == "Lora" && portduino_config.lora_spi_dev == "ch341") { + if (i->config_section == "Lora" && + (portduino_config.lora_spi_dev == "ch341" || portduino_config.lora_spi_dev == "serial")) { continue; } if (i->enabled) { @@ -580,7 +581,8 @@ void portduinoSetup() for (auto i : portduino_config.extra_pins) { // In the case of a ch341 Lora device, we don't want to touch the system GPIO lines for Lora // Those GPIO are handled in our usermode driver instead. - if (i.config_section == "Lora" && portduino_config.lora_spi_dev == "ch341") { + if (i.config_section == "Lora" && + (portduino_config.lora_spi_dev == "ch341" || portduino_config.lora_spi_dev == "serial")) { continue; } if (i.enabled) { @@ -627,7 +629,8 @@ void portduinoSetup() for (auto i : portduino_config.extra_pins) { // In the case of a ch341 Lora device, we don't want to touch the system GPIO lines for Lora // Those GPIO are handled in our usermode driver instead. - if (i.config_section == "Lora" && portduino_config.lora_spi_dev == "ch341") { + if (i.config_section == "Lora" && + (portduino_config.lora_spi_dev == "ch341" || portduino_config.lora_spi_dev == "serial")) { continue; } if (i.enabled && i.default_high) { @@ -637,7 +640,8 @@ void portduinoSetup() } // Only initialize the radio pins when dealing with real, kernel controlled SPI hardware - if (portduino_config.lora_spi_dev != "" && portduino_config.lora_spi_dev != "ch341") { + if (portduino_config.lora_spi_dev != "" && portduino_config.lora_spi_dev != "ch341" && + portduino_config.lora_spi_dev != "serial") { SPI.begin(portduino_config.lora_spi_dev.c_str()); } @@ -671,6 +675,7 @@ void portduinoSetup() } if (portduino_config.lora_spi_dev != "") { portduinoSetOptions({.realHardware = true}); + LOG_DEBUG("Running with real hardware SPI device %s", portduino_config.lora_spi_dev.c_str()); } return; } @@ -807,12 +812,15 @@ bool loadConfig(const char *configPath) } portduino_config.spiSpeed = yamlConfig["Lora"]["spiSpeed"].as(2000000); + portduino_config.lora_serial_device = yamlConfig["Lora"]["SerialDevice"].as(""); + portduino_config.lora_serial_baud = yamlConfig["Lora"]["SerialBaud"].as(115200); + portduino_config.lora_serial_timeout_ms = yamlConfig["Lora"]["SerialTimeoutMs"].as(500); portduino_config.lora_usb_serial_num = yamlConfig["Lora"]["USB_Serialnum"].as(""); portduino_config.lora_usb_pid = yamlConfig["Lora"]["USB_PID"].as(0x5512); portduino_config.lora_usb_vid = yamlConfig["Lora"]["USB_VID"].as(0x1A86); portduino_config.lora_spi_dev = yamlConfig["Lora"]["spidev"].as("spidev0.0"); - if (portduino_config.lora_spi_dev != "ch341") { + if (portduino_config.lora_spi_dev != "ch341" && portduino_config.lora_spi_dev != "serial") { portduino_config.lora_spi_dev = "/dev/" + portduino_config.lora_spi_dev; if (portduino_config.lora_spi_dev.length() == 14) { int x = portduino_config.lora_spi_dev.at(11) - '0'; diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index b38cfca25..fc3833aea 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -86,6 +86,9 @@ extern struct portduino_config_struct { bool has_device_id = false; uint8_t device_id[16] = {0}; std::string lora_spi_dev = ""; + std::string lora_serial_device = ""; + int lora_serial_baud = 115200; + int lora_serial_timeout_ms = 500; std::string lora_usb_serial_num = ""; int lora_spi_dev_int = 0; int lora_default_gpiochip = 0; @@ -276,6 +279,12 @@ extern struct portduino_config_struct { } if (lora_usb_serial_num != "") out << YAML::Key << "USB_Serialnum" << YAML::Value << lora_usb_serial_num; + if (lora_serial_device != "") + out << YAML::Key << "SerialDevice" << YAML::Value << lora_serial_device; + if (lora_serial_baud != 115200) + out << YAML::Key << "SerialBaud" << YAML::Value << lora_serial_baud; + if (lora_serial_timeout_ms != 500) + out << YAML::Key << "SerialTimeoutMs" << YAML::Value << lora_serial_timeout_ms; if (spiSpeed != 2000000) out << YAML::Key << "spiSpeed" << YAML::Value << spiSpeed; if (rfswitch_dio_pins[0] != RADIOLIB_NC) { diff --git a/src/platform/portduino/SerialHal.cpp b/src/platform/portduino/SerialHal.cpp new file mode 100644 index 000000000..75f6347c1 --- /dev/null +++ b/src/platform/portduino/SerialHal.cpp @@ -0,0 +1,595 @@ +#include "platform/portduino/SerialHal.h" + +#include "mesh/mesh-pb-constants.h" +#include "platform/portduino/PortduinoGlue.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr uint8_t START1 = 0x94; +constexpr uint8_t SERIALHAL_MAGIC = 0xA5; +constexpr size_t HEADER_SIZE = 4; // START1 + SERIALHAL_MAGIC + LEN_H + LEN_L +constexpr uint8_t START2 = 0xC3; // second byte of a normal FromRadio frame + +speed_t toTermiosBaud(uint32_t baud) +{ + switch (baud) { + case 9600: + return B9600; + case 19200: + return B19200; + case 38400: + return B38400; + case 57600: + return B57600; + case 115200: + return B115200; + case 230400: + return B230400; + case 460800: + return B460800; + case 921600: + return B921600; + default: + return B115200; + } +} +} // namespace + +SerialHal::SerialHal(const std::string &devicePath, uint32_t baudRate, uint32_t opTimeoutMs) + : RadioLibHal(SERIAL_PI_INPUT, SERIAL_PI_OUTPUT, SERIAL_PI_LOW, SERIAL_PI_HIGH, SERIAL_PI_RISING, SERIAL_PI_FALLING), + device(devicePath), baud(baudRate), timeoutMs(opTimeoutMs) +{ + if (!openPort()) { + setTransportError("unable to open serial device"); + } +} + +SerialHal::~SerialHal() +{ + closePort(); +} + +bool SerialHal::openPort() +{ + closePort(); + fd = ::open(device.c_str(), O_RDWR | O_NOCTTY | O_SYNC); + if (fd < 0) { + return false; + } + + termios tty = {}; + if (tcgetattr(fd, &tty) != 0) { + closePort(); + return false; + } + + cfsetospeed(&tty, toTermiosBaud(baud)); + cfsetispeed(&tty, toTermiosBaud(baud)); + + tty.c_cflag = (tty.c_cflag & ~CSIZE) | CS8; + tty.c_iflag &= ~(IGNBRK | IXON | IXOFF | IXANY); + tty.c_lflag = 0; + tty.c_oflag = 0; + tty.c_cc[VMIN] = 0; + tty.c_cc[VTIME] = 0; + tty.c_cflag |= (CLOCAL | CREAD); + tty.c_cflag &= ~(PARENB | PARODD); + tty.c_cflag &= ~CSTOPB; + tty.c_cflag &= ~CRTSCTS; + + if (tcsetattr(fd, TCSANOW, &tty) != 0) { + closePort(); + return false; + } + + tcflush(fd, TCIOFLUSH); + inError = false; + startReaderThread(); + return true; +} + +void SerialHal::closePort() +{ + stopReaderThread(); + if (fd >= 0) { + ::close(fd); + fd = -1; + } +} + +void SerialHal::setTransportError(const char *msg) +{ + if (!inError.load() || !hasWarned) { + LOG_ERROR("SerialHal: %s (%s)", msg, device.c_str()); + } + inError = true; + hasWarned = true; + portduino_status.LoRa_in_error = true; +} + +bool SerialHal::waitForReadable(int timeout) +{ + if (fd < 0) { + return false; + } + pollfd pfd = {}; + pfd.fd = fd; + pfd.events = POLLIN; + int ret = poll(&pfd, 1, timeout); + return ret > 0 && (pfd.revents & POLLIN); +} + +bool SerialHal::writeAll(const uint8_t *data, size_t len) +{ + size_t off = 0; + while (off < len) { + ssize_t rc = ::write(fd, data + off, len - off); + if (rc < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + off += (size_t)rc; + } + return true; +} + +bool SerialHal::readExact(uint8_t *data, size_t len) +{ + size_t off = 0; + auto start = std::chrono::steady_clock::now(); + while (off < len) { + auto now = std::chrono::steady_clock::now(); + int elapsed = (int)std::chrono::duration_cast(now - start).count(); + int remaining = (int)timeoutMs - elapsed; + if (remaining <= 0 || !waitForReadable(remaining)) { + return false; + } + ssize_t rc = ::read(fd, data + off, len - off); + if (rc < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + if (rc == 0) { + return false; + } + off += (size_t)rc; + } + return true; +} + +uint16_t SerialHal::crc16(const uint8_t *data, size_t len) const +{ + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < len; ++i) { + crc ^= ((uint16_t)data[i] << 8); + for (int bit = 0; bit < 8; ++bit) { + if (crc & 0x8000) { + crc = (uint16_t)((crc << 1) ^ 0x1021); + } else { + crc = (uint16_t)(crc << 1); + } + } + } + return crc; +} + +bool SerialHal::sendRequest(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse *response) +{ + if (fd < 0 && !openPort()) { + setTransportError("serial open failed"); + return false; + } + + uint8_t encoded[meshtastic_SerialHalCommand_size] = {0}; + const size_t payloadLen = + pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_SerialHalCommand_msg, static_cast(&cmd)); + if (payloadLen == 0 || payloadLen > 0xFFFF) { + setTransportError("serial command encode failed"); + return false; + } + + // Build frame with StreamAPI canonical framing: START1 SERIALHAL_MAGIC LEN_H LEN_L [payload] + std::vector frame; + frame.resize(HEADER_SIZE + payloadLen); + + frame[0] = START1; + frame[1] = SERIALHAL_MAGIC; + frame[2] = (uint8_t)((payloadLen >> 8) & 0xFF); // LEN_H (big-endian) + frame[3] = (uint8_t)(payloadLen & 0xFF); // LEN_L + memcpy(frame.data() + HEADER_SIZE, encoded, payloadLen); + + { + std::lock_guard writeGuard(writeMutex); + if (!writeAll(frame.data(), frame.size())) { + setTransportError("serial write failed"); + return false; + } + } + + meshtastic_SerialHalResponse got = meshtastic_SerialHalResponse_init_zero; + { + std::unique_lock lock(stateMutex); + const auto timeout = std::chrono::milliseconds(timeoutMs); + const bool arrived = responseCv.wait_for(lock, timeout, [&]() { return pendingResponses.count(cmd.transaction_id) > 0; }); + if (!arrived) { + setTransportError("serial response timeout"); + LOG_WARN("SerialHal: response timeout for transaction_id %u, cmd type %u", cmd.transaction_id, cmd.type); + return false; + } + + got = pendingResponses[cmd.transaction_id]; + pendingResponses.erase(cmd.transaction_id); + } + + if (got.result != meshtastic_SerialHalResponse_Result_OK) { + setTransportError("serial response reported error"); + LOG_WARN("SerialHal: response error: %s, %u, %u", got.error, cmd.type, cmd.data.size); + + return false; + } + + if (response != nullptr) { + *response = got; + } + + inError = false; + hasWarned = false; + return true; +} + +void SerialHal::pinMode(uint32_t pin, uint32_t mode) +{ + if (checkError() || pin == RADIOLIB_NC) { + return; + } + + meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero; + cmd.transaction_id = txId.fetch_add(1); + cmd.type = meshtastic_SerialHalCommand_Type_PIN_MODE; + cmd.pin = pin; + cmd.mode = mode; + + meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero; + sendRequest(cmd, &response); +} + +void SerialHal::digitalWrite(uint32_t pin, uint32_t value) +{ + if (checkError() || pin == RADIOLIB_NC) { + return; + } + + meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero; + cmd.transaction_id = txId.fetch_add(1); + cmd.type = meshtastic_SerialHalCommand_Type_DIGITAL_WRITE; + cmd.pin = pin; + cmd.value = value; + + meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero; + sendRequest(cmd, &response); +} + +uint32_t SerialHal::digitalRead(uint32_t pin) +{ + if (checkError() || pin == RADIOLIB_NC) { + return 0; + } + + meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero; + cmd.transaction_id = txId.fetch_add(1); + cmd.type = meshtastic_SerialHalCommand_Type_DIGITAL_READ; + cmd.pin = pin; + + meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero; + if (!sendRequest(cmd, &response)) { + return 0; + } + return response.value; +} + +void SerialHal::attachInterrupt(uint32_t interruptNum, void (*interruptCb)(void), uint32_t mode) +{ + if (checkError() || interruptNum == RADIOLIB_NC) { + return; + } + + { + std::lock_guard lock(stateMutex); + interruptCallbacks[interruptNum] = interruptCb; + } + + meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero; + cmd.transaction_id = txId.fetch_add(1); + cmd.type = meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT; + cmd.pin = interruptNum; + cmd.mode = mode; + + meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero; + sendRequest(cmd, &response); +} + +void SerialHal::detachInterrupt(uint32_t interruptNum) +{ + if (checkError() || interruptNum == RADIOLIB_NC) { + return; + } + + { + std::lock_guard lock(stateMutex); + interruptCallbacks.erase(interruptNum); + } + + meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero; + cmd.transaction_id = txId.fetch_add(1); + cmd.type = meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT; + cmd.pin = interruptNum; + + meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero; + sendRequest(cmd, &response); +} + +void SerialHal::delay(unsigned long ms) +{ + delayMicroseconds(ms * 1000); +} + +void SerialHal::delayMicroseconds(unsigned long us) +{ + if (us == 0) { + sched_yield(); + return; + } + usleep(us); +} + +void SerialHal::yield() +{ + sched_yield(); +} + +unsigned long SerialHal::millis() +{ + struct timeval tv; + gettimeofday(&tv, nullptr); + return (tv.tv_sec * 1000ULL) + (tv.tv_usec / 1000ULL); +} + +unsigned long SerialHal::micros() +{ + struct timeval tv; + gettimeofday(&tv, nullptr); + return (tv.tv_sec * 1000000ULL) + tv.tv_usec; +} + +long SerialHal::pulseIn(uint32_t pin, uint32_t state, unsigned long timeout) +{ + (void)pin; + (void)state; + (void)timeout; + LOG_WARN("SerialHal pulseIn is not supported"); + return 0; +} + +void SerialHal::spiTransfer(uint8_t *out, size_t len, uint8_t *in) +{ + if (checkError()) { + return; + } + + if (len == 0) { + return; + } + + meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero; + cmd.transaction_id = txId.fetch_add(1); + cmd.type = meshtastic_SerialHalCommand_Type_SPI_TRANSFER; + + const size_t maxTx = sizeof(cmd.data.bytes); + const size_t txLen = len < maxTx ? len : maxTx; + cmd.data.size = txLen; + if (out != nullptr) { + memcpy(cmd.data.bytes, out, txLen); + } else { + memset(cmd.data.bytes, 0, txLen); + } + + meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero; + if (!sendRequest(cmd, &response)) { + return; + } + + if (in != nullptr) { + size_t copyLen = response.data.size < len ? response.data.size : len; + memcpy(in, response.data.bytes, copyLen); + if (copyLen < len) { + memset(in + copyLen, 0, len - copyLen); + } + } +} + +bool SerialHal::checkError() +{ + if (inError.load()) { + if (!hasWarned) { + LOG_ERROR("SerialHal in_error detected"); + hasWarned = true; + } + portduino_status.LoRa_in_error = true; + return true; + } + hasWarned = false; + return false; +} + +bool SerialHal::readFrame(std::vector &payload, int firstByteTimeoutMs) +{ + payload.clear(); + + // Loop so that normal FromRadio frames (START1 START2 ...) emitted by the + // device on the same serial port are drained and discarded rather than + // causing the byte stream to desync. + for (;;) { + uint8_t hdr[HEADER_SIZE] = {0}; + for (;;) { + + ssize_t rc = ::read(fd, &hdr[0], 1); + if (rc < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + if (rc == 0) { + return false; + } + if (hdr[0] == START1) { + break; + } + } + + if (!readExact(hdr + 1, HEADER_SIZE - 1)) { + return false; + } + + const uint16_t len = ((uint16_t)hdr[2] << 8) | (uint16_t)hdr[3]; + + if (hdr[1] == SERIALHAL_MAGIC) { + // SerialHal response frame — this is what we want. + if (len > meshtastic_SerialHalResponse_size) { + return false; + } + payload.resize(len); + if (len > 0 && !readExact(payload.data(), len)) { + payload.clear(); + return false; + } + return true; + } else if (hdr[1] == START2) { + // Normal FromRadio frame emitted by the device — drain and discard + // its payload so we stay in sync, then loop to find a SerialHal frame. + if (len > 0) { + std::vector discard(len); + if (!readExact(discard.data(), len)) { + return false; + } + } + // continue looping, look for next frame + } else { + // Unknown second byte after START1 — restart search for framing. + continue; + } + } +} + +void SerialHal::readerLoop() +{ + readerRunning = true; + while (!readerStopRequested.load()) { + if (fd < 0) { + break; + } + + if (!waitForReadable(100)) { + continue; + } + + std::vector payload; + if (!readFrame(payload, 40)) { + continue; + } + + meshtastic_SerialHalResponse resp = meshtastic_SerialHalResponse_init_zero; + if (payload.empty() || !pb_decode_from_bytes(payload.data(), payload.size(), &meshtastic_SerialHalResponse_msg, &resp)) { + continue; + } + + if (resp.transaction_id == 0) { + LOG_WARN("SerialHal: received unsolicited interrupt event: pin=%u", resp.value); + // transaction_id 0 is reserved for unsolicited interrupt events. + // The device reports the triggered pin in resp.value instead of + // matching one of the synchronous request/response transactions. + { + std::lock_guard lock(stateMutex); + if (interruptCallbacks.count(resp.value) > 0) { + pendingInterruptPins.push_back(resp.value); + } + } + interruptCv.notify_one(); + continue; + } + + { + std::lock_guard lock(stateMutex); + pendingResponses[resp.transaction_id] = resp; + } + responseCv.notify_all(); + } + readerRunning = false; +} + +void SerialHal::interruptDispatchLoop() +{ + interruptDispatcherRunning = true; + while (!readerStopRequested.load()) { + uint32_t pin = 0; + void (*cb)(void) = nullptr; + + { + std::unique_lock lock(stateMutex); + interruptCv.wait(lock, [&]() { return readerStopRequested.load() || !pendingInterruptPins.empty(); }); + if (readerStopRequested.load()) { + break; + } + + pin = pendingInterruptPins.front(); + pendingInterruptPins.pop_front(); + + auto it = interruptCallbacks.find(pin); + if (it != interruptCallbacks.end()) { + cb = it->second; + } + } + + if (cb != nullptr) { + cb(); + } + } + interruptDispatcherRunning = false; +} + +void SerialHal::startReaderThread() +{ + stopReaderThread(); + readerStopRequested = false; + readerThread = std::thread(&SerialHal::readerLoop, this); + interruptThread = std::thread(&SerialHal::interruptDispatchLoop, this); +} + +void SerialHal::stopReaderThread() +{ + readerStopRequested = true; + interruptCv.notify_all(); + if (readerThread.joinable()) { + readerThread.join(); + } + if (interruptThread.joinable()) { + interruptThread.join(); + } + + std::lock_guard lock(stateMutex); + pendingInterruptPins.clear(); +} diff --git a/src/platform/portduino/SerialHal.h b/src/platform/portduino/SerialHal.h new file mode 100644 index 000000000..8a155293a --- /dev/null +++ b/src/platform/portduino/SerialHal.h @@ -0,0 +1,99 @@ +#ifndef PI_HAL_SERIAL_H +#define PI_HAL_SERIAL_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define SERIAL_PI_INPUT (0) +#define SERIAL_PI_OUTPUT (1) +#define SERIAL_PI_LOW (0) +#define SERIAL_PI_HIGH (1) +#define SERIAL_PI_RISING (1) +#define SERIAL_PI_FALLING (2) + +class SerialHal : public RadioLibHal +{ + public: + explicit SerialHal(const std::string &device, uint32_t baud = 115200, uint32_t timeoutMs = 500); + ~SerialHal() override; + + void init() override {} + void term() override {} + + void pinMode(uint32_t pin, uint32_t mode) override; + void digitalWrite(uint32_t pin, uint32_t value) override; + uint32_t digitalRead(uint32_t pin) override; + + void attachInterrupt(uint32_t interruptNum, void (*interruptCb)(void), uint32_t mode) override; + void detachInterrupt(uint32_t interruptNum) override; + + void delay(unsigned long ms) override; + void delayMicroseconds(unsigned long us) override; + void yield() override; + + unsigned long millis() override; + unsigned long micros() override; + + long pulseIn(uint32_t pin, uint32_t state, unsigned long timeout) override; + + void spiBegin() override {} + void spiBeginTransaction() override {} + void spiTransfer(uint8_t *out, size_t len, uint8_t *in) override; + void spiEndTransaction() override {} + void spiEnd() override {} + + bool checkError(); + + private: + bool openPort(); + void closePort(); + bool sendRequest(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse *response); + bool writeAll(const uint8_t *data, size_t len); + bool readExact(uint8_t *data, size_t len); + bool waitForReadable(int timeoutMs); + bool readFrame(std::vector &payload, int firstByteTimeoutMs); + void readerLoop(); + void interruptDispatchLoop(); + void startReaderThread(); + void stopReaderThread(); + + uint16_t crc16(const uint8_t *data, size_t len) const; + void setTransportError(const char *msg); + + std::string device; + uint32_t baud; + uint32_t timeoutMs; + int fd = -1; + bool hasWarned = false; + std::atomic inError{false}; + std::atomic txId{1}; + + std::mutex fdMutex; + std::mutex writeMutex; + std::mutex stateMutex; + std::condition_variable responseCv; + + std::thread readerThread; + std::thread interruptThread; + std::atomic readerStopRequested{false}; + std::atomic readerRunning{false}; + std::atomic interruptDispatcherRunning{false}; + + std::condition_variable interruptCv; + std::deque pendingInterruptPins; + + std::unordered_map interruptCallbacks; + std::unordered_map pendingResponses; +}; + +#endif