From 560cab1dced77c50bb1685d8e8f19f3a3d56e21a Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:34:47 +0100 Subject: [PATCH 01/34] Updated clang format --- .clang-format | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .clang-format diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..df1f495 --- /dev/null +++ b/.clang-format @@ -0,0 +1,16 @@ +--- +Language: Cpp +BasedOnStyle: WebKit + +IndentWidth: 4 + +PointerAlignment: Right # This is my preferred setting. Most code examples from books, etc use this too. +DerivePointerAlignment: false + +SortIncludes: false +IndentCaseLabels: true # Because I'm sane + +AlignTrailingComments: true +BreakBeforeBinaryOperators: NonAssignment # also because I'm sane + +BreakBeforeBraces: Attach From 5bc14b7bdffd3234225b9a6953971662bda7179d Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:35:08 +0100 Subject: [PATCH 02/34] Updated README with better instructions --- README.md | 153 +++++++++++++++++++++++++++++++++++++++++++++++- build.sh | 2 +- libs/libs.cmake | 13 ++++ platformio.ini | 15 +++-- 4 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 libs/libs.cmake diff --git a/README.md b/README.md index 6b7df05..20c4d51 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,153 @@ # Project HELIA: Flight Software -This repository contains the flight software for Project HELIA: source code for all MCUs, all units tests, all shared libraries, and doxygen for documentation generation + +This repository contains the flight software for Project HELIA: source code for all MCUs, all units tests, all shared libraries, and doxygen for documentation generation. + +## Code Organisation + +Since the repository has gotten pretty big, I'm going to make some notes on where everything is so other people can easily navigate around. +The computer system is 6 ESP32 S3s with a CAN Bus for central communication, so although this is a single codebase it actually feels more like 6 mini code bases in one. + +### `libs/` + +The `libs/` folder is for shared behaviour. Basically anything that all the ESP32s need to do is placed in here. +At the start this was mostly the `can` and `fsm` packages, and now slowly as I'm noticing code duplication I'm abstracting the behaviour and putting it in a shared library within `libs`. + +In the previous codebase, the shared behaviour folder got pretty big and messy, mostly because I started putting actual implementation in there rather than just abstract libraries that each ESP32 could use. +There should be a pretty hard line between what should be in the `src` folder and `libs` folder: + +- `src` is for implementation code, anything that will actually run on the ESP32s whether that is logic or hardware specific code +- `libs` is for abstract, shared libraries that code within `src` uses to improve the implementation and reduce code duplication across the codebase + +A general rule of thumb, if you're starting FreeRTOS tasks inside `libs` then you're going too far. + +Inside libs there are a number of small libraries that are dedicate to specific hardware or groups of functions/structs that are related in some way: + +- `can` handles everything CAN Bus related +- `fsm` handles everything finite state machine related (INIT -> SAFE -> ACTIVE -> SLEEP loop) +- `utilities` has a bunch of general utility functions, if you want to introduce a new library function without starting an entirely new library, then utilities is usually a safe bet + +### `src/` + +This is where the actual hardware specific logic and control logic lives. Usually this is split into `control` which is the primary task of the subsystem/esp32, usually data acquisition, some control flow, or whatever. `comms` is entirely just CAN communication, mostly the heartbeat, responding to commands, and more. These are pretty clearly shown with the `control.c/.h` and `comms.c/.h` convention used throughout the codebase. + +There is also `setup.c` this is the main entry point for all ESP32s, think of it as a `main.c` essentially. It contains all the set up code needed, including a `app_main` function and often also the main FreeRTOS tasks for communication and control (which use functions from the relevant headers). + +You will notice most of the control, set up, communication code is abract and doesn't feature much hardware interfacing. That's because all hardware specific logic lives within the [Hardware Abstraction Layer (HAL)](https://en.wikipedia.org/wiki/Hardware_abstraction), so ESP32-specific driver code is kept separate from other logic. This is done to make testing easier, as I can run tests locally or on a CI/CD without need ESP32s, but also keeps all the messy hardware specific logic in its own place which I find nicer to work with. + +There are 5 folders within `src/`: + +- `Master`: for OBDH/TTC; sd card logging, sending data over ethernet, main flight manager, etc +- `EPS`: for the Electric Power Supply (EPS) code; this is mostly just a current and voltage sensor, all the real power shit is buck converters on the main PCB (not a software problem yippeee!) +- `Instrumentation`: this is a bunch of housekeeping sensors, pretty chill, just DAQ +- `Mechanisms`: houses the iris and fluidics code, again pretty easy as it's just motoring +- `Photonics`: DAQ for photodiodes +- `thermals`: a bit more complex, will be a bang bang controller or pid loop controlling the temperature regulation of the experiment + +### `tests/` + +This is where all the test files live. Split into `libs/` for testing library functions and `src/` for testing source code functions. +I'm using `GoogleTest`, a C++ framework for writing the tests so that's why you might see: + +```c +#ifdef __cplusplus +extern "C" { +#endif + +// ... code or smth + +#ifdef __cplusplus +} +#endif +``` + +This is especially common in the `libs` code header code. + +### Config files + +We use CMake for building and platformio.ini for compiling each ESP32 easily. There's also script files like `build.sh` which are handy for quickly building everything. For that reason I recommend using a unix supported operating system such as MacOS, Linux, or WSL2 (if on Windows) as everything will run the same as on my computer. + +## Contributing + +If you're interested in contributing to the project, I'm going to write some rough guidelines and ideas for getting started. + +### How to start + +I'd recommend use MacOS, Linux, or Windows Subsystem for Linux (WSL2) for development. All scripts and configs have been testing on those and it should be easier to start rather than trying to get everything to work on Windows. + +You will need to install: `cmake`, `platfromio`, `googletest` (`gtest`), `git`, `doxygen`, and `python` (dependency of platformio). +You can run the `setup.sh` that should install all these packages and sets everything up for you. This is probably the best place to start. + +I'd recommend using the `platformio` extension and `vscode` for development if you're new, or any set up you're familiar with if you're more experienced. + +#### Git Flow + +I've used Git Flow methodology in the past, and although it's a ballache if you're managing a repository on your own, when other people start getting interested in contritbing that's usually when I start using it. +The idea is pretty simple: `main` branch for production ready code, `dev` branch for still in progress code, and individual contributors create branches off of `dev` to write and test code before merging into `dev`. Then merges into `main` are reviewed by myself or another collaborator on the project. + +If you're getting an error when pushing to `main` that the branch is protected and you can't push, that is intentional. You should stash your work, open a new branch from `dev`, commit, and pull request into `dev` rather than `main`. + +### Where to start + +It's hard to know exactly where to start, especially when you're dropped into the middle of a codebase written entirely by someone else. +I'd recommend checking out the issues tab, I will start populating it with work that needs being done. +Otherwise, read through the codebase and decide to add new functionality, add tests for something, or document stuff. + +#### Documentation + +The code should be fairly self documenting, I'd like to avoid code that is overly complex for no reason, but sometimes it is neccessary or just happens. To avoid this I've added Doxygen to the project (and will be setting up a doxygen web build soon). You will see these through out the codebase as comments, but for a brief summary: + +```c +/** + * @file some_library.h + * @brief Add a brief description of what's in the library + */ +#ifndef HELIA_SOME_LIBRARY_H +#define HELIA_SOME_LIBRARY_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Brief description of the point of this struct */ +typedef struct { + float value; /**< This value is used for.. Or is in .. format */ + bool ok; /**< True if the data is fresh/valid, otherwise disregard */ +} some_data; + +/** + * @brief Description of the function's behaviour, + * @param f What is this param and how is it relevant + * @param alpha etc etc + * + * @return this function is void, but this would discuss what the return is + */ +void some_init(some_data *f, float alpha); + +/** + * @brief Description of the function's behaviour, + * @param f What is this param and how is it relevant + * @param alpha etc etc + * + * @return blah blah blah + */ +float some_func(some_data *f, float sample); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_SOME_LIBRARY_H */ + +``` + +The example above should give you an idea of how most of the code is documented now. +Anything else can be found in the [doxygen documentation](https://www.doxygen.nl/manual/docblocks.html) (just skim the code examples tbh). + +### Final notes + +This repository is for Project HELIA, a BEXUS flight experiment launching in October (2026). After the launch campaign I may not respond as quickly to enquiries or pull requests. You should be able to ping me using @hrszpuk in your issues/pull requests. +However, even then this codebase won't be a top priority for me, as I'll have moved onto other projects. + +Happy coding! diff --git a/build.sh b/build.sh index 432ee01..d52f672 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Build + test everything, flash every board, then monitor Master. -# Each board's port is set in platformio.ini, so each firmware goes to the right board. +# Each board's port is set in platformio.ini, so each firmware goes to the right board set -euo pipefail cd "$(dirname "$0")" export PATH="$HOME/.local/bin:$HOME/.platformio/penv/bin:$PATH" diff --git a/libs/libs.cmake b/libs/libs.cmake new file mode 100644 index 0000000..8607507 --- /dev/null +++ b/libs/libs.cmake @@ -0,0 +1,13 @@ +set(HELIA_LIB_DIR ${CMAKE_CURRENT_LIST_DIR}) + +# Pure helpers: built everywhere +file(GLOB HELIA_LIB_SRCS CONFIGURE_DEPENDS ${HELIA_LIB_DIR}/*/*.c) + +# Platform-specific helpers: libs//esp32/*.c or libs//sim/*.c +file(GLOB HELIA_LIB_ESP32_SRCS CONFIGURE_DEPENDS ${HELIA_LIB_DIR}/*/esp32/*.c) +file(GLOB HELIA_LIB_SIM_SRCS CONFIGURE_DEPENDS ${HELIA_LIB_DIR}/*/sim/*.c) + +set(HELIA_LIB_INCS + ${HELIA_LIB_DIR}/fsm + ${HELIA_LIB_DIR}/can + ${HELIA_LIB_DIR}/utilities) // Add more here as they're implemented :> \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index da04823..2910044 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,16 +1,14 @@ -; HELIA flight software - one environment per node. +; HELIA flight software set up ; ; pio run -e eps -t upload -t monitor ; -; Every env pins its upload/monitor port so firmware can't land on the wrong -; board. macOS names ports by USB socket position, so after moving cables run -; `pio device list` and match each board by its SER= value. +; remember to change usb ports each time you plugin in - remy [platformio] default_envs = eps [env] -platform = espressif32@6.9.0 ; ships ESP-IDF 5.3.1 +platform = espressif32@6.9.0 framework = espidf board = esp32-s3-devkitc-1 board_upload.flash_size = 4MB @@ -21,12 +19,12 @@ monitor_speed = 115200 board_build.cmake_extra_args = -DHELIA_NODE=Master board_upload.flash_size = 2MB board_build.flash_size = 2MB -upload_port = /dev/cu.usbmodem101 ; TODO: confirm with `pio device list` +upload_port = /dev/cu.usbmodem101 monitor_port = /dev/cu.usbmodem101 [env:eps] board_build.cmake_extra_args = -DHELIA_NODE=EPS -upload_port = /dev/cu.usbmodem1301 ; the board you verified as EPS +upload_port = /dev/cu.usbmodem1301 monitor_port = /dev/cu.usbmodem1301 [env:instrumentation] @@ -34,6 +32,7 @@ board_build.cmake_extra_args = -DHELIA_NODE=Instrumentation upload_port = /dev/cu.usbmodem1201 monitor_port = /dev/cu.usbmodem1201 +; Add when they're ready to be programmed properly ; [env:mechanisms] ; board_build.cmake_extra_args = -DHELIA_NODE=Mechanisms ; [env:thermals] @@ -42,4 +41,4 @@ monitor_port = /dev/cu.usbmodem1201 [env:photonics] board_build.cmake_extra_args = -DHELIA_NODE=Photonics upload_port = /dev/cu.usbmodem21401 -monitor_port = /dev/cu.usbmodem21401 \ No newline at end of file +monitor_port = /dev/cu.usbmodem21401 From 627fe9684f8ecdada20ec7b4aa120969bfeeb459 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:35:33 +0100 Subject: [PATCH 03/34] Added test files for `src` and `libs` --- tests/CMakeLists.txt | 29 +++++ tests/libs/test_can.cpp | 158 ++++++++++++++++++++++++++ tests/libs/test_can_eps.cpp | 39 +++++++ tests/libs/test_fsm.cpp | 58 ++++++++++ tests/libs/test_util_thermistor.cpp | 58 ++++++++++ tests/libs/test_utilities.cpp | 131 ++++++++++++++++++++++ tests/src/test_eps.cpp | 168 ++++++++++++++++++++++++++++ tests/src/test_instrumentation.cpp | 32 ++++++ 8 files changed, 673 insertions(+) create mode 100644 tests/CMakeLists.txt create mode 100644 tests/libs/test_can.cpp create mode 100644 tests/libs/test_can_eps.cpp create mode 100644 tests/libs/test_fsm.cpp create mode 100644 tests/libs/test_util_thermistor.cpp create mode 100644 tests/libs/test_utilities.cpp create mode 100644 tests/src/test_eps.cpp create mode 100644 tests/src/test_instrumentation.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..6d9b2f2 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,29 @@ +include(FetchContent) +FetchContent_Declare(googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.15.2.zip + DOWNLOAD_EXTRACT_TIMESTAMP TRUE) +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) +include(GoogleTest) + +# One executable per library; add a line here per new test file +function(helia_test name) + add_executable(${name} ${ARGN}) + target_link_libraries(${name} PRIVATE helia_libs GTest::gtest_main) + gtest_discover_tests(${name}) +endfunction() + +helia_test(test_fsm libs/test_fsm.cpp) +helia_test(test_can libs/test_can.cpp) +helia_test(test_utilities libs/test_utilities.cpp) +helia_test(test_util_thermistor libs/test_util_thermistor.cpp) +helia_test(test_can_eps libs/test_can_eps.cpp) + +helia_test(test_eps src/test_eps.cpp) +target_link_libraries(test_eps PRIVATE node_EPS) + +helia_test(test_master src/test_master.cpp) +target_link_libraries(test_master PRIVATE node_Master) + +helia_test(test_instrumentation src/test_instrumentation.cpp) +target_link_libraries(test_instrumentation PRIVATE node_Instrumentation) \ No newline at end of file diff --git a/tests/libs/test_can.cpp b/tests/libs/test_can.cpp new file mode 100644 index 0000000..c9e1f6e --- /dev/null +++ b/tests/libs/test_can.cpp @@ -0,0 +1,158 @@ +#include +#include +#include "can.h" + +TEST(CanId, RoundTripsEveryCombination) +{ + for (unsigned p = 0; p < 4; p++) + for (unsigned s = 0; s < 8; s++) + for (unsigned t = 0; t < 64; t++) { + uint32_t id = can_make_id((can_priority_t)p, (can_source_t)s, (uint8_t)t); + ASSERT_LE(id, 0x7FFu); + ASSERT_EQ(can_id_priority(id), (can_priority_t)p); + ASSERT_EQ(can_id_source(id), (can_source_t)s); + ASSERT_EQ(can_id_type(id), t); + } +} + +TEST(CanId, OutOfRangeTypeIsMaskedNotSpilled) +{ + // 0x41 is 7 bits; must not corrupt the source field + uint32_t id = can_make_id(CAN_PRIO_INFO, CAN_SRC_EPS, 0x41); + EXPECT_EQ(can_id_source(id), CAN_SRC_EPS); + EXPECT_EQ(can_id_type(id), 0x01); +} + +TEST(CanId, KnownValues) +{ + EXPECT_EQ(can_make_id(CAN_PRIO_INFO, CAN_SRC_MASTER, CAN_MSG_HEARTBEAT), 0x640u); + EXPECT_EQ(can_make_id(CAN_PRIO_IMPORTANT, CAN_SRC_MASTER, CAN_CMD_UNLOCK_NODE), 0x240u); +} + +TEST(CanBytes, LittleEndianLayout) +{ + uint8_t b[4] = {}; + can_put_u32(b, 0x11223344u); + EXPECT_EQ(b[0], 0x44); EXPECT_EQ(b[1], 0x33); EXPECT_EQ(b[2], 0x22); EXPECT_EQ(b[3], 0x11); + can_put_u16(b, 0xABCD); + EXPECT_EQ(b[0], 0xCD); EXPECT_EQ(b[1], 0xAB); +} + +TEST(CanBytes, RoundTripsExtremes) +{ + uint8_t b[4] = {}; + const int16_t i16s[] = {INT16_MIN, -1, 0, 372, INT16_MAX}; + for (int16_t v : i16s) { + can_put_i16(b, v); + EXPECT_EQ(can_get_i16(b), v); + } + for (uint32_t v : {0u, 1u, 0xDEADBEEFu, UINT32_MAX}) { + can_put_u32(b, v); + EXPECT_EQ(can_get_u32(b), v); + } +} + +TEST(CanFrame, InitZeroesPayloadAndClampsDlc) +{ + can_frame_t f; + memset(&f, 0xFF, sizeof f); + can_frame_init(&f, CAN_PRIO_INFO, CAN_SRC_EPS, 0x04, 12); + EXPECT_EQ(f.dlc, 8); + for (uint8_t byte : f.data) EXPECT_EQ(byte, 0); +} + +/* ---- Heartbeat --------------------------------------------------------- */ + +TEST(CanHeartbeat, RoundTrip) +{ + can_heartbeat_t in = {CAN_SRC_EPS, 2, 123456u}, out = {}; + can_frame_t f; + can_encode_heartbeat(&f, &in); + ASSERT_TRUE(can_decode_heartbeat(&f, &out)); + EXPECT_EQ(out.source, CAN_SRC_EPS); + EXPECT_EQ(out.state, 2); + EXPECT_EQ(out.uptime_s, 123456u); +} + +TEST(CanHeartbeat, RejectsCommandsAndShortFrames) +{ + can_heartbeat_t out = {}; + can_frame_t f; + + can_encode_sys_cmd(&f, CAN_SYS_UNLOCK, CAN_SRC_BROADCAST); // type 0x00 too + EXPECT_FALSE(can_decode_heartbeat(&f, &out)); + + can_encode_sys_cmd(&f, CAN_SYS_UNLOCK, CAN_SRC_EPS); // Master, type 0x00 + EXPECT_FALSE(can_decode_heartbeat(&f, &out)); + + can_heartbeat_t hb = {CAN_SRC_EPS, 1, 5}; + can_encode_heartbeat(&f, &hb); + f.dlc = 4; + EXPECT_FALSE(can_decode_heartbeat(&f, &out)); + EXPECT_FALSE(can_decode_heartbeat(nullptr, &out)); +} + +/* ---- System commands --------------------------------------------------- */ + +TEST(CanSysCmd, MasterHeartbeatIsNotAnUnlock) +{ + // The old bug: Master heartbeat and CMD_UNLOCK_NODE share source 0x1 and + // type 0x00. A heartbeat whose first byte equals our ID must not unlock us. + can_heartbeat_t hb = {CAN_SRC_MASTER, (uint8_t)CAN_SRC_EPS, 0}; + can_frame_t f; + can_encode_heartbeat(&f, &hb); + ASSERT_EQ(f.data[0], (uint8_t)CAN_SRC_EPS); + EXPECT_EQ(can_decode_sys_cmd(&f, CAN_SRC_EPS), CAN_SYS_NONE); +} + +struct SysCase { + const char *name; + can_sys_cmd_t sent; + can_source_t target; + can_source_t me; + can_sys_cmd_t expected; +}; + +class CanSysCmdTable : public ::testing::TestWithParam {}; + +TEST_P(CanSysCmdTable, EncodeThenDecode) +{ + const SysCase &c = GetParam(); + can_frame_t f; + ASSERT_TRUE(can_encode_sys_cmd(&f, c.sent, c.target)); + EXPECT_EQ(can_decode_sys_cmd(&f, c.me), c.expected); +} + +INSTANTIATE_TEST_SUITE_P(All, CanSysCmdTable, ::testing::Values( + SysCase{"UnlockAll", CAN_SYS_UNLOCK, CAN_SRC_BROADCAST, CAN_SRC_EPS, CAN_SYS_UNLOCK}, + SysCase{"SleepAll", CAN_SYS_SLEEP, CAN_SRC_BROADCAST, CAN_SRC_EPS, CAN_SYS_SLEEP}, + SysCase{"ResetAll", CAN_SYS_RESET, CAN_SRC_BROADCAST, CAN_SRC_EPS, CAN_SYS_RESET}, + SysCase{"UnlockMe", CAN_SYS_UNLOCK, CAN_SRC_EPS, CAN_SRC_EPS, CAN_SYS_UNLOCK}, + SysCase{"SleepMe", CAN_SYS_SLEEP, CAN_SRC_EPS, CAN_SRC_EPS, CAN_SYS_SLEEP}, + SysCase{"ResetMe", CAN_SYS_RESET, CAN_SRC_EPS, CAN_SRC_EPS, CAN_SYS_RESET}, + SysCase{"UnlockOtherNode", CAN_SYS_UNLOCK, CAN_SRC_THERMAL, CAN_SRC_EPS, CAN_SYS_NONE}, + SysCase{"ResetOtherNode", CAN_SYS_RESET, CAN_SRC_THERMAL, CAN_SRC_EPS, CAN_SYS_NONE} +), [](const auto &info) { return std::string(info.param.name); }); + +TEST(CanSysCmd, TargetedCommandWithNoPayloadIsIgnored) +{ + can_frame_t f; + can_encode_sys_cmd(&f, CAN_SYS_UNLOCK, CAN_SRC_EPS); + f.dlc = 0; // data[0] still holds our ID, but it isn't part of the frame + EXPECT_EQ(can_decode_sys_cmd(&f, CAN_SRC_EPS), CAN_SYS_NONE); +} + +TEST(CanSysCmd, WrongPriorityIsIgnored) +{ + can_frame_t f; + can_frame_init(&f, CAN_PRIO_INFO, CAN_SRC_BROADCAST, CAN_CMD_UNLOCK_ALL, 0); + EXPECT_EQ(can_decode_sys_cmd(&f, CAN_SRC_EPS), CAN_SYS_NONE); +} + +TEST(CanSysCmd, BadInputs) +{ + can_frame_t f; + EXPECT_FALSE(can_encode_sys_cmd(&f, CAN_SYS_NONE, CAN_SRC_EPS)); + EXPECT_FALSE(can_encode_sys_cmd(&f, (can_sys_cmd_t)99, CAN_SRC_EPS)); + EXPECT_EQ(can_decode_sys_cmd(nullptr, CAN_SRC_EPS), CAN_SYS_NONE); +} \ No newline at end of file diff --git a/tests/libs/test_can_eps.cpp b/tests/libs/test_can_eps.cpp new file mode 100644 index 0000000..15f05ee --- /dev/null +++ b/tests/libs/test_can_eps.cpp @@ -0,0 +1,39 @@ +#include +#include "can_eps.h" + +TEST(CanEps, StatusRoundTrip) +{ + can_eps_status_t in = {412, 4120, -35}, out = {}; + can_frame_t f; + can_encode_eps_status(&f, &in); + EXPECT_EQ(f.dlc, 6); + ASSERT_TRUE(can_decode_eps_status(&f, &out)); + EXPECT_EQ(out.current_ma, 412); + EXPECT_EQ(out.shunt_mv_x100, 4120); + EXPECT_EQ(out.temp_c_x10, -35); +} + +TEST(CanEps, InvalidSentinelSurvives) +{ + can_eps_status_t in = {INT16_MIN, INT16_MIN, INT16_MIN}, out = {}; + can_frame_t f; + can_encode_eps_status(&f, &in); + ASSERT_TRUE(can_decode_eps_status(&f, &out)); + EXPECT_EQ(out.current_ma, INT16_MIN); + EXPECT_EQ(out.temp_c_x10, INT16_MIN); +} + +TEST(CanEps, RejectsOtherFrames) +{ + can_eps_status_t out; + can_frame_t f; + can_heartbeat_t hb = {CAN_SRC_EPS, 1, 0}; + can_encode_heartbeat(&f, &hb); + EXPECT_FALSE(can_decode_eps_status(&f, &out)); + + can_eps_status_t in = {1, 2, 3}; + can_encode_eps_status(&f, &in); + f.dlc = 5; + EXPECT_FALSE(can_decode_eps_status(&f, &out)); + EXPECT_FALSE(can_decode_eps_status(nullptr, &out)); +} \ No newline at end of file diff --git a/tests/libs/test_fsm.cpp b/tests/libs/test_fsm.cpp new file mode 100644 index 0000000..9ec57df --- /dev/null +++ b/tests/libs/test_fsm.cpp @@ -0,0 +1,58 @@ +#include +#include "fsm.h" + +struct Transition { + fsm_state_t from; + fsm_event_t evt; + fsm_state_t expected; +}; + +// Makes ctest output readable: "SAFE + evt 2 -> ACTIVE" instead of raw bytes +static std::ostream &operator<<(std::ostream &os, const Transition &t) +{ + return os << fsm_state_name(t.from) << " + evt " << t.evt + << " -> " << fsm_state_name(t.expected); +} + +class FsmTransitions : public ::testing::TestWithParam {}; + +TEST_P(FsmTransitions, NextStateIsCorrect) +{ + const Transition &t = GetParam(); + EXPECT_EQ(fsm_next(t.from, t.evt), t.expected) + << fsm_state_name(t.from) << " + event " << t.evt; +} + +INSTANTIATE_TEST_SUITE_P(AllTransitions, FsmTransitions, ::testing::Values( + // INIT + Transition{FSM_INIT, FSM_EVT_NONE, FSM_INIT}, + Transition{FSM_INIT, FSM_EVT_INIT_OK, FSM_SAFE}, + Transition{FSM_INIT, FSM_EVT_UNLOCK, FSM_INIT}, // can't skip SAFE + Transition{FSM_INIT, FSM_EVT_SLEEP, FSM_INIT}, + // SAFE + Transition{FSM_SAFE, FSM_EVT_NONE, FSM_SAFE}, + Transition{FSM_SAFE, FSM_EVT_INIT_OK, FSM_SAFE}, + Transition{FSM_SAFE, FSM_EVT_UNLOCK, FSM_ACTIVE}, + Transition{FSM_SAFE, FSM_EVT_SLEEP, FSM_SLEEP}, + // ACTIVE + Transition{FSM_ACTIVE, FSM_EVT_NONE, FSM_ACTIVE}, + Transition{FSM_ACTIVE, FSM_EVT_UNLOCK, FSM_ACTIVE}, // repeated unlock is harmless + Transition{FSM_ACTIVE, FSM_EVT_SLEEP, FSM_SLEEP}, + // SLEEP + Transition{FSM_SLEEP, FSM_EVT_UNLOCK, FSM_SLEEP}, + Transition{FSM_SLEEP, FSM_EVT_INIT_OK, FSM_SLEEP} +)); + +TEST(Fsm, CorruptedStateFailsSafe) +{ + EXPECT_EQ(fsm_next(static_cast(42), FSM_EVT_UNLOCK), FSM_SAFE); +} + +TEST(Fsm, StateNamesNeverNull) +{ + for (int s = 0; s <= FSM_STATE_COUNT; ++s) { + ASSERT_NE(fsm_state_name(static_cast(s)), nullptr); + } + EXPECT_STREQ(fsm_state_name(FSM_ACTIVE), "ACTIVE"); + EXPECT_STREQ(fsm_state_name(static_cast(42)), "UNKNOWN"); +} \ No newline at end of file diff --git a/tests/libs/test_util_thermistor.cpp b/tests/libs/test_util_thermistor.cpp new file mode 100644 index 0000000..38dd8fd --- /dev/null +++ b/tests/libs/test_util_thermistor.cpp @@ -0,0 +1,58 @@ +#include +#include +#include "util_thermistor.h" + +static util_ntc_cfg_t cfg(bool ntc_on_top) +{ + return {3300.0f, 10000.0f, 10000.0f, 25.0f, 3950.0f, ntc_on_top}; +} + +/* Divider voltage the circuit would produce at temp_c (the forward model) */ +static float divider_mv(const util_ntc_cfg_t &c, float temp_c) +{ + float r = c.r0_ohm * std::exp(c.beta * (1.0f / (temp_c + 273.15f) - 1.0f / (c.t0_c + 273.15f))); + return c.ntc_on_top ? c.supply_mv * c.r_fixed_ohm / (r + c.r_fixed_ohm) + : c.supply_mv * r / (r + c.r_fixed_ohm); +} + +TEST(UtilNtc, MidpointIsReferenceTemperature) +{ + // R_ntc == R_fixed == R0 puts the ADC at half supply in either layout + util_ntc_cfg_t top = cfg(true), bottom = cfg(false); + EXPECT_NEAR(util_ntc_temp_c(&top, 1650.0f), 25.0f, 0.01f); + EXPECT_NEAR(util_ntc_temp_c(&bottom, 1650.0f), 25.0f, 0.01f); +} + +TEST(UtilNtc, InvertsTheDividerAcrossTheRange) +{ + for (bool top : {true, false}) { + util_ntc_cfg_t c = cfg(top); + for (float t : {-40.0f, -10.0f, 0.0f, 35.0f, 60.0f, 100.0f}) { + EXPECT_NEAR(util_ntc_temp_c(&c, divider_mv(c, t)), t, 0.05f) + << "top=" << top << " t=" << t; + } + } +} + +TEST(UtilNtc, HotterMeansHigherVoltageWhenNtcOnTop) +{ + util_ntc_cfg_t c = cfg(true); + EXPECT_GT(util_ntc_temp_c(&c, 2000.0f), util_ntc_temp_c(&c, 1500.0f)); +} + +TEST(UtilNtc, OpenOrShortedThermistorIsNaN) +{ + util_ntc_cfg_t c = cfg(true); + EXPECT_TRUE(std::isnan(util_ntc_temp_c(&c, 0.0f))); + EXPECT_TRUE(std::isnan(util_ntc_temp_c(&c, 3300.0f))); + EXPECT_TRUE(std::isnan(util_ntc_temp_c(&c, -5.0f))); + EXPECT_TRUE(std::isnan(util_ntc_temp_c(&c, NAN))); +} + +TEST(UtilNtc, BadConfigIsNaN) +{ + util_ntc_cfg_t c = cfg(true); + c.beta = 0.0f; + EXPECT_TRUE(std::isnan(util_ntc_temp_c(&c, 1650.0f))); + EXPECT_TRUE(std::isnan(util_ntc_temp_c(nullptr, 1650.0f))); +} \ No newline at end of file diff --git a/tests/libs/test_utilities.cpp b/tests/libs/test_utilities.cpp new file mode 100644 index 0000000..f6812a3 --- /dev/null +++ b/tests/libs/test_utilities.cpp @@ -0,0 +1,131 @@ +#include +#include +#include +#include "util_math.h" +#include "util_filter.h" +#include "util_control.h" +#include "util_timer.h" + +static const float kNaN = std::numeric_limits::quiet_NaN(); +static const float kInf = std::numeric_limits::infinity(); + +TEST(UtilMath, InRange) +{ + EXPECT_TRUE(util_in_range(37.2f, -50.0f, 100.0f)); + EXPECT_TRUE(util_in_range(-50.0f, -50.0f, 100.0f)); // edges inclusive + EXPECT_FALSE(util_in_range(-200.0f, -50.0f, 100.0f)); // the SED's example + EXPECT_FALSE(util_in_range(kNaN, -50.0f, 100.0f)); + EXPECT_FALSE(util_in_range(kInf, -50.0f, 100.0f)); +} + +TEST(UtilMath, Clamp) +{ + EXPECT_EQ(util_clampf(5.0f, 0.0f, 10.0f), 5.0f); + EXPECT_EQ(util_clampf(-1.0f, 0.0f, 10.0f), 0.0f); + EXPECT_EQ(util_clampf(11.0f, 0.0f, 10.0f), 10.0f); + EXPECT_EQ(util_clampf(kNaN, 0.0f, 10.0f), 0.0f); +} + +TEST(UtilMath, ToI16RoundsAndScales) +{ + EXPECT_EQ(util_to_i16(37.2f, 10.0f), 372); // SED example + EXPECT_EQ(util_to_i16(-12.36f, 10.0f), -124); // rounds to nearest + EXPECT_EQ(util_to_i16(28.012f, 1000.0f), 28012); // volts -> mV +} + +TEST(UtilMath, ToI16SaturatesAndFlagsInvalid) +{ + EXPECT_EQ(util_to_i16(1e9f, 1.0f), INT16_MAX); + EXPECT_EQ(util_to_i16(-1e9f, 1.0f), INT16_MIN + 1); // never collides with INVALID + EXPECT_EQ(util_to_i16(kInf, 1.0f), INT16_MAX); + EXPECT_EQ(util_to_i16(kNaN, 10.0f), UTIL_I16_INVALID); +} + +TEST(UtilMath, FromI16) +{ + EXPECT_FLOAT_EQ(util_from_i16(372, 10.0f), 37.2f); + EXPECT_TRUE(std::isnan(util_from_i16(UTIL_I16_INVALID, 10.0f))); + EXPECT_TRUE(std::isnan(util_from_i16(100, 0.0f))); +} + +TEST(UtilEma, FirstSampleSeedsOutput) +{ + util_ema_t f; + util_ema_init(&f, 0.1f); + EXPECT_FLOAT_EQ(util_ema_update(&f, 35.0f), 35.0f); // no ramp from 0 +} + +TEST(UtilEma, ConvergesToStepInput) +{ + util_ema_t f; + util_ema_init(&f, 0.2f); + util_ema_update(&f, 0.0f); + float out = 0.0f; + for (int i = 0; i < 100; i++) out = util_ema_update(&f, 10.0f); + EXPECT_NEAR(out, 10.0f, 1e-3f); +} + +TEST(UtilEma, SmoothsASpike) +{ + util_ema_t f; + util_ema_init(&f, 0.1f); + util_ema_update(&f, 35.0f); + EXPECT_NEAR(util_ema_update(&f, 135.0f), 45.0f, 1e-4f); // 10% of the jump +} + +TEST(UtilEma, BadAlphaMeansNoFiltering) +{ + for (float a : {0.0f, -0.5f, 1.5f, kNaN}) { + util_ema_t f; + util_ema_init(&f, a); + util_ema_update(&f, 1.0f); + EXPECT_FLOAT_EQ(util_ema_update(&f, 7.0f), 7.0f) << "alpha=" << a; + } +} + +TEST(UtilBangBang, HysteresisCycle) +{ + util_bangbang_t c; + util_bangbang_init(&c, 35.0f, 0.5f); + EXPECT_FALSE(c.on); + + EXPECT_TRUE(util_bangbang_update(&c, 30.0f)); // cold: on + EXPECT_TRUE(util_bangbang_update(&c, 35.2f)); // in band while heating: stays on + EXPECT_FALSE(util_bangbang_update(&c, 35.5f)); // top of band: off + EXPECT_FALSE(util_bangbang_update(&c, 34.8f)); // in band while cooling: stays off + EXPECT_TRUE(util_bangbang_update(&c, 34.5f)); // bottom of band: on +} + +TEST(UtilBangBang, NaNTurnsOff) +{ + util_bangbang_t c; + util_bangbang_init(&c, 35.0f, 0.5f); + util_bangbang_update(&c, 20.0f); + EXPECT_FALSE(util_bangbang_update(&c, kNaN)); +} + +TEST(UtilBangBang, SetpointChangeTakesEffect) +{ + util_bangbang_t c; + util_bangbang_init(&c, 35.0f, 0.5f); + EXPECT_FALSE(util_bangbang_update(&c, 36.0f)); + c.setpoint = 40.0f; // e.g. CMD_SET_TARGET between flight phases + EXPECT_TRUE(util_bangbang_update(&c, 36.0f)); +} + +TEST(UtilTimer, FiresOncePerPeriod) +{ + uint32_t next = 0; + EXPECT_TRUE(util_every(0, &next, 500)); // first call fires immediately + EXPECT_FALSE(util_every(499, &next, 500)); + EXPECT_TRUE(util_every(500, &next, 500)); + EXPECT_FALSE(util_every(999, &next, 500)); +} + +TEST(UtilTimer, SurvivesClockWrap) +{ + uint32_t next = UINT32_MAX - 100; + EXPECT_TRUE(util_every(UINT32_MAX - 100, &next, 500)); + EXPECT_FALSE(util_every(398, &next, 500)); // 499 ms later, wrapped + EXPECT_TRUE(util_every(399, &next, 500)); // 500 ms later +} \ No newline at end of file diff --git a/tests/src/test_eps.cpp b/tests/src/test_eps.cpp new file mode 100644 index 0000000..0ea155b --- /dev/null +++ b/tests/src/test_eps.cpp @@ -0,0 +1,168 @@ +#include +#include +#include "control.h" +#include "comms.h" +#include "can_eps.h" +#include "util_math.h" + +/* Pin voltage the real divider gives at temp_c (independent of util_ntc_temp_c) */ +static float divider_mv(float temp_c) +{ + const util_ntc_cfg_t c = EPS_NTC_CFG; + float r = c.r0_ohm * std::exp(c.beta * (1.0f / (temp_c + 273.15f) - 1.0f / (c.t0_c + 273.15f))); + return c.ntc_on_top ? c.supply_mv * c.r_fixed_ohm / (r + c.r_fixed_ohm) + : c.supply_mv * r / (r + c.r_fixed_ohm); +} + +static const float MV_25C = divider_mv(25.0f); + +class EpsControl : public ::testing::Test { +protected: + eps_control_t ctl; + eps_data_t d; + void SetUp() override { eps_control_init(&ctl); } + void step(bool s_ok, float s_mv, bool t_ok, float t_mv) + { + eps_raw_t raw = {s_ok, s_mv, t_ok, t_mv}; + eps_control_step(&ctl, &raw, &d); + } +}; + +TEST_F(EpsControl, CurrentFromShunt) +{ + step(true, 41.2f, true, MV_25C); + EXPECT_EQ(d.shunt_mv_x100, 4120); + EXPECT_EQ(d.current_ma, 412); // 41.2 mV / 0.1 ohm +} + +TEST_F(EpsControl, NegativeCurrentIsValid) +{ + step(true, -1.0f, true, MV_25C); + EXPECT_EQ(d.current_ma, -10); +} + +TEST_F(EpsControl, SaturatedOrMissingShuntIsInvalid) +{ + step(true, 81.92f, true, MV_25C); + EXPECT_EQ(d.current_ma, UTIL_I16_INVALID); + EXPECT_EQ(d.shunt_mv_x100, UTIL_I16_INVALID); + + step(false, 10.0f, true, MV_25C); + EXPECT_EQ(d.current_ma, UTIL_I16_INVALID); + EXPECT_NE(d.temp_c_x10, UTIL_I16_INVALID); // one bad sensor doesn't hide the other +} + +TEST_F(EpsControl, ThermistorAt25C) +{ + step(true, 0.0f, true, MV_25C); + EXPECT_EQ(d.temp_c_x10, 250); +} + +TEST_F(EpsControl, ThermistorColdAndWarm) +{ + step(true, 0.0f, true, divider_mv(-20.0f)); + EXPECT_EQ(d.temp_c_x10, -200); + + eps_control_init(&ctl); // fresh filter so the next reading isn't smoothed + step(true, 0.0f, true, divider_mv(30.0f)); + EXPECT_EQ(d.temp_c_x10, 300); +} + +TEST_F(EpsControl, ClippedAdcIsInvalidNotAFalseTemperature) +{ + step(true, 0.0f, true, 3100.0f); + EXPECT_EQ(d.temp_c_x10, UTIL_I16_INVALID); +} + +TEST_F(EpsControl, DisconnectedThermistorIsInvalid) +{ + step(true, 0.0f, true, 0.0f); // pin pulled to ground + EXPECT_EQ(d.temp_c_x10, UTIL_I16_INVALID); + step(true, 0.0f, false, MV_25C); // ADC read failed + EXPECT_EQ(d.temp_c_x10, UTIL_I16_INVALID); +} + +TEST_F(EpsControl, TemperatureSpikeIsSmoothed) +{ + step(true, 0.0f, true, MV_25C); // 25.0 degC seeds the filter + step(true, 0.0f, true, divider_mv(30.0f)); // sudden jump to 30 degC + EXPECT_EQ(d.temp_c_x10, 260); // only 20% of the way there +} + +class EpsComms : public ::testing::Test { +protected: + eps_comms_t c; + eps_data_t data = {412, 4120, 301}; + can_frame_t tx[EPS_COMMS_MAX_TX]; + + void SetUp() override + { + eps_comms_init(&c, 0); + eps_comms_boot_done(&c); // SAFE, as setup.c does + } + void receive(can_sys_cmd_t cmd, can_source_t target) + { + can_frame_t f; + can_encode_sys_cmd(&f, cmd, target); + eps_comms_on_frame(&c, &f); + } +}; + +TEST_F(EpsComms, SafeSendsHeartbeatButNoStatus) +{ + ASSERT_EQ(eps_comms_on_tick(&c, 0, &data, tx), 1u); + can_heartbeat_t hb; + ASSERT_TRUE(can_decode_heartbeat(&tx[0], &hb)); + EXPECT_EQ(hb.source, CAN_SRC_EPS); + EXPECT_EQ(hb.state, FSM_SAFE); +} + +TEST_F(EpsComms, UnlockStartsStatus) +{ + receive(CAN_SYS_UNLOCK, CAN_SRC_EPS); + EXPECT_EQ(c.state, FSM_ACTIVE); + + ASSERT_EQ(eps_comms_on_tick(&c, 0, &data, tx), 2u); // heartbeat + status + can_eps_status_t s; + ASSERT_TRUE(can_decode_eps_status(&tx[1], &s)); + EXPECT_EQ(s.current_ma, 412); + EXPECT_EQ(s.shunt_mv_x100, 4120); + EXPECT_EQ(s.temp_c_x10, 301); +} + +TEST_F(EpsComms, StatusEverySecond) +{ + receive(CAN_SYS_UNLOCK, CAN_SRC_BROADCAST); + EXPECT_EQ(eps_comms_on_tick(&c, 0, &data, tx), 2u); + EXPECT_EQ(eps_comms_on_tick(&c, 500, &data, tx), 1u); // heartbeat only + EXPECT_EQ(eps_comms_on_tick(&c, 1000, &data, tx), 2u); // both +} + +TEST_F(EpsComms, OtherNodesUnlockIsIgnored) +{ + receive(CAN_SYS_UNLOCK, CAN_SRC_THERMAL); + EXPECT_EQ(c.state, FSM_SAFE); +} + +TEST_F(EpsComms, SleepAndReset) +{ + receive(CAN_SYS_RESET, CAN_SRC_BROADCAST); + EXPECT_TRUE(c.reset_requested); + receive(CAN_SYS_SLEEP, CAN_SRC_EPS); + EXPECT_EQ(c.state, FSM_SLEEP); +} + +TEST_F(EpsComms, HeartbeatEvery500ms) +{ + EXPECT_EQ(eps_comms_on_tick(&c, 0, &data, tx), 1u); + EXPECT_EQ(eps_comms_on_tick(&c, 499, &data, tx), 0u); + EXPECT_EQ(eps_comms_on_tick(&c, 500, &data, tx), 1u); +} + +TEST_F(EpsComms, TimingSurvivesClockWrap) +{ + eps_comms_init(&c, UINT32_MAX - 100); + EXPECT_EQ(eps_comms_on_tick(&c, UINT32_MAX - 100, &data, tx), 1u); + EXPECT_EQ(eps_comms_on_tick(&c, 398, &data, tx), 0u); // 499 ms later, wrapped + EXPECT_EQ(eps_comms_on_tick(&c, 399, &data, tx), 1u); // 500 ms later +} \ No newline at end of file diff --git a/tests/src/test_instrumentation.cpp b/tests/src/test_instrumentation.cpp new file mode 100644 index 0000000..55a03b7 --- /dev/null +++ b/tests/src/test_instrumentation.cpp @@ -0,0 +1,32 @@ +#include +#include "comms.h" + +TEST(InstrComms, HeartbeatAsInstrumentation) +{ + instr_comms_t c; + can_frame_t tx[INSTR_COMMS_MAX_TX]; + instr_comms_init(&c, 0); + instr_comms_boot_done(&c); + + ASSERT_EQ(instr_comms_on_tick(&c, 0, tx), 1u); + can_heartbeat_t hb; + ASSERT_TRUE(can_decode_heartbeat(&tx[0], &hb)); + EXPECT_EQ(hb.source, CAN_SRC_INSTRUMENTATION); + EXPECT_EQ(hb.state, FSM_SAFE); +} + +TEST(InstrComms, UnlockedByMaster) +{ + instr_comms_t c; + can_frame_t f; + instr_comms_init(&c, 0); + instr_comms_boot_done(&c); + + can_encode_sys_cmd(&f, CAN_SYS_UNLOCK, CAN_SRC_EPS); // someone else's unlock + instr_comms_on_frame(&c, &f); + EXPECT_EQ(c.state, FSM_SAFE); + + can_encode_sys_cmd(&f, CAN_SYS_UNLOCK, CAN_SRC_INSTRUMENTATION); + instr_comms_on_frame(&c, &f); + EXPECT_EQ(c.state, FSM_ACTIVE); +} \ No newline at end of file From 8dd67850e7879c19fe9a68781354419c6006aa2c Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:35:49 +0100 Subject: [PATCH 04/34] Updated the finite state machine library --- libs/fsm/fsm.c | 34 ++++++++++++++++++++++++++++++++++ libs/fsm/fsm.h | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 libs/fsm/fsm.c diff --git a/libs/fsm/fsm.c b/libs/fsm/fsm.c new file mode 100644 index 0000000..526a598 --- /dev/null +++ b/libs/fsm/fsm.c @@ -0,0 +1,34 @@ +#include "fsm.h" + +fsm_state_t fsm_next(fsm_state_t cur, fsm_event_t evt) +{ + switch (cur) { + case FSM_INIT: + return (evt == FSM_EVT_INIT_OK) ? FSM_SAFE : FSM_INIT; + + case FSM_SAFE: + if (evt == FSM_EVT_UNLOCK) return FSM_ACTIVE; + if (evt == FSM_EVT_SLEEP) return FSM_SLEEP; + return FSM_SAFE; + + case FSM_ACTIVE: + return (evt == FSM_EVT_SLEEP) ? FSM_SLEEP : FSM_ACTIVE; + + case FSM_SLEEP: + return FSM_SLEEP; /* only a reboot (GPIO wake) leaves SLEEP */ + + default: + return FSM_SAFE; /* corrupted state: fail safe */ + } +} + +const char *fsm_state_name(fsm_state_t s) +{ + switch (s) { + case FSM_INIT: return "INIT"; + case FSM_SAFE: return "SAFE"; + case FSM_ACTIVE: return "ACTIVE"; + case FSM_SLEEP: return "SLEEP"; + default: return "UNKNOWN"; + } +} \ No newline at end of file diff --git a/libs/fsm/fsm.h b/libs/fsm/fsm.h index e69de29..a926bc7 100644 --- a/libs/fsm/fsm.h +++ b/libs/fsm/fsm.h @@ -0,0 +1,47 @@ +/** + * @file fsm.h + * @brief Node state machine shared by every node, per SED: + * INIT -> SAFE -> ACTIVE, and SAFE/ACTIVE <-> SLEEP. + * + */ +#ifndef HELIA_FSM_H +#define HELIA_FSM_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Node states */ +typedef enum { + FSM_INIT = 0, /**< Booting, checking sensors and CAN */ + FSM_SAFE, /**< Waiting for unlock; actuators must stay off to prevent shit going crazy */ + FSM_ACTIVE, /**< Main control loop running */ + FSM_SLEEP, /**< Entering deep sleep; wake is a reboot back into INIT */ + FSM_STATE_COUNT +} fsm_state_t; + +/** @brief Events that can move the state machine. */ +typedef enum { + FSM_EVT_NONE = 0, /**< Nothing happened */ + FSM_EVT_INIT_OK, /**< INIT checks passed */ + FSM_EVT_UNLOCK, /**< CMD_UNLOCK_ALL, or CMD_UNLOCK_NODE for this node */ + FSM_EVT_SLEEP, /**< CMD_SLEEP_ALL, or CMD_SLEEP_NODE for this node */ + FSM_EVT_COUNT +} fsm_event_t; + +/** + * @brief Compute the next state, invalid transitions leave the state unchanged + * @param cur Current state: an out-of-range value (e.g. a bit flip) returns FSM_SAFE. + * @param evt Event to apply + * @return The next state + */ +fsm_state_t fsm_next(fsm_state_t cur, fsm_event_t evt); + +/** @brief Short name for logging */ +const char *fsm_state_name(fsm_state_t s); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_FSM_H */ \ No newline at end of file From edf34106e554c4a25bc715e53274713d72cde082 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:36:10 +0100 Subject: [PATCH 05/34] Updated CAN Bus library (this needs cleaning up) --- libs/can/can.h | 54 ++++++------- libs/can/can_bus.h | 15 ++-- libs/can/can_eps.h | 10 +-- libs/can/can_instr.c | 5 +- libs/can/can_instr.h | 42 +++------- libs/can/can_master.h | 23 ++---- libs/can/can_photonics.c | 2 +- libs/can/can_photonics.h | 15 ++-- libs/can/esp32/can_bus_twai.c | 146 ++++++++++++++++++++++++++++++++++ libs/can/protocol.h | 59 +++++++------- 10 files changed, 240 insertions(+), 131 deletions(-) create mode 100644 libs/can/esp32/can_bus_twai.c diff --git a/libs/can/can.h b/libs/can/can.h index 148ee4b..7d659ac 100644 --- a/libs/can/can.h +++ b/libs/can/can.h @@ -1,10 +1,10 @@ /** * @file can.h * @brief Driver-independent CAN helpers: frame type, byte packing, and - * encode/decode for messages every node shares. + * encode/decode for messages every node shares * - * Pure functions only. Sending and receiving frames is done by each node's - * comms code through its HAL; this library never touches the bus. + * NOTE: Sending and receiving frames is done by each node's + * comms code through its HAL; this library never touches the bus just helpers */ #ifndef HELIA_CAN_H #define HELIA_CAN_H @@ -18,16 +18,16 @@ extern "C" { #endif -/** @brief One classic CAN frame, independent of ESP-IDF's twai_message_t. */ +/** @brief Abstract CAN frame without any driver shit (Standard CAN; not FD)*/ typedef struct { - uint32_t id; /**< 11-bit ID, see can_make_id(). */ - uint8_t dlc; /**< Payload length, 0-8. */ + uint32_t id; /**< 11-bit ID, see can_make_id() */ + uint8_t dlc; /**< Payload length, 0-8 */ uint8_t data[CAN_MAX_DLC]; } can_frame_t; /** - * @brief Start a frame: sets the ID and length, zeroes the payload. - * @param dlc Payload length; values above 8 are clamped to 8. + * @brief Start a frame: sets the ID and length, zeroes the payload + * @param dlc Payload length; values above 8 are clamped to 8 */ void can_frame_init(can_frame_t *f, can_priority_t prio, can_source_t src, uint8_t type, uint8_t dlc); @@ -42,46 +42,46 @@ int16_t can_get_i16(const uint8_t *buf); uint32_t can_get_u32(const uint8_t *buf); /**@}*/ -/** @brief Heartbeat contents. Payload: [state u8][uptime_s u32] = 5 bytes. */ +/** @brief Heartbeat contents, Payload: [state u8][uptime_s u32] = 5 bytes. */ typedef struct { - can_source_t source; /**< Which node sent it (from the ID). */ - uint8_t state; /**< Sender's fsm_state_t, as a byte. */ - uint32_t uptime_s; /**< Seconds since boot. A drop means the node rebooted. */ + can_source_t source; /**< Which node sent it (from the ID) */ + uint8_t state; /**< Sender's fsm_state_t, as a byte */ + uint32_t uptime_s; /**< Seconds since boot, if this is lower than the prior log it means the node rebooted! */ } can_heartbeat_t; -/** @brief Build a heartbeat frame (INFO priority, type 0x00). */ +/** @brief Build a heartbeat frame (INFO priority, type 0x00) */ void can_encode_heartbeat(can_frame_t *f, const can_heartbeat_t *hb); /** - * @brief Decode a heartbeat. + * @brief Decode a heartbeat * @return false if the frame is not a well-formed heartbeat (wrong priority, - * wrong type, command-only source, or too short). @p out is untouched. + * wrong type, command-only source, or too short, etc + * @p out is usuallyuntouched */ bool can_decode_heartbeat(const can_frame_t *f, can_heartbeat_t *out); -/** @brief Commands every node must handle, whether broadcast or targeted. */ +/** @brief Commands every node must handle, whether broadcast or targeted */ typedef enum { - CAN_SYS_NONE = 0, /**< Frame is not a system command for this node. */ - CAN_SYS_UNLOCK, /**< SAFE -> ACTIVE. */ - CAN_SYS_SLEEP, /**< Enter SLEEP. */ - CAN_SYS_RESET, /**< Reboot the MCU. */ + CAN_SYS_NONE = 0, /**< Frame is not a system command for this node */ + CAN_SYS_UNLOCK, /**< SAFE -> ACTIVE */ + CAN_SYS_SLEEP, /**< Enter SLEEP */ + CAN_SYS_RESET, /**< Reboot the MCU */ } can_sys_cmd_t; /** - * @brief Build a system command frame (Master uses this). + * @brief Build a system command frame (Master uses this) * @param target A node's source ID, or CAN_SRC_BROADCAST for the *_ALL version. - * @return false if @p cmd is CAN_SYS_NONE or out of range. + * @return false if @p cmd is CAN_SYS_NONE or out of range */ bool can_encode_sys_cmd(can_frame_t *f, can_sys_cmd_t cmd, can_source_t target); /** - * @brief Work out whether a frame is a system command meant for this node. + * @brief Work out whether a frame is a system command meant for this node * - * Matches the full ID (priority included), so Master's HEARTBEAT (INFO, type - * 0x00) is never mistaken for CMD_UNLOCK_NODE (IMPORTANT, type 0x00). + * Matches the full ID (priority included) * - * @param me This node's source ID. - * @return The command, or CAN_SYS_NONE if the frame is anything else. + * @param me This node's source ID + * @return The command, or CAN_SYS_NONE if the frame is anything else */ can_sys_cmd_t can_decode_sys_cmd(const can_frame_t *f, can_source_t me); diff --git a/libs/can/can_bus.h b/libs/can/can_bus.h index d4d2816..d12ff06 100644 --- a/libs/can/can_bus.h +++ b/libs/can/can_bus.h @@ -17,24 +17,23 @@ extern "C" { #endif -/** @brief Start the bus at CAN_BITRATE. Pins are ignored by the sim. */ +/** @brief Start the bus at CAN_BITRATE */ bool can_bus_init(int tx_gpio, int rx_gpio); -/** @brief Queue a frame for sending. False on timeout or bus error. */ +/** @brief Queue a frame for sending */ bool can_bus_send(const can_frame_t *f, uint32_t timeout_ms); -/** @brief Wait up to @p timeout_ms for a frame. False if none arrived. */ +/** @brief Wait up to @p timeout_ms for a frame, false if none arrived */ bool can_bus_recv(can_frame_t *f, uint32_t timeout_ms); -/** @brief Call regularly: recovers from bus-off without a dedicated task, - * and drains driver alerts (RX_QUEUE_FULL, bus-off, error-passive). */ +/** @brief Call regularly: recovers from bus-off without a dedicated RTOS task + * and drains driver alerts (RX_QUEUE_FULL, bus-off, error-passive) */ void can_bus_poll_recovery(void); -/** @brief Log the controller's state and error counters (for debugging the bus). */ +/** @brief Log the controller's state and error counters (added for debugging */ void can_bus_log_status(void); -/** @brief Frames lost to RX_QUEUE_FULL since can_bus_init(). For telemetry - * (Master status frame) rather than just log lines. */ +/** @brief Frames lost to RX_QUEUE_FULL since can_bus_init() */ uint32_t can_bus_dropped_frames(void); #ifdef __cplusplus diff --git a/libs/can/can_eps.h b/libs/can/can_eps.h index bf39a69..368c7e3 100644 --- a/libs/can/can_eps.h +++ b/libs/can/can_eps.h @@ -20,15 +20,15 @@ extern "C" { /** @brief EPS_STATUS contents. */ typedef struct { - int16_t current_ma; /**< Input bus current, mA. */ - int16_t shunt_mv_x100; /**< Voltage across the shunt, units of 0.01 mV. */ - int16_t temp_c_x10; /**< EPS PCB temperature, units of 0.1 degC. */ + int16_t current_ma; /**< Input bus current */ + int16_t shunt_mv_x100; /**< units of 0.01 mV */ + int16_t temp_c_x10; /**< units of 0.1 degC. */ } can_eps_status_t; -/** @brief Encode EPS_STATUS. */ +/** @brief Encode EPS_STATUS */ void can_encode_eps_status(can_frame_t *f, const can_eps_status_t *s); -/** @brief Decode EPS_STATUS. False if the frame isn't one. */ +/** @brief Decode EPS_STATUS, false if the frame isn't one */ bool can_decode_eps_status(const can_frame_t *f, can_eps_status_t *out); #ifdef __cplusplus diff --git a/libs/can/can_instr.c b/libs/can/can_instr.c index d7b7f2f..4885a3a 100644 --- a/libs/can/can_instr.c +++ b/libs/can/can_instr.c @@ -1,10 +1,7 @@ #include "can_instr.h" #include "protocol.h" -/* Little-endian packing helpers, explicit rather than memcpy'ing the - * struct - avoids relying on host/target sharing endianness or struct - * layout, same approach as can_master.c. */ - +/* Little-endian packing helpers, explicit rather than memcpy'ing */ static void put_i16(uint8_t *p, int16_t v) { p[0] = (uint8_t)(v & 0xFF); p[1] = (uint8_t)((v >> 8) & 0xFF); } static void put_u16(uint8_t *p, uint16_t v) { p[0] = (uint8_t)(v & 0xFF); p[1] = (uint8_t)((v >> 8) & 0xFF); } static void put_i32(uint8_t *p, int32_t v) diff --git a/libs/can/can_instr.h b/libs/can/can_instr.h index 47465fb..cec674c 100644 --- a/libs/can/can_instr.h +++ b/libs/can/can_instr.h @@ -1,15 +1,7 @@ /** * @file can_instr.h - * @brief Instrumentation telemetry (source 0x5) encode/decode. Pure - no - * FreeRTOS, no I2C - same tier as can_master.c/can_eps.c. - * - * PRESSURE_DATA/ALTITUDE_DATA/HOUSEKEEPING_DATA (0x01-0x03) are in the SED's - * Instrumentation CAN Message Dictionary. HUMIDITY_DATA/GAS_DATA/ACCEL_DATA/ - * STATUS_DATA (0x04-0x07) are NOT in the SED yet - the board carries an - * SHT45, SCD41 and BNO08x that the current SED table doesn't have messages - * for, and STATUS_DATA (sensor health bitmap + MS5611 board temp) is new. - * Picked the next free type IDs; the SED needs a table update to match - * before this is final. + * @brief Instrumentation telemetry (source 0x5) encode/decode! + * This is pure with no HAL or RTOS dependencies unlike the last codebase I had which got really messy */ #ifndef HELIA_CAN_INSTR_H #define HELIA_CAN_INSTR_H @@ -22,20 +14,16 @@ extern "C" { #endif -#define CAN_MSG_PRESSURE 0x01u /**< per SED */ -#define CAN_MSG_ALTITUDE 0x02u /**< per SED */ -#define CAN_MSG_HOUSEKEEPING 0x03u /**< per SED: pressure + altitude, 1 Hz */ -#define CAN_MSG_HUMIDITY 0x04u /**< NOT YET IN SED - SHT45 */ -#define CAN_MSG_GAS 0x05u /**< NOT YET IN SED - SCD41 */ -#define CAN_MSG_ACCEL 0x06u /**< NOT YET IN SED - BNO08x */ -#define CAN_MSG_STATUS 0x07u /**< NOT YET IN SED - sensor health bitmap + MS5611 board temp */ - -/** @brief Bits in can_instr_status_t.sensor_status - set when that sensor - * read OK this poll cycle. A sensor that's simply not soldered - * down yet (SCD41/MS5611 on the current board rev) will always - * read its bit as 0, same as a genuinely failed one - this only - * tells you "did the last read succeed", not "should this be - * populated". */ +#define CAN_MSG_PRESSURE 0x01u +#define CAN_MSG_ALTITUDE 0x02u +#define CAN_MSG_HOUSEKEEPING 0x03u + +// New (not in SED) messages for sensing +#define CAN_MSG_HUMIDITY 0x04u +#define CAN_MSG_GAS 0x05u +#define CAN_MSG_ACCEL 0x06u +#define CAN_MSG_STATUS 0x07u + #define CAN_INSTR_STATUS_BARO (1u << 0) /**< MS5611 */ #define CAN_INSTR_STATUS_SHT (1u << 1) /**< SHT45 */ #define CAN_INSTR_STATUS_GAS (1u << 2) /**< SCD41 */ @@ -74,11 +62,7 @@ typedef struct { typedef struct { uint8_t sensor_status; /**< bitmask, CAN_INSTR_STATUS_* */ - int16_t board_temp_c_x10; /**< MS5611's own temperature reading - a - free cross-check against the SHT45's - ambient reading. 0 when baro isn't - present/reading OK; check the BARO - status bit before trusting it. */ + int16_t board_temp_c_x10; } can_instr_status_t; void can_encode_pressure(can_frame_t *f, const can_instr_pressure_t *s); diff --git a/libs/can/can_master.h b/libs/can/can_master.h index 12e3a49..1df7031 100644 --- a/libs/can/can_master.h +++ b/libs/can/can_master.h @@ -1,16 +1,7 @@ /** * @file can_master.h * @brief Master status message payload. Shared so Master encodes and ground - * (or any node) decodes with the same layout. - * - * MASTER_STATUS payload (8 bytes, little-endian): - * [uptime_s u32][uplink_cmds u16][chip_temp_c i8][dl_drops u8] - * - * This replaces the TT&C bring-up firmware's ad hoc 8-byte "heartbeat" - * (which reused CAN_MSG_HEARTBEAT for uptime/cmd-count/temp/drops instead - * of the real 5-byte state+uptime heartbeat every node sends). Master now - * sends both: the standard heartbeat via can_encode_heartbeat(), and this - * as a separate message for the extra bring-up telemetry. + * (or any node) decodes with the same layout :) */ #ifndef HELIA_CAN_MASTER_H #define HELIA_CAN_MASTER_H @@ -25,16 +16,16 @@ extern "C" { /** @brief MASTER_STATUS contents. */ typedef struct { - uint32_t uptime_s; /**< Seconds since boot. */ - uint16_t uplink_cmds; /**< TT&C uplink commands processed since boot. */ - int8_t chip_temp_c; /**< Whole degrees C. INT8_MIN = unavailable. */ - uint8_t dl_drops; /**< Downlink queue-overflow + no-link drops. Saturates at 255. */ + uint32_t uptime_s; /**< Seconds since boot */ + uint16_t uplink_cmds; /**< TT&C uplink commands processed since boot */ + int8_t chip_temp_c; /**< Whole degrees C not floating */ + uint8_t dl_drops; /**< Downlink queue-overflow + no-link drops... Note this can saturate if errors enough */ } can_master_status_t; -/** @brief Encode MASTER_STATUS (INFO priority, source Master). */ +/** @brief Encode MASTER_STATUS (INFO priority, source Master) */ void can_encode_master_status(can_frame_t *f, const can_master_status_t *s); -/** @brief Decode MASTER_STATUS. False if the frame isn't one. */ +/** @brief Decode MASTER_STATUS */ bool can_decode_master_status(const can_frame_t *f, can_master_status_t *out); #ifdef __cplusplus diff --git a/libs/can/can_photonics.c b/libs/can/can_photonics.c index e20ccd8..3490758 100644 --- a/libs/can/can_photonics.c +++ b/libs/can/can_photonics.c @@ -1,7 +1,7 @@ #include "can_photonics.h" #include "protocol.h" -/* Little-endian packing helpers - same approach as can_instr.c/can_master.c. */ +/* Little-endian packing helpers, same approach as can_instr.c/can_master.c */ static void put_i16(uint8_t *p, int16_t v) { p[0] = (uint8_t)(v & 0xFF); p[1] = (uint8_t)((v >> 8) & 0xFF); } static void put_i32(uint8_t *p, int32_t v) diff --git a/libs/can/can_photonics.h b/libs/can/can_photonics.h index fa7abd0..d521345 100644 --- a/libs/can/can_photonics.h +++ b/libs/can/can_photonics.h @@ -24,19 +24,16 @@ extern "C" { #endif -#define CAN_MSG_UV0 0x07u /**< NOT YET IN SED - LTR390 #0 (mux ch 0): lux + UV index */ -#define CAN_MSG_UV1 0x08u /**< NOT YET IN SED - LTR390 #1 (mux ch 1): lux + UV index */ -#define CAN_MSG_ADC 0x09u /**< NOT YET IN SED - onboard ADS1115, 4 raw channels (mV) */ +#define CAN_MSG_UV0 0x07u +#define CAN_MSG_UV1 0x08u +#define CAN_MSG_ADC 0x09u -/** @brief One LTR390's latest reading. Sent whenever either field is - * freshly updated - the other field just carries its last known - * value (see hal_read_uv()'s ping-pong doc comment). */ typedef struct { - int32_t lux_x10; /**< Ambient light, units of 0.1 lux. */ - int32_t uvi_x1000; /**< UV index, units of 0.001. */ + int32_t lux_x10; + int32_t uvi_x1000; /**< units of 0.001. */ } can_photonics_uv_t; -/** @brief Raw ADS1115 4-channel read, millivolts. */ +/** @brief Raw ADS1115 4-channel read */ typedef struct { int16_t mv[4]; } can_photonics_adc_t; diff --git a/libs/can/esp32/can_bus_twai.c b/libs/can/esp32/can_bus_twai.c new file mode 100644 index 0000000..c46cd09 --- /dev/null +++ b/libs/can/esp32/can_bus_twai.c @@ -0,0 +1,146 @@ +#include "can_bus.h" +#include +#include "driver/gpio.h" +#include "driver/twai.h" +#include "esp_rom_sys.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" + +static const char *TAG = "can_bus"; + +#ifdef HELIA_CAN_SELF_TEST +#define TWAI_MODE TWAI_MODE_NO_ACK +#else +#define TWAI_MODE TWAI_MODE_NORMAL +#endif + +#define CAN_RX_QUEUE_LEN 64u +#define CAN_TX_QUEUE_LEN 16u + +#define CAN_ALERTS (TWAI_ALERT_RX_QUEUE_FULL | TWAI_ALERT_BUS_OFF | \ + TWAI_ALERT_BUS_RECOVERED | TWAI_ALERT_ERR_PASS) + +static uint32_t s_rx_dropped; /* frames lost to RX_QUEUE_FULL since boot */ + +static void check_rx_line(int rx_gpio) +{ + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << rx_gpio, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + }; + if (gpio_config(&cfg) != ESP_OK) return; + + int high = 0; + const int samples = 200; /* 200 x 50 us = 10 ms, ~5 frame times at 500 kbit/s */ + for (int i = 0; i < samples; i++) { + high += gpio_get_level((gpio_num_t)rx_gpio); + esp_rom_delay_us(50); + } + + if (high == 0) { + ESP_LOGE(TAG, "RX (GPIO%d) stuck LOW: bus held dominant. Check the transceiver " + "(powered? TX/RX swapped?) and CANH/CANL for a short.", rx_gpio); + } else if (high == samples) { + ESP_LOGI(TAG, "bus idle before start (no traffic in 10 ms)"); + } else { + ESP_LOGI(TAG, "bus traffic seen before start (%d%% idle)", high * 100 / samples); + } +} + +bool can_bus_init(int tx_gpio, int rx_gpio) +{ + check_rx_line(rx_gpio); + + twai_general_config_t g = TWAI_GENERAL_CONFIG_DEFAULT( + (gpio_num_t)tx_gpio, (gpio_num_t)rx_gpio, TWAI_MODE); + g.rx_queue_len = CAN_RX_QUEUE_LEN; + g.tx_queue_len = CAN_TX_QUEUE_LEN; + g.alerts_enabled = CAN_ALERTS; + + twai_timing_config_t t = TWAI_TIMING_CONFIG_500KBITS(); /* CAN_BITRATE */ + twai_filter_config_t f = TWAI_FILTER_CONFIG_ACCEPT_ALL(); + + if (twai_driver_install(&g, &t, &f) != ESP_OK || twai_start() != ESP_OK) { + ESP_LOGE(TAG, "TWAI start failed (TX=%d RX=%d)", tx_gpio, rx_gpio); + return false; + } + s_rx_dropped = 0; + ESP_LOGI(TAG, "CAN up, TX=%d RX=%d, rx_queue=%u%s", tx_gpio, rx_gpio, CAN_RX_QUEUE_LEN, + TWAI_MODE == TWAI_MODE_NO_ACK ? " (SELF-TEST, no ACK needed)" : ""); + return true; +} + +bool can_bus_send(const can_frame_t *f, uint32_t timeout_ms) +{ + twai_message_t m = {0}; + m.identifier = f->id; + m.data_length_code = f->dlc; + memcpy(m.data, f->data, f->dlc); +#ifdef HELIA_CAN_SELF_TEST + m.self = 1; /* receive our own frame back */ +#endif + return twai_transmit(&m, pdMS_TO_TICKS(timeout_ms)) == ESP_OK; +} + +bool can_bus_recv(can_frame_t *f, uint32_t timeout_ms) +{ + twai_message_t m; + if (twai_receive(&m, pdMS_TO_TICKS(timeout_ms)) != ESP_OK) return false; + if (m.extd || m.rtr) return false; /* HELIA only uses standard data frames */ + + f->id = m.identifier; + f->dlc = (m.data_length_code > CAN_MAX_DLC) ? (uint8_t)CAN_MAX_DLC : m.data_length_code; + memcpy(f->data, m.data, f->dlc); + return true; +} + +void can_bus_poll_recovery(void) +{ + uint32_t alerts; + if (twai_read_alerts(&alerts, 0) == ESP_OK) { + if (alerts & TWAI_ALERT_RX_QUEUE_FULL) { + s_rx_dropped++; + ESP_LOGW(TAG, "RX queue full, frame dropped (total %lu)", (unsigned long)s_rx_dropped); + } + if (alerts & TWAI_ALERT_ERR_PASS) { + ESP_LOGW(TAG, "entered error-passive state"); + } + if (alerts & TWAI_ALERT_BUS_RECOVERED) { + ESP_LOGI(TAG, "bus recovered"); + } + } + + twai_status_info_t s; + if (twai_get_status_info(&s) != ESP_OK) return; + + if (s.state == TWAI_STATE_BUS_OFF) { + ESP_LOGW(TAG, "bus-off, recovering"); + twai_initiate_recovery(); + } else if (s.state == TWAI_STATE_STOPPED) { + ESP_LOGI(TAG, "recovered, restarting"); + twai_start(); + } +} + +/** @brief Frames lost to RX_QUEUE_FULL since can_bus_init(). */ +uint32_t can_bus_dropped_frames(void) +{ + return s_rx_dropped; +} + +void can_bus_log_status(void) +{ + static const char *const NAMES[] = { "STOPPED", "RUNNING", "BUS_OFF", "RECOVERING" }; + twai_status_info_t s; + if (twai_get_status_info(&s) != ESP_OK) { + ESP_LOGW(TAG, "driver not installed"); + return; + } + ESP_LOGW(TAG, "state=%s tx_err=%lu rx_err=%lu waiting_to_send=%lu bus_errors=%lu arb_lost=%lu received=%lu rx_dropped=%lu", + (unsigned)s.state < 4 ? NAMES[s.state] : "?", + (unsigned long)s.tx_error_counter, (unsigned long)s.rx_error_counter, + (unsigned long)s.msgs_to_tx, (unsigned long)s.bus_error_count, + (unsigned long)s.arb_lost_count, (unsigned long)s.msgs_to_rx, + (unsigned long)s_rx_dropped); +} \ No newline at end of file diff --git a/libs/can/protocol.h b/libs/can/protocol.h index 115c939..5200139 100644 --- a/libs/can/protocol.h +++ b/libs/can/protocol.h @@ -1,14 +1,9 @@ /** * @file protocol.h * @brief HELIA CAN protocol constants: the SED's 11-bit ID scheme and message - * dictionary. Single source of truth - if the SED changes, change it here. - * - * ID layout (standard 11-bit CAN, MSB -> LSB): - * [priority: 2 bits][source: 3 bits][message type: 6 bits] - * Lower numeric ID wins arbitration, so CRITICAL (0) beats INFO (3). - * - * Multi-byte payload fields are little-endian. Sensor values are scaled - * integers, never floats (SED: e.g. 37.2 degC is sent as 372). + * dictionary + * This is supposed to be the single source of truth for the entire CAN protocol. + * If anything changes in here then all codes should be recompiled to match! Any SEDS changes should be reflected here also */ #ifndef HELIA_CAN_PROTOCOL_H #define HELIA_CAN_PROTOCOL_H @@ -19,18 +14,19 @@ extern "C" { #endif -/** @brief Bus bitrate. SED requirement: <= 500 kBd. */ +/** @brief Bus bitrate */ #define CAN_BITRATE 500000u -/** @brief Every node sends a heartbeat at 2 Hz (SED). */ +/** @brief Every node sends a heartbeat at 2 Hz */ #define CAN_HEARTBEAT_PERIOD_MS 500u -/** @brief A node is considered lost after 3 missed heartbeats. */ + +/** @brief A node is considered lost after 3 missed heartbeats (needs to be tested extensively) */ #define CAN_HEARTBEAT_TIMEOUT_MS (3u * CAN_HEARTBEAT_PERIOD_MS) -/** @brief Maximum payload of a classic CAN frame. */ +/** @brief Maximum payload */ #define CAN_MAX_DLC 8u -/** @brief Message priority (2 bits). Lower value = higher priority. */ +/** @brief Message priority (2 bits), lower value = higher priority */ typedef enum { CAN_PRIO_CRITICAL = 0x0, CAN_PRIO_IMPORTANT = 0x1, @@ -38,23 +34,21 @@ typedef enum { CAN_PRIO_INFO = 0x3, } can_priority_t; -/** @brief Message source (3 bits). Numbering per SED Tables 4.51-4.58. */ +/** @brief Message source (3 bits) */ typedef enum { CAN_SRC_GROUND = 0x0, /**< Manual command from ground support. */ - CAN_SRC_MASTER = 0x1, /**< OBDH/TT&C. */ - CAN_SRC_BROADCAST = 0x2, /**< Commands every node acts on. */ + CAN_SRC_MASTER = 0x1, /**< OBDH/TT&C */ + CAN_SRC_BROADCAST = 0x2, /**< Commands every node acts on */ CAN_SRC_MECHANISMS = 0x3, CAN_SRC_PHOTONICS = 0x4, CAN_SRC_INSTRUMENTATION = 0x5, CAN_SRC_EPS = 0x6, - CAN_SRC_THERMAL = 0x7, + CAN_SRC_THERMAL = 0x7, // TODO(hrs) These might need to be reordered in the future } can_source_t; -/* ---- Message types (6 bits). Type IDs are only unique per source. ------ */ - /** @name Every node */ /**@{*/ -#define CAN_MSG_HEARTBEAT 0x00u /**< INFO, 2 Hz. */ +#define CAN_MSG_HEARTBEAT 0x00u /**@}*/ /** @name Broadcast commands (source 0x2) */ @@ -63,43 +57,44 @@ typedef enum { #define CAN_CMD_SLEEP_ALL 0x01u /**< IMPORTANT */ #define CAN_CMD_RESET_ALL 0x02u /**< CRITICAL */ #define CAN_CMD_FLIGHT_PHASE 0x03u /**< IMPORTANT */ -#define CAN_CMD_TIME_SYNC 0x04u /**< IMPORTANT */ /**@}*/ -/** @name Master per-node commands (source 0x1); data[0] = target node */ +/** @name Master per-node commands (source 0x1) */ /**@{*/ -#define CAN_CMD_UNLOCK_NODE 0x00u /**< IMPORTANT. Same type as Master HEARTBEAT: priority tells them apart. */ +#define CAN_CMD_UNLOCK_NODE 0x00u /**< IMPORTANT. Same type as Master HEARTBEAT: priority tells them apart, it works in practice is a bit weird conceptually I might change this later */ #define CAN_CMD_SLEEP_NODE 0x01u /**< IMPORTANT */ #define CAN_CMD_RESET_NODE 0x02u /**< CRITICAL */ /**@}*/ /** @name Master telemetry (source 0x1), see can_master.h for payload */ /**@{*/ -#define CAN_MSG_MASTER_STATUS 0x10u /**< INFO, 1 Hz. Uptime, uplink activity, chip temp, link drops. */ +#define CAN_MSG_MASTER_STATUS 0x10u /**< INFO, 1 Hz. Uptime, uplink activity, chip temp, link drops */ /**@}*/ /** @name EPS messages (source 0x6), see can_eps.h for payloads */ /**@{*/ -#define CAN_EPS_STATUS 0x04u /**< INFO, 1 Hz while ACTIVE. */ +#define CAN_EPS_STATUS 0x04u /**< INFO, 1 Hz while in ACTIVE */ /**@}*/ -/* Other nodes' message types are added here as each node is implemented, - * so this file always matches what the code actually uses. */ +/* Other nodes' message types are added here as each node is implemented (if I remember haha), + * so this file always matches what the code actually uses */ /** - * @brief Pack an 11-bit CAN ID. Out-of-range inputs are masked, never spill - * into neighbouring fields. + * @brief Pack an 11-bit CAN ID, + * Out-of-range inputs are masked so the result is always a valid ID */ static inline uint32_t can_make_id(can_priority_t prio, can_source_t src, uint8_t type) { return (((uint32_t)prio & 0x3u) << 9) | (((uint32_t)src & 0x7u) << 6) | ((uint32_t)type & 0x3Fu); } -/** @brief Priority field of an ID. */ +/** @brief Priority field of an ID */ static inline can_priority_t can_id_priority(uint32_t id) { return (can_priority_t)((id >> 9) & 0x3u); } -/** @brief Source field of an ID. */ + +/** @brief Source field of an ID */ static inline can_source_t can_id_source(uint32_t id) { return (can_source_t)((id >> 6) & 0x7u); } -/** @brief Message type field of an ID. */ + +/** @brief Message type field of an ID */ static inline uint8_t can_id_type(uint32_t id) { return (uint8_t)(id & 0x3Fu); } #ifdef __cplusplus From f979db9898361dbf11301cdc2deec269dcd2a164 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:36:30 +0100 Subject: [PATCH 06/34] Added testing for master --- tests/src/test_master.cpp | 111 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/src/test_master.cpp diff --git a/tests/src/test_master.cpp b/tests/src/test_master.cpp new file mode 100644 index 0000000..fbb0646 --- /dev/null +++ b/tests/src/test_master.cpp @@ -0,0 +1,111 @@ +#include +#include +#include "comms.h" +#include "can_eps.h" + +class MasterComms : public ::testing::Test { +protected: + master_comms_t c; + can_frame_t tx[MASTER_COMMS_MAX_TX]; + char text[96]; + + void SetUp() override { master_comms_init(&c, 0); } + + can_frame_t heartbeat_from(can_source_t src, fsm_state_t state) + { + can_frame_t f; + can_heartbeat_t hb = {src, (uint8_t)state, 12}; + can_encode_heartbeat(&f, &hb); + return f; + } +}; + +TEST_F(MasterComms, StartsActiveAndSendsHeartbeat) +{ + EXPECT_EQ(c.state, FSM_ACTIVE); + ASSERT_EQ(master_comms_on_tick(&c, 0, tx), 1u); + can_heartbeat_t hb; + ASSERT_TRUE(can_decode_heartbeat(&tx[0], &hb)); + EXPECT_EQ(hb.source, CAN_SRC_MASTER); + EXPECT_EQ(hb.state, FSM_ACTIVE); + EXPECT_EQ(master_comms_on_tick(&c, 499, tx), 0u); + EXPECT_EQ(master_comms_on_tick(&c, 500, tx), 1u); +} + +TEST_F(MasterComms, UnlocksANodeInSafe) +{ + can_frame_t hb = heartbeat_from(CAN_SRC_EPS, FSM_SAFE); + ASSERT_EQ(master_comms_on_frame(&c, 0, &hb, tx), 1u); + // The reply is an unlock EPS accepts, and no other node does + EXPECT_EQ(can_decode_sys_cmd(&tx[0], CAN_SRC_EPS), CAN_SYS_UNLOCK); + EXPECT_EQ(can_decode_sys_cmd(&tx[0], CAN_SRC_THERMAL), CAN_SYS_NONE); +} + +TEST_F(MasterComms, LeavesActiveNodesAlone) +{ + can_frame_t hb = heartbeat_from(CAN_SRC_EPS, FSM_ACTIVE); + EXPECT_EQ(master_comms_on_frame(&c, 0, &hb, tx), 0u); +} + +TEST_F(MasterComms, IgnoresOtherFrames) +{ + can_frame_t f; + can_eps_status_t s = {412, 4120, 301}; + can_encode_eps_status(&f, &s); + EXPECT_EQ(master_comms_on_frame(&c, 0, &f, tx), 0u); +} + +TEST_F(MasterComms, DescribesHeartbeat) +{ + can_frame_t hb = heartbeat_from(CAN_SRC_EPS, FSM_SAFE); + EXPECT_EQ(std::string(master_describe_frame(&hb, text, sizeof text)), + "EPS heartbeat: SAFE, up 12 s"); +} + +TEST_F(MasterComms, DescribesEpsStatusIncludingInvalid) +{ + can_frame_t f; + can_eps_status_t s = {412, 4120, INT16_MIN}; + can_encode_eps_status(&f, &s); + EXPECT_EQ(std::string(master_describe_frame(&f, text, sizeof text)), + "EPS status: current 412.00 mA, shunt 41.20 mV, PCB invalid"); +} + +TEST_F(MasterComms, DescribesUnknownFrames) +{ + can_frame_t f; + can_frame_init(&f, CAN_PRIO_WARNING, CAN_SRC_THERMAL, 0x06, 2); + EXPECT_EQ(std::string(master_describe_frame(&f, text, sizeof text)), + "THERMAL frame: prio 2, type 0x06, 2 bytes (id 0x5C6)"); +} + +TEST_F(MasterComms, DescribeNeverOverflows) +{ + can_frame_t hb = heartbeat_from(CAN_SRC_INSTRUMENTATION, FSM_SAFE); + char tiny[8]; + master_describe_frame(&hb, tiny, sizeof tiny); + EXPECT_EQ(std::string(tiny).size(), 7u); +} + +TEST_F(MasterComms, TracksNodesComingAndGoing) +{ + EXPECT_EQ(c.alive_mask, 0); + can_frame_t eps = heartbeat_from(CAN_SRC_EPS, FSM_ACTIVE); + can_frame_t ins = heartbeat_from(CAN_SRC_INSTRUMENTATION, FSM_ACTIVE); + master_comms_on_frame(&c, 1000, &eps, tx); + master_comms_on_frame(&c, 1000, &ins, tx); + EXPECT_EQ(c.alive_mask, (1u << CAN_SRC_EPS) | (1u << CAN_SRC_INSTRUMENTATION)); + + master_comms_on_frame(&c, 2400, &eps, tx); // EPS keeps talking, Instrumentation goes quiet + master_comms_on_tick(&c, 2500, tx); // 1500 ms since Instrumentation: not yet lost + EXPECT_EQ(c.alive_mask, (1u << CAN_SRC_EPS) | (1u << CAN_SRC_INSTRUMENTATION)); + master_comms_on_tick(&c, 2501, tx); // 1501 ms: lost + EXPECT_EQ(c.alive_mask, 1u << CAN_SRC_EPS); +} + +TEST_F(MasterComms, IgnoresItsOwnHeartbeat) +{ + can_frame_t own = heartbeat_from(CAN_SRC_MASTER, FSM_ACTIVE); + EXPECT_EQ(master_comms_on_frame(&c, 0, &own, tx), 0u); + EXPECT_EQ(c.alive_mask, 0); +} \ No newline at end of file From 31a3ed00269bbf73f43eefaffbcd2843529d1682 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:36:49 +0100 Subject: [PATCH 07/34] Added a basic bang bang controller implementation - waiting on thermals --- libs/utilities/util_control.c | 22 +++++++++++++++++++ libs/utilities/util_control.h | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 libs/utilities/util_control.c create mode 100644 libs/utilities/util_control.h diff --git a/libs/utilities/util_control.c b/libs/utilities/util_control.c new file mode 100644 index 0000000..86d9470 --- /dev/null +++ b/libs/utilities/util_control.c @@ -0,0 +1,22 @@ +#include "util_control.h" +#include + +void util_bangbang_init(util_bangbang_t *c, float setpoint, float hysteresis) +{ + c->setpoint = setpoint; + c->hysteresis = (hysteresis > 0.0f) ? hysteresis : 0.0f; + c->on = false; +} + +bool util_bangbang_update(util_bangbang_t *c, float measured) +{ + if (isnan(measured)) { + c->on = false; + } else if (measured <= c->setpoint - c->hysteresis) { + c->on = true; + } else if (measured >= c->setpoint + c->hysteresis) { + c->on = false; + } + /* otherwise inside the dead band: keep previous state */ + return c->on; +} \ No newline at end of file diff --git a/libs/utilities/util_control.h b/libs/utilities/util_control.h new file mode 100644 index 0000000..84a9f27 --- /dev/null +++ b/libs/utilities/util_control.h @@ -0,0 +1,40 @@ +/** + * @file util_control.h + * @brief Bang-bang (on/off) controller with hysteresis. PID goes here later. + * + * Output turns ON at or below (setpoint - hysteresis), OFF at or above + * (setpoint + hysteresis), and holds its previous state in between, so a + * heater doesn't chatter on and off around the setpoint. + */ +#ifndef HELIA_UTIL_CONTROL_H +#define HELIA_UTIL_CONTROL_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Controller state. Change setpoint directly (e.g. on CMD_SET_TARGET) */ +typedef struct { + float setpoint; /**< Target value, e.g. 35.0 degC */ + float hysteresis; /**< Half-width of the dead band, e.g. 0.5 degC */ + bool on; /**< Current output. */ +} util_bangbang_t; + +/** @brief Set up a controller + * Output starts OFF + * Negative hysteresis is treated as 0 */ +void util_bangbang_init(util_bangbang_t *c, float setpoint, float hysteresis); + +/** + * @brief Feed a measurement, get the new output pretty much + * A NaN measurement turns the output OFF + */ +bool util_bangbang_update(util_bangbang_t *c, float measured); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_UTIL_CONTROL_H */ \ No newline at end of file From 5e2064c48f66269b4630534eda6a99250f2c3705 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:37:04 +0100 Subject: [PATCH 08/34] Basic EMA filter added to utilities --- libs/utilities/util_filter.c | 19 ++++++++++++++++++ libs/utilities/util_filter.h | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 libs/utilities/util_filter.c create mode 100644 libs/utilities/util_filter.h diff --git a/libs/utilities/util_filter.c b/libs/utilities/util_filter.c new file mode 100644 index 0000000..1c90535 --- /dev/null +++ b/libs/utilities/util_filter.c @@ -0,0 +1,19 @@ +#include "util_filter.h" + +void util_ema_init(util_ema_t *f, float alpha) +{ + f->alpha = (alpha > 0.0f && alpha <= 1.0f) ? alpha : 1.0f; /* NaN fails both */ + f->value = 0.0f; + f->seeded = false; +} + +float util_ema_update(util_ema_t *f, float sample) +{ + if (!f->seeded) { + f->value = sample; + f->seeded = true; + } else { + f->value += f->alpha * (sample - f->value); + } + return f->value; +} \ No newline at end of file diff --git a/libs/utilities/util_filter.h b/libs/utilities/util_filter.h new file mode 100644 index 0000000..cfd2237 --- /dev/null +++ b/libs/utilities/util_filter.h @@ -0,0 +1,39 @@ +/** + * @file util_filter.h + * @brief Exponential moving average (EMA) filter for noisy sensors + */ +#ifndef HELIA_UTIL_FILTER_H +#define HELIA_UTIL_FILTER_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief EMA state. */ +typedef struct { + float alpha; /**< Smoothing factor, (0, 1) */ + float value; /**< Current filtered output */ + bool seeded; /**< False until the first sample arrives */ +} util_ema_t; + +/** + * @brief Set up a filter. + * An alpha outside (0, 1] (or NaN) becomes 1.0, + * i.e. no filtering, rather than a filter that never moves + */ +void util_ema_init(util_ema_t *f, float alpha); + +/** + * @brief Add a sample and return the new filtered value. + * The first sample is taken as-is, so the output doesn't ramp up from zero + * Range-check samples with util_in_range() before calling this just ot be sure + */ +float util_ema_update(util_ema_t *f, float sample); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_UTIL_FILTER_H */ \ No newline at end of file From 6d2d37c99b78f3a33eee7e7b02017da2a8d48b41 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:37:20 +0100 Subject: [PATCH 09/34] Math & timer helpers added --- libs/utilities/util_math.c | 30 +++++++++++++++++++++++++ libs/utilities/util_math.h | 44 +++++++++++++++++++++++++++++++++++++ libs/utilities/util_timer.c | 8 +++++++ libs/utilities/util_timer.h | 28 +++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 libs/utilities/util_math.c create mode 100644 libs/utilities/util_math.h create mode 100644 libs/utilities/util_timer.c create mode 100644 libs/utilities/util_timer.h diff --git a/libs/utilities/util_math.c b/libs/utilities/util_math.c new file mode 100644 index 0000000..9affa4f --- /dev/null +++ b/libs/utilities/util_math.c @@ -0,0 +1,30 @@ +#include "util_math.h" +#include + +bool util_in_range(float v, float lo, float hi) +{ + return v >= lo && v <= hi; /* every comparison with NaN is false */ +} + +float util_clampf(float v, float lo, float hi) +{ + if (!(v >= lo)) return lo; /* also catches NaN */ + if (v > hi) return hi; + return v; +} + +int16_t util_to_i16(float value, float scale) +{ + float scaled = value * scale; + if (isnan(scaled)) return UTIL_I16_INVALID; + + /* Clamp BEFORE casting: casting an out-of-range float to int is undefined */ + scaled = util_clampf(roundf(scaled), (float)(INT16_MIN + 1), (float)INT16_MAX); + return (int16_t)scaled; +} + +float util_from_i16(int16_t raw, float scale) +{ + if (raw == UTIL_I16_INVALID || scale == 0.0f) return NAN; + return (float)raw / scale; +} \ No newline at end of file diff --git a/libs/utilities/util_math.h b/libs/utilities/util_math.h new file mode 100644 index 0000000..ac38b84 --- /dev/null +++ b/libs/utilities/util_math.h @@ -0,0 +1,44 @@ +/** + * @file util_math.h + * @brief Range checks, clamping, and float <-> scaled-integer conversion. + */ +#ifndef HELIA_UTIL_MATH_H +#define HELIA_UTIL_MATH_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Scaled value meaning "no valid reading" + * I don't think this is ever goign to be a problem tbh, might remove later + */ +#define UTIL_I16_INVALID INT16_MIN + +/** + * @brief True if lo <= v <= hi. + * False for NaN, so a broken sensor fails the check + * I know what you're thinking, this is a pretty dumb function. The thought behind it is readability but also to avoid common mistakes with comparisons that could risk fucking over the project. + */ +bool util_in_range(float v, float lo, float hi); + +/** @brief Limit v to [lo, hi]. NaN returns lo. */ +float util_clampf(float v, float lo, float hi); + +/** + * @brief Convert a physical value to a scaled int16 for CAN (SED: 37.2 degC x10 -> 372) + * Rounds to nearest and saturates to [-32767, 32767] + * NaN returns UTIL_I16_INVALID + */ +int16_t util_to_i16(float value, float scale); + +/** @brief Inverse of util_to_i16() */ +float util_from_i16(int16_t raw, float scale); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_UTIL_MATH_H */ \ No newline at end of file diff --git a/libs/utilities/util_timer.c b/libs/utilities/util_timer.c new file mode 100644 index 0000000..6f45d37 --- /dev/null +++ b/libs/utilities/util_timer.c @@ -0,0 +1,8 @@ +#include "util_timer.h" + +bool util_every(uint32_t now_ms, uint32_t *next_ms, uint32_t period_ms) +{ + if ((int32_t)(now_ms - *next_ms) < 0) return false; + *next_ms = now_ms + period_ms; + return true; +} \ No newline at end of file diff --git a/libs/utilities/util_timer.h b/libs/utilities/util_timer.h new file mode 100644 index 0000000..02516d4 --- /dev/null +++ b/libs/utilities/util_timer.h @@ -0,0 +1,28 @@ +/** + * @file util_timer.h + * @brief Periodic timing helper for task loops that are given the current + * millisecond count. + */ +#ifndef HELIA_UTIL_TIMER_H +#define HELIA_UTIL_TIMER_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief True once per @p period_ms, advancing @p next_ms to the next deadline. + * + * Correct across the millisecond counter wrapping (~49 days). Initialise + * *next_ms to the current time to make the first call fire immediately. + */ +bool util_every(uint32_t now_ms, uint32_t *next_ms, uint32_t period_ms); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_UTIL_TIMER_H */ \ No newline at end of file From d81ab59513ef0936661983a2dab01ebf88ea9722 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:37:29 +0100 Subject: [PATCH 10/34] Thermistor code added --- libs/utilities/util_thermistor.c | 23 ++++++++++++++++++ libs/utilities/util_thermistor.h | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 libs/utilities/util_thermistor.c create mode 100644 libs/utilities/util_thermistor.h diff --git a/libs/utilities/util_thermistor.c b/libs/utilities/util_thermistor.c new file mode 100644 index 0000000..b5397d5 --- /dev/null +++ b/libs/utilities/util_thermistor.c @@ -0,0 +1,23 @@ +#include "util_thermistor.h" +#include +#include + +#define KELVIN 273.15f + +float util_ntc_temp_c(const util_ntc_cfg_t *cfg, float adc_mv) +{ + if (cfg == NULL) return NAN; + if (!(cfg->supply_mv > 0.0f && cfg->r_fixed_ohm > 0.0f && + cfg->r0_ohm > 0.0f && cfg->beta > 0.0f)) return NAN; + + /* At either rail the thermistor is open or shorted: no information */ + if (!(adc_mv > 0.0f && adc_mv < cfg->supply_mv)) return NAN; + + /* Solve the divider for the NTC's resistance */ + float r_ntc = cfg->ntc_on_top + ? cfg->r_fixed_ohm * (cfg->supply_mv - adc_mv) / adc_mv + : cfg->r_fixed_ohm * adc_mv / (cfg->supply_mv - adc_mv); + + float inv_t = 1.0f / (cfg->t0_c + KELVIN) + logf(r_ntc / cfg->r0_ohm) / cfg->beta; + return 1.0f / inv_t - KELVIN; +} \ No newline at end of file diff --git a/libs/utilities/util_thermistor.h b/libs/utilities/util_thermistor.h new file mode 100644 index 0000000..93465a0 --- /dev/null +++ b/libs/utilities/util_thermistor.h @@ -0,0 +1,41 @@ +/** + * @file util_thermistor.h + * @brief Convert an NTC thermistor voltage-divider reading to degC using the + * Beta equation. Shared: EPS, Master, Mechanisms and Thermals all + * have NTC thermistors. + * + * Beta equation: 1/T = 1/T0 + ln(R/R0)/B (T in kelvin) + */ +#ifndef HELIA_UTIL_THERMISTOR_H +#define HELIA_UTIL_THERMISTOR_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Describes one thermistor circuit. + * Values are random bullshit AI made up because there's alike 5 different thermistors on the procurement sheet and none of them are right + * */ +typedef struct { + float supply_mv; + float r_fixed_ohm; + float r0_ohm; + float t0_c; + float beta; + bool ntc_on_top; +} util_ntc_cfg_t; + +/** + * @brief Temperature in degC from the voltage at the ADC pin + * @return NaN if the reading is at or beyond either rail (open or shorted + * thermistor) or the config is invalid. Please range check the output before using it! - Remy + */ +float util_ntc_temp_c(const util_ntc_cfg_t *cfg, float adc_mv); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_UTIL_THERMISTOR_H */ \ No newline at end of file From 036d1672a9c64b0395499ee8807ecb18514e7702 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:37:43 +0100 Subject: [PATCH 11/34] Updated src/CMakeLists --- src/CMakeLists.txt | 20 +++++++++ src/Kconfig.projbuild | 94 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/CMakeLists.txt create mode 100644 src/Kconfig.projbuild diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..2cedf64 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,20 @@ +# ESP-IDF reads this file twice. The first "early expansion" pass only wants +# to know which IDF components we depend on, and can't run file(GLOB ...). +if(CMAKE_BUILD_EARLY_EXPANSION) + idf_component_register(REQUIRES driver esp_adc) + return() +endif() + +if(NOT DEFINED HELIA_NODE) + message(FATAL_ERROR "HELIA_NODE not set") +endif() + +include(${CMAKE_CURRENT_LIST_DIR}/../libs/libs.cmake) + +set(NODE_DIR ${CMAKE_CURRENT_LIST_DIR}/${HELIA_NODE}) +file(GLOB NODE_SRCS ${NODE_DIR}/*.c ${NODE_DIR}/HAL/esp32/*.c) + +idf_component_register( + SRCS ${NODE_SRCS} ${HELIA_LIB_SRCS} ${HELIA_LIB_ESP32_SRCS} + INCLUDE_DIRS ${NODE_DIR} ${NODE_DIR}/HAL ${NODE_DIR}/HAL/esp32 ${HELIA_LIB_INCS} + REQUIRES driver esp_adc esp_eth esp_netif esp_event lwip) \ No newline at end of file diff --git a/src/Kconfig.projbuild b/src/Kconfig.projbuild new file mode 100644 index 0000000..ee9f8fe --- /dev/null +++ b/src/Kconfig.projbuild @@ -0,0 +1,94 @@ +menu "HELIA TT&C (Ethernet)" + + config TTC_ETH_MISO_GPIO + int "W5500 SPI MISO GPIO" + default 13 + help + TODO: fill in from the working PoC's sdkconfig / menuconfig + + config TTC_ETH_MOSI_GPIO + int "W5500 SPI MOSI GPIO" + default 11 + help + TODO: fill in from the working PoC. + + config TTC_ETH_SCLK_GPIO + int "W5500 SPI SCLK GPIO" + default 12 + help + TODO: fill in from the working PoC. + + config TTC_ETH_CS_GPIO + int "W5500 SPI CS GPIO" + default 14 + help + TODO: fill in from the working PoC. + + config TTC_ETH_INT_GPIO + int "W5500 INT GPIO (-1 to poll instead)" + default -1 + help + -1 disables the interrupt line and falls back to polling at + TTC_ETH_POLL_PERIOD_MS. Set to the real INT pin if it's wired. + + config TTC_ETH_POLL_PERIOD_MS + int "W5500 poll period (ms), used only if INT_GPIO is -1" + default 10 + depends on TTC_ETH_INT_GPIO < 0 + + config TTC_ETH_RST_GPIO + int "W5500 RESET GPIO (-1 if tied to board reset)" + default -1 + + config TTC_ETH_SPI_CLOCK_MHZ + int "W5500 SPI clock (MHz)" + default 20 + help + W5500 supports up to ~33 MHz SPI. Start conservative (20 MHz) + on first bring-up with real wiring, raise once link is stable. + + config TTC_USE_STATIC_IP + bool "Use a static IP instead of DHCP" + default y + help + No DHCP server on a point-to-point bench/flight Ethernet link, + so static is the sane default here. + + config TTC_STATIC_IP + string "Static IP address" + default "192.168.50.10" + depends on TTC_USE_STATIC_IP + + config TTC_STATIC_NETMASK + string "Static netmask" + default "255.255.255.0" + depends on TTC_USE_STATIC_IP + + config TTC_STATIC_GW + string "Static gateway" + default "192.168.50.1" + depends on TTC_USE_STATIC_IP + + config TTC_GROUND_IP + string "Default ground station IP (downlink destination)" + default "192.168.50.1" + help + Used until/unless TTC_LEARN_GROUND_IP overrides it with the + IP of whoever connects on the uplink TCP port. + + config TTC_UDP_DOWNLINK_PORT + int "UDP downlink port" + default 5000 + + config TTC_TCP_UPLINK_PORT + int "TCP uplink port" + default 5001 + + config TTC_LEARN_GROUND_IP + bool "Learn ground IP from the uplink TCP client" + default y + help + When ground connects on the uplink port, downlink switches to + their source IP instead of the static TTC_GROUND_IP above. + +endmenu From 38ce0db59f7fd3306557412b3ef275abe975ddab Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:37:54 +0100 Subject: [PATCH 12/34] Setting up EPS --- src/EPS/setup.c | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/EPS/setup.c b/src/EPS/setup.c index e69de29..4caad1b 100644 --- a/src/EPS/setup.c +++ b/src/EPS/setup.c @@ -0,0 +1,93 @@ +/* EPS boot and task loops. The only EPS file that touches FreeRTOS: all the + * logic lives in control.c and comms.c, where it can be unit-tested. */ +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/queue.h" +#include "esp_log.h" + +#include "hal.h" +#include "can_bus.h" +#include "control.h" +#include "comms.h" + +static const char *TAG = "eps"; + +#define CONTROL_PERIOD_MS 100u /* 10 Hz sampling */ +#define RX_TIMEOUT_MS 20u /* how long comms waits for a frame each loop */ +#define TX_TIMEOUT_MS 10u + +/* Length-1 queue used as a mailbox: control overwrites, comms peeks the latest. */ +static QueueHandle_t s_latest; + +static uint32_t now_ms(void) +{ + return (uint32_t)pdTICKS_TO_MS(xTaskGetTickCount()); +} +static void control_task(void *arg) +{ + (void)arg; + eps_control_t ctl; + eps_control_init(&ctl); + TickType_t last_wake = xTaskGetTickCount(); + + for (;;) { + eps_raw_t raw; + eps_data_t data; + + raw.shunt_ok = hal_get_shunt_mv(&raw.shunt_mv); + raw.therm_ok = hal_get_thermistor_mv(&raw.therm_mv); + eps_control_step(&ctl, &raw, &data); + xQueueOverwrite(s_latest, &data); + + vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(CONTROL_PERIOD_MS)); + } +} + +static void comms_task(void *arg) +{ + (void)arg; + eps_comms_t comms; + uint32_t last_status_log_ms = 0; + eps_comms_init(&comms, now_ms()); + eps_comms_boot_done(&comms); /* INIT -> SAFE */ + ESP_LOGI(TAG, "state %s", fsm_state_name(comms.state)); + + for (;;) { + eps_data_t latest; + can_frame_t rx, tx[EPS_COMMS_MAX_TX]; + fsm_state_t before = comms.state; + + if (can_bus_recv(&rx, RX_TIMEOUT_MS)) { + ESP_LOGI(TAG, "rx id=0x%03lX dlc=%u", (unsigned long)rx.id, rx.dlc); /* bench debug: remove once the bus works */ + eps_comms_on_frame(&comms, &rx); + } + + if (xQueuePeek(s_latest, &latest, 0) != pdTRUE) eps_data_init(&latest); + size_t n = eps_comms_on_tick(&comms, now_ms(), &latest, tx); + for (size_t i = 0; i < n; i++) { + if (!can_bus_send(&tx[i], TX_TIMEOUT_MS)) { + ESP_LOGW(TAG, "send failed, id=0x%03lX", (unsigned long)tx[i].id); + if (now_ms() - last_status_log_ms >= 2000u) { + can_bus_log_status(); + last_status_log_ms = now_ms(); + } + } + } + + if (comms.state != before) ESP_LOGI(TAG, "state %s -> %s", fsm_state_name(before), fsm_state_name(comms.state)); + if (comms.reset_requested) hal_restart(); + can_bus_poll_recovery(); + } +} + + +void app_main(void) +{ + ESP_LOGI(TAG, "boot"); + if (!hal_init()) ESP_LOGE(TAG, "hardware init incomplete; affected readings will be sent as invalid"); + + s_latest = xQueueCreate(1, sizeof(eps_data_t)); + + xTaskCreatePinnedToCore(comms_task, "eps_comms", 4096, NULL, 10, NULL, 0); + xTaskCreatePinnedToCore(control_task, "eps_control", 4096, NULL, 5, NULL, 1); +} \ No newline at end of file From fe57f4f2cbf5a31dc7864e45632099b7b2f7edf7 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:38:12 +0100 Subject: [PATCH 13/34] EPS comms.c/.h (renamed from comm; typo?) --- src/EPS/comm.c | 0 src/EPS/comms.c | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/EPS/comms.h | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) delete mode 100644 src/EPS/comm.c create mode 100644 src/EPS/comms.c create mode 100644 src/EPS/comms.h diff --git a/src/EPS/comm.c b/src/EPS/comm.c deleted file mode 100644 index e69de29..0000000 diff --git a/src/EPS/comms.c b/src/EPS/comms.c new file mode 100644 index 0000000..71f88c8 --- /dev/null +++ b/src/EPS/comms.c @@ -0,0 +1,47 @@ +#include "comms.h" +#include "can_eps.h" +#include "util_timer.h" + +#define ME CAN_SRC_EPS + +void eps_comms_init(eps_comms_t *c, uint32_t now_ms) +{ + c->state = FSM_INIT; + c->next_heartbeat_ms = now_ms; + c->next_status_ms = now_ms; + c->reset_requested = false; +} + +void eps_comms_boot_done(eps_comms_t *c) +{ + c->state = fsm_next(c->state, FSM_EVT_INIT_OK); +} + +void eps_comms_on_frame(eps_comms_t *c, const can_frame_t *rx) +{ + switch (can_decode_sys_cmd(rx, ME)) { + case CAN_SYS_UNLOCK: c->state = fsm_next(c->state, FSM_EVT_UNLOCK); break; + case CAN_SYS_SLEEP: c->state = fsm_next(c->state, FSM_EVT_SLEEP); break; + case CAN_SYS_RESET: c->reset_requested = true; break; + + // base case + case CAN_SYS_NONE: break; + } +} + +size_t eps_comms_on_tick(eps_comms_t *c, uint32_t now_ms, + const eps_data_t *latest, can_frame_t tx[EPS_COMMS_MAX_TX]) +{ + size_t n = 0; + + if (util_every(now_ms, &c->next_heartbeat_ms, CAN_HEARTBEAT_PERIOD_MS)) { + can_heartbeat_t hb = { ME, (uint8_t)c->state, now_ms / 1000u }; + can_encode_heartbeat(&tx[n++], &hb); + } + + if (c->state == FSM_ACTIVE && util_every(now_ms, &c->next_status_ms, EPS_STATUS_PERIOD_MS)) { + can_eps_status_t s = { latest->current_ma, latest->shunt_mv_x100, latest->temp_c_x10 }; + can_encode_eps_status(&tx[n++], &s); + } + return n; +} \ No newline at end of file diff --git a/src/EPS/comms.h b/src/EPS/comms.h new file mode 100644 index 0000000..c1f1046 --- /dev/null +++ b/src/EPS/comms.h @@ -0,0 +1,42 @@ +/** + * @file comms.h + * @brief Set's up heartbeat and state transitions for the EPS node + */ +#ifndef HELIA_EPS_COMMS_H +#define HELIA_EPS_COMMS_H + +#include +#include +#include +#include "can.h" +#include "fsm.h" +#include "control.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define EPS_COMMS_MAX_TX 2 +#define EPS_STATUS_PERIOD_MS 1000u + +typedef struct { + fsm_state_t state; + uint32_t next_heartbeat_ms; + uint32_t next_status_ms; + bool reset_requested; // shit +} eps_comms_t; + + +void eps_comms_init(eps_comms_t *c, uint32_t now_ms); + +// Double checks we're in SAFE after boot +void eps_comms_boot_done(eps_comms_t *c); +void eps_comms_on_frame(eps_comms_t *c, const can_frame_t *rx); +size_t eps_comms_on_tick(eps_comms_t *c, uint32_t now_ms, + const eps_data_t *latest, can_frame_t tx[EPS_COMMS_MAX_TX]); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_EPS_COMMS_H */ \ No newline at end of file From 09e3315f3d52cdc01b9104bda9212446c3a85004 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:38:28 +0100 Subject: [PATCH 14/34] Added EPS control.c/.h imple --- src/EPS/control.c | 33 ++++++++++++++++++++++++++ src/EPS/control.h | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/EPS/control.h diff --git a/src/EPS/control.c b/src/EPS/control.c index e69de29..29153f7 100644 --- a/src/EPS/control.c +++ b/src/EPS/control.c @@ -0,0 +1,33 @@ +#include "control.h" +#include "util_math.h" + +void eps_control_init(eps_control_t *c) +{ + c->ntc = (util_ntc_cfg_t)EPS_NTC_CFG; + util_ema_init(&c->temp_filter, EPS_TEMP_ALPHA); +} + +void eps_data_init(eps_data_t *d) +{ + d->current_ma = UTIL_I16_INVALID; + d->shunt_mv_x100 = UTIL_I16_INVALID; + d->temp_c_x10 = UTIL_I16_INVALID; +} + +void eps_control_step(eps_control_t *c, const eps_raw_t *raw, eps_data_t *out) +{ + eps_data_init(out); + + // Filter isn't needed + if (raw->shunt_ok && util_in_range(raw->shunt_mv, -EPS_SHUNT_MV_MAX, EPS_SHUNT_MV_MAX)) { + out->shunt_mv_x100 = util_to_i16(raw->shunt_mv, 100.0f); + out->current_ma = util_to_i16(raw->shunt_mv / EPS_SHUNT_OHMS, 1.0f); /* mV / ohm = mA */ + } + + if (raw->therm_ok && raw->therm_mv < EPS_ADC_MAX_MV) { + float t = util_ntc_temp_c(&c->ntc, raw->therm_mv); + if (util_in_range(t, EPS_TEMP_MIN_C, EPS_TEMP_MAX_C)) { + out->temp_c_x10 = util_to_i16(util_ema_update(&c->temp_filter, t), 10.0f); + } + } +} \ No newline at end of file diff --git a/src/EPS/control.h b/src/EPS/control.h new file mode 100644 index 0000000..70a7ca9 --- /dev/null +++ b/src/EPS/control.h @@ -0,0 +1,60 @@ +/** + * @file control.h + * @brief turn raw EPS readings into scaled data for CAN + */ +#ifndef HELIA_EPS_CONTROL_H +#define HELIA_EPS_CONTROL_H + +#include +#include +#include "util_filter.h" +#include "util_thermistor.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define EPS_SHUNT_OHMS 0.1f // Resistor value in ohms: shunt voltage / shunt resistance = current in amps (pookie recommended) +#define EPS_SHUNT_MV_MAX 81.9f + +#define EPS_NTC_CFG { .supply_mv = 5000.0f, .r_fixed_ohm = 10000.0f, .r0_ohm = 10000.0f, \ + .t0_c = 25.0f, .beta = 3950.0f, .ntc_on_top = true } + + +#define EPS_ADC_MAX_MV 3000.0f + +#define EPS_TEMP_MIN_C -40.0f +#define EPS_TEMP_MAX_C 125.0f // double check + +#define EPS_TEMP_ALPHA 0.2f + + +typedef struct { + bool shunt_ok; + float shunt_mv; + bool therm_ok; + float therm_mv; +} eps_raw_t; + +typedef struct { + int16_t current_ma; + int16_t shunt_mv_x100; + int16_t temp_c_x10; +} eps_data_t; + +typedef struct { + util_ntc_cfg_t ntc; + util_ema_t temp_filter; +} eps_control_t; + + +void eps_control_init(eps_control_t *c); +void eps_data_init(eps_data_t *d); + +void eps_control_step(eps_control_t *c, const eps_raw_t *raw, eps_data_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_EPS_CONTROL_H */ \ No newline at end of file From f3fd386894fc5cabb8a069bf39bb9d34a3889339 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:38:48 +0100 Subject: [PATCH 15/34] Added EPS's HAL with ESP32 implementation --- src/EPS/HAL/esp32/hal_esp32.c | 139 ++++++++++++++++++++++++++++++++++ src/EPS/HAL/esp32/pins.h | 14 ++++ src/EPS/HAL/hal.h | 35 +++++++++ src/EPS/HAL/sim/hal_sim.c | 50 ++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 src/EPS/HAL/esp32/pins.h create mode 100644 src/EPS/HAL/hal.h diff --git a/src/EPS/HAL/esp32/hal_esp32.c b/src/EPS/HAL/esp32/hal_esp32.c index e69de29..d881816 100644 --- a/src/EPS/HAL/esp32/hal_esp32.c +++ b/src/EPS/HAL/esp32/hal_esp32.c @@ -0,0 +1,139 @@ +// A lot of this is based on Kyane (pookie)'s code for the EPS in Arduino IDE +// Power electronics scares me, so I just copied the code and made it work in ESP-IDF. I don't know what I'm doing, but it works! :3 +#include "hal.h" +#include "pins.h" +#include "can_bus.h" +#include "driver/i2c_master.h" +#include "esp_adc/adc_oneshot.h" +#include "esp_adc/adc_cali.h" +#include "esp_adc/adc_cali_scheme.h" +#include "esp_log.h" +#include "esp_system.h" + +static const char *TAG = "eps_hal"; + +// https://www.ti.com/lit/ds/symlink/ina226.pdf +#define INA_REG_CONFIG 0x00 +#define INA_REG_SHUNT 0x01 /* signed, 2.5 uV per bit */ +#define INA_REG_MFR_ID 0xFE +#define INA_MFR_ID_TI 0x5449 /* "TI" */ + +#define INA_CONFIG_AVG16 0x4527 + +#define I2C_TIMEOUT_MS 20 +#define I2C_SPEED_HZ 100000 + +static i2c_master_bus_handle_t s_bus; +static i2c_master_dev_handle_t s_ina; +static bool s_ina_ok; /* false: skip reads instead of logging an I2C error every 100 ms */ + +static bool ina_read(uint8_t reg, uint16_t *out) { + + uint8_t buf[2]; + if (i2c_master_transmit_receive(s_ina, ®, 1, buf, 2, I2C_TIMEOUT_MS) != ESP_OK) return false; + *out = (uint16_t)((buf[0] << 8) | buf[1]); /* INA226 registers are big-endian ESP32s ain't */ + + return true; +} + +static bool ina_write(uint8_t reg, uint16_t v) { + uint8_t buf[3] = { reg, (uint8_t)(v >> 8), (uint8_t)(v & 0xFF) }; + return i2c_master_transmit(s_ina, buf, sizeof buf, I2C_TIMEOUT_MS) == ESP_OK; +} + +// +static void i2c_scan(void) +{ + int found = 0; + for (uint16_t addr = 0x08; addr < 0x78; addr++) { + if (i2c_master_probe(s_bus, addr, I2C_TIMEOUT_MS) == ESP_OK) { + ESP_LOGW(TAG, "> I2C device answered at 0x%02X", addr); + found++; + } + } + if (found == 0) { + ESP_LOGW(TAG, "> nothing answered: check SDA=%d SCL=%d, don't trust sensor values", + PIN_I2C_SDA, PIN_I2C_SCL); + } +} + +static bool ina_init(void) { + i2c_master_bus_config_t bus_cfg = { + .i2c_port = I2C_NUM_0, + .sda_io_num = PIN_I2C_SDA, + .scl_io_num = PIN_I2C_SCL, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .flags.enable_internal_pullup = true, + }; + i2c_device_config_t dev_cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = INA226_ADDR, + .scl_speed_hz = I2C_SPEED_HZ, + }; + uint16_t id = 0; + + if (i2c_new_master_bus(&bus_cfg, &s_bus) != ESP_OK) return false; + if (i2c_master_bus_add_device(s_bus, &dev_cfg, &s_ina) != ESP_OK) return false; + + if (!ina_read(INA_REG_MFR_ID, &id) || id != INA_MFR_ID_TI) { + ESP_LOGE(TAG, "INA226 not found at 0x%02X (SDA=%d SCL=%d), scanning I2C bus:", + INA226_ADDR, PIN_I2C_SDA, PIN_I2C_SCL); + i2c_scan(); + return false; + } + return ina_write(INA_REG_CONFIG, INA_CONFIG_AVG16); +} + +static adc_oneshot_unit_handle_t s_adc; +static adc_cali_handle_t s_cali; +static adc_channel_t s_chan; + +static bool adc_init(void) +{ + adc_unit_t unit; + if (adc_oneshot_io_to_channel(PIN_THERMISTOR, &unit, &s_chan) != ESP_OK) return false; + + adc_oneshot_unit_init_cfg_t unit_cfg = { .unit_id = unit }; + adc_oneshot_chan_cfg_t chan_cfg = { .atten = ADC_ATTEN_DB_12, .bitwidth = ADC_BITWIDTH_DEFAULT }; + adc_cali_curve_fitting_config_t cali_cfg = { + .unit_id = unit, .chan = s_chan, .atten = ADC_ATTEN_DB_12, .bitwidth = ADC_BITWIDTH_DEFAULT, + }; + + return adc_oneshot_new_unit(&unit_cfg, &s_adc) == ESP_OK && + adc_oneshot_config_channel(s_adc, s_chan, &chan_cfg) == ESP_OK && + adc_cali_create_scheme_curve_fitting(&cali_cfg, &s_cali) == ESP_OK; +} +bool hal_init(void) +{ + bool ok = true; + + if (!can_bus_init(PIN_CAN_TX, PIN_CAN_RX)) { ESP_LOGE(TAG, "CAN init failed"); ok = false; } + s_ina_ok = ina_init(); + if (!s_ina_ok) ok = false; + if (!adc_init()) { ESP_LOGE(TAG, "thermistor ADC init failed"); ok = false; } + return ok; +} + +bool hal_get_shunt_mv(float *out) +{ + uint16_t raw; + if (!s_ina_ok) return false; /* aa */ + if (!ina_read(INA_REG_SHUNT, &raw)) return false; + *out = (float)(int16_t)raw * 0.0025f; /* 2.5 uV per bit */ + return true; +} + +bool hal_get_thermistor_mv(float *out) +{ + int raw, mv; + if (adc_oneshot_read(s_adc, s_chan, &raw) != ESP_OK) return false; + if (adc_cali_raw_to_voltage(s_cali, raw, &mv) != ESP_OK) return false; + *out = (float)mv; + return true; +} + +void hal_restart(void) +{ + esp_restart(); +} \ No newline at end of file diff --git a/src/EPS/HAL/esp32/pins.h b/src/EPS/HAL/esp32/pins.h new file mode 100644 index 0000000..01ed517 --- /dev/null +++ b/src/EPS/HAL/esp32/pins.h @@ -0,0 +1,14 @@ +/** @file pins.h @brief Board wiring from KiCad */ +#ifndef HELIA_EPS_PINS_H +#define HELIA_EPS_PINS_H + +#define PIN_CAN_TX 1 +#define PIN_CAN_RX 2 + +#define PIN_I2C_SDA 9 +#define PIN_I2C_SCL 8 +#define INA226_ADDR 0x40 + +#define PIN_THERMISTOR 13 + +#endif /* HELIA_EPS_PINS_H */ \ No newline at end of file diff --git a/src/EPS/HAL/hal.h b/src/EPS/HAL/hal.h new file mode 100644 index 0000000..498e933 --- /dev/null +++ b/src/EPS/HAL/hal.h @@ -0,0 +1,35 @@ +/** + * @file hal.h + * @brief EPS hardware interface. Implemented by HAL/esp32/hal_esp32.c on the + * board and HAL/sim/hal_sim.c on a laptop; the build links one of them. + * + * Functions return raw physical measurements. Turning those into current + * and temperature is done in control.c, where it can be unit-tested. + */ +#ifndef HELIA_EPS_HAL_H +#define HELIA_EPS_HAL_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Bring up I2C, the INA226, the thermistor ADC and the CAN bus. + * @return false if any of them failed (the node keeps running regardless). */ +bool hal_init(void); + +/** @brief Voltage across the input-bus shunt resistor, in mV (signed). False if the INA226 didn't answer. */ +bool hal_get_shunt_mv(float *out); + +/** @brief Voltage at the PCB thermistor's ADC pin, in mV. False if the ADC read failed. */ +bool hal_get_thermistor_mv(float *out); + +/** @brief Reboot the node (CMD_RESET_*). Does not return. */ +void hal_restart(void); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_EPS_HAL_H */ \ No newline at end of file diff --git a/src/EPS/HAL/sim/hal_sim.c b/src/EPS/HAL/sim/hal_sim.c index e69de29..215a439 100644 --- a/src/EPS/HAL/sim/hal_sim.c +++ b/src/EPS/HAL/sim/hal_sim.c @@ -0,0 +1,50 @@ +/* EPS HAL for running on a laptop: plausible readings with a little wobble, + * so everything above the HAL behaves as it would on the board. */ +#include "hal.h" +#include "control.h" /* EPS_NTC_CFG, so the sim always matches the real divider */ +#include +#include +#include + +#define SIM_SHUNT_MV 35.0f /* ~350 mA through 0.1 ohm */ +#define SIM_PCB_TEMP_C 30.0f + +static unsigned s_tick; + +static float wobble(void) /* repeating -0.5% .. +0.5% */ +{ + return (float)(s_tick++ % 11u) * 0.001f - 0.005f; +} + +/* The divider voltage the real circuit would produce at temp_c */ +static float divider_mv(float temp_c) +{ + const util_ntc_cfg_t c = EPS_NTC_CFG; + float r_ntc = c.r0_ohm * expf(c.beta * (1.0f / (temp_c + 273.15f) - 1.0f / (c.t0_c + 273.15f))); + return c.ntc_on_top ? c.supply_mv * c.r_fixed_ohm / (r_ntc + c.r_fixed_ohm) + : c.supply_mv * r_ntc / (r_ntc + c.r_fixed_ohm); +} + +bool hal_init(void) +{ + /* The simulated CAN bus is added with the simulator step. */ + return true; +} + +bool hal_get_shunt_mv(float *out) +{ + *out = SIM_SHUNT_MV * (1.0f + wobble()); + return true; +} + +bool hal_get_thermistor_mv(float *out) +{ + *out = divider_mv(SIM_PCB_TEMP_C) * (1.0f + wobble()); + return true; +} + +void hal_restart(void) +{ + printf("[sim] restart requested\n"); + exit(0); +} \ No newline at end of file From ad8b34c665933ddb475574fa6c5d03db3b437ffd Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:38:56 +0100 Subject: [PATCH 16/34] Setting up instrumentation --- src/Instrumentation/setup.c | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/Instrumentation/setup.c b/src/Instrumentation/setup.c index e69de29..e8b0c71 100644 --- a/src/Instrumentation/setup.c +++ b/src/Instrumentation/setup.c @@ -0,0 +1,59 @@ +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" + +#include "hal.h" +#include "can_bus.h" +#include "comms.h" +#include "control.h" + +static const char *TAG = "instr"; + +#define RX_TIMEOUT_MS 20u +#define TX_TIMEOUT_MS 10u + +static uint32_t now_ms(void) +{ + return (uint32_t)pdTICKS_TO_MS(xTaskGetTickCount()); +} + +static void comms_task(void *arg) +{ + (void)arg; + instr_comms_t comms; + uint32_t last_status_log_ms = 0; + instr_comms_init(&comms, now_ms()); + instr_comms_boot_done(&comms); /* INIT -> SAFE */ + ESP_LOGI(TAG, "state %s", fsm_state_name(comms.state)); + + for (;;) { + can_frame_t rx, tx[INSTR_COMMS_MAX_TX]; + fsm_state_t before = comms.state; + + if (can_bus_recv(&rx, RX_TIMEOUT_MS)) instr_comms_on_frame(&comms, &rx); + + size_t n = instr_comms_on_tick(&comms, now_ms(), tx); + for (size_t i = 0; i < n; i++) { + if (!can_bus_send(&tx[i], TX_TIMEOUT_MS)) { + ESP_LOGW(TAG, "send failed, id=0x%03lX", (unsigned long)tx[i].id); + if (now_ms() - last_status_log_ms >= 2000u) { + can_bus_log_status(); + last_status_log_ms = now_ms(); + } + } + } + + if (comms.state != before) ESP_LOGI(TAG, "state %s -> %s", fsm_state_name(before), fsm_state_name(comms.state)); + if (comms.reset_requested) hal_restart(); + can_bus_poll_recovery(); + } +} + +void app_main(void) +{ + ESP_LOGI(TAG, "boot"); + if (!hal_init()) ESP_LOGE(TAG, "CAN init failed"); + + xTaskCreatePinnedToCore(comms_task, "instr_comms", 4096, NULL, 10, NULL, 0); + xTaskCreatePinnedToCore(instr_control_task, "instr_ctrl", 4096, NULL, 5, NULL, 1); +} \ No newline at end of file From df73d02b218fb8ef00e5aa56722e981d4189c9a4 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:39:11 +0100 Subject: [PATCH 17/34] Added contro.c./.h for instrumentaion --- src/Instrumentation/control.c | 117 ++++++++++++++++++++++++++++++++++ src/Instrumentation/control.h | 21 ++++++ 2 files changed, 138 insertions(+) create mode 100644 src/Instrumentation/control.h diff --git a/src/Instrumentation/control.c b/src/Instrumentation/control.c index e69de29..48daa8a 100644 --- a/src/Instrumentation/control.c +++ b/src/Instrumentation/control.c @@ -0,0 +1,117 @@ +#include "control.h" + +#ifdef ESP_PLATFORM +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" + +#include "hal.h" +#include "can_bus.h" +#include "can_instr.h" +#include "util_timer.h" + +static const char *TAG = "instr_ctrl"; + +#define TX_TIMEOUT_MS 10u +#define POLL_PERIOD_MS 1000u + +/* TODO(remy): update this to be an accurate atmospheric reference */ +#define SEA_LEVEL_MBAR 1013.25f + +/* + * This entire file needs debugging and testing, some sensors are missing from the original set up and can be removed too. + * TODO(remy): do that +*/ + +static uint32_t now_ms(void) +{ + return (uint32_t)pdTICKS_TO_MS(xTaskGetTickCount()); +} + +static void send(const can_frame_t *f, const char *what) +{ + if (!can_bus_send(f, TX_TIMEOUT_MS)) { + ESP_LOGW(TAG, "%s send failed, id=0x%03lX", what, (unsigned long)f->id); + } +} + +void instr_control_task(void *arg) +{ + (void)arg; + + uint32_t next_poll_ms = now_ms(); + uint32_t next_summary_ms = now_ms(); + + ESP_LOGI(TAG, "control core up"); + + for (;;) { + if (util_every(now_ms(), &next_poll_ms, POLL_PERIOD_MS)) { + can_frame_t f; + bool sent_baro = false, sent_sht = false, sent_gas = false, sent_imu = false; + uint8_t status = 0; + + hal_ms5611_t baro = hal_read_ms5611(); + int16_t board_temp_c_x10 = 0; + if (baro.ok) { + sent_baro = true; + status |= CAN_INSTR_STATUS_BARO; + board_temp_c_x10 = baro.temp_c_x10; + float pressure_mbar = baro.pressure_mbar_x10 / 10.0f; + float altitude_m = 44330.0f * (1.0f - powf(pressure_mbar / SEA_LEVEL_MBAR, 1.0f / 5.255f)); + + can_instr_housekeeping_t hk = { + .pressure_mbar_x10 = baro.pressure_mbar_x10, + .altitude_m_x10 = (int32_t)lroundf(altitude_m * 10.0f), + }; + can_encode_housekeeping(&f, &hk); + send(&f, "housekeeping"); + } + + hal_sht45_t sht = hal_read_sht45(); + if (sht.ok) { + sent_sht = true; + status |= CAN_INSTR_STATUS_SHT; + can_instr_humidity_t h = { .temp_c_x10 = sht.temp_c_x10, .rh_pct_x10 = sht.rh_pct_x10 }; + can_encode_humidity(&f, &h); + send(&f, "humidity"); + } + + hal_scd41_t gas = hal_read_scd41(); + if (gas.ok) { + sent_gas = true; + status |= CAN_INSTR_STATUS_GAS; + can_instr_gas_t g = { .co2_ppm = gas.co2_ppm, .temp_c_x10 = gas.temp_c_x10, .rh_pct_x10 = gas.rh_pct_x10 }; + can_encode_gas(&f, &g); + send(&f, "gas"); + } + + hal_imu_t imu = hal_read_imu(); + if (imu.ok) { + sent_imu = true; + status |= CAN_INSTR_STATUS_IMU; + can_instr_accel_t a = { .ax_mmss = imu.ax_mmss, .ay_mmss = imu.ay_mmss, .az_mmss = imu.az_mmss }; + can_encode_accel(&f, &a); + send(&f, "accel"); + } + hal_ads1115_t ads = hal_read_ads1115(); + if (ads.ok) status |= CAN_INSTR_STATUS_ADS; + + can_instr_status_t st = { .sensor_status = status, .board_temp_c_x10 = board_temp_c_x10 }; + can_encode_status(&f, &st); + send(&f, "status"); + + if (util_every(now_ms(), &next_summary_ms, 5000u)) { + + // Log a summary every 5 seconds, useful for debugging, might remove later + ESP_LOGI(TAG, "sensors: baro=%s sht=%s gas=%s imu=%s ads=%s", + sent_baro ? "ok" : "-", sent_sht ? "ok" : "-", + sent_gas ? "ok" : "-", sent_imu ? "ok" : "-", + (status & CAN_INSTR_STATUS_ADS) ? "ok" : "-"); + } + } + + vTaskDelay(pdMS_TO_TICKS(50)); + } +} +#endif /* ESP_PLATFORM */ diff --git a/src/Instrumentation/control.h b/src/Instrumentation/control.h new file mode 100644 index 0000000..1a5613c --- /dev/null +++ b/src/Instrumentation/control.h @@ -0,0 +1,21 @@ +/** + * @file control.h + * @brief Polls the five I2C sensors and sends their readings onto CAN + */ +#ifndef HELIA_INSTRUMENTATION_CONTROL_H +#define HELIA_INSTRUMENTATION_CONTROL_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Pinned to core 1 by setup.c. ESP32-only (see control.c) - + * compiles to nothing on the host, same as every other node's + * control.c. */ +void instr_control_task(void *arg); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_INSTRUMENTATION_CONTROL_H */ \ No newline at end of file From 5fe5c8c2e7ac6804181714d2dca1f2a5adc0e4ba Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:39:26 +0100 Subject: [PATCH 18/34] Updated comms for instrumentation --- src/Instrumentation/comm.c | 0 src/Instrumentation/comms.c | 33 +++++++++++++++++++++++++++++++++ src/Instrumentation/comms.h | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) delete mode 100644 src/Instrumentation/comm.c create mode 100644 src/Instrumentation/comms.c create mode 100644 src/Instrumentation/comms.h diff --git a/src/Instrumentation/comm.c b/src/Instrumentation/comm.c deleted file mode 100644 index e69de29..0000000 diff --git a/src/Instrumentation/comms.c b/src/Instrumentation/comms.c new file mode 100644 index 0000000..c38517c --- /dev/null +++ b/src/Instrumentation/comms.c @@ -0,0 +1,33 @@ +#include "comms.h" +#include "util_timer.h" + +#define ME CAN_SRC_INSTRUMENTATION + +void instr_comms_init(instr_comms_t *c, uint32_t now_ms) { + c->state = FSM_INIT; + c->next_heartbeat_ms = now_ms; + c->reset_requested = false; +} + +void instr_comms_boot_done(instr_comms_t *c) { + c->state = fsm_next(c->state, FSM_EVT_INIT_OK); +} + +void instr_comms_on_frame(instr_comms_t *c, const can_frame_t *rx) { + switch (can_decode_sys_cmd(rx, ME)) { + case CAN_SYS_UNLOCK: c->state = fsm_next(c->state, FSM_EVT_UNLOCK); break; + case CAN_SYS_SLEEP: c->state = fsm_next(c->state, FSM_EVT_SLEEP); break; + case CAN_SYS_RESET: c->reset_requested = true; break; + + // SKIP SKIP When does this game get good + case CAN_SYS_NONE: break; + } +} + +size_t instr_comms_on_tick(instr_comms_t *c, uint32_t now_ms, can_frame_t tx[INSTR_COMMS_MAX_TX]) { + if (!util_every(now_ms, &c->next_heartbeat_ms, CAN_HEARTBEAT_PERIOD_MS)) return 0; + + can_heartbeat_t hb = { ME, (uint8_t)c->state, now_ms / 1000u }; + can_encode_heartbeat(&tx[0], &hb); + return 1; +} \ No newline at end of file diff --git a/src/Instrumentation/comms.h b/src/Instrumentation/comms.h new file mode 100644 index 0000000..96e23f6 --- /dev/null +++ b/src/Instrumentation/comms.h @@ -0,0 +1,36 @@ +/** + * @file comms.h + * @brief for now only the lifecycle (every node has (heartbeat, unlock/sleep/reset) + */ +#ifndef HELIA_INSTRUMENTATION_COMMS_H +#define HELIA_INSTRUMENTATION_COMMS_H + +#include +#include +#include +#include "can.h" +#include "fsm.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define INSTR_COMMS_MAX_TX 1 + +typedef struct { + fsm_state_t state; + uint32_t next_heartbeat_ms; + bool reset_requested; +} instr_comms_t; + + +void instr_comms_init(instr_comms_t *c, uint32_t now_ms); +void instr_comms_boot_done(instr_comms_t *c); +void instr_comms_on_frame(instr_comms_t *c, const can_frame_t *rx); +size_t instr_comms_on_tick(instr_comms_t *c, uint32_t now_ms, can_frame_t tx[INSTR_COMMS_MAX_TX]); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_INSTRUMENTATION_COMMS_H */ \ No newline at end of file From ced0912d651757bbe52a5cb4e25760627694a9d6 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:39:34 +0100 Subject: [PATCH 19/34] instrumentation pins --- src/Instrumentation/HAL/esp32/pins.h | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/Instrumentation/HAL/esp32/pins.h diff --git a/src/Instrumentation/HAL/esp32/pins.h b/src/Instrumentation/HAL/esp32/pins.h new file mode 100644 index 0000000..81da347 --- /dev/null +++ b/src/Instrumentation/HAL/esp32/pins.h @@ -0,0 +1,10 @@ +/** @file pins.h @brief Instrumentation board wiring. Only the ESP32 HAL includes this. */ +#ifndef HELIA_INSTRUMENTATION_PINS_H +#define HELIA_INSTRUMENTATION_PINS_H + +/* From the old platformio.ini. TODO: confirm against the real board + * (remember EPS's KiCad didn't match its wiring). */ +#define PIN_CAN_TX 1 +#define PIN_CAN_RX 2 + +#endif /* HELIA_INSTRUMENTATION_PINS_H */ \ No newline at end of file From 585633f0ef75ee096763bdbf61deb0b29a64e886 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:39:47 +0100 Subject: [PATCH 20/34] HAL implemented for instrumentation (ESP32) --- src/Instrumentation/HAL/esp32/hal_esp32.c | 408 ++++++++++++++++++++++ src/Instrumentation/HAL/hal.h | 70 ++++ src/Instrumentation/HAL/sim/hal_sim.c | 16 + 3 files changed, 494 insertions(+) create mode 100644 src/Instrumentation/HAL/hal.h diff --git a/src/Instrumentation/HAL/esp32/hal_esp32.c b/src/Instrumentation/HAL/esp32/hal_esp32.c index e69de29..bb48da6 100644 --- a/src/Instrumentation/HAL/esp32/hal_esp32.c +++ b/src/Instrumentation/HAL/esp32/hal_esp32.c @@ -0,0 +1,408 @@ +/* + * ESP32 HAL instrumentation + * Fair warning: this is a bit of a mess, some sensors work and others dont + * it needs a proper debugging session to iron out issues, might delegate it to an intern >:) +*/ +#include "hal.h" + +#include +#include +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/i2c.h" + +#include "can_bus.h" +#include "pins.h" + +static const char *TAG = "instr_hal"; + +// I've double checked these should be right, might move to pins.h later? +#define SDA_GPIO 8 +#define SCL_GPIO 9 +#define I2C_HZ 100000 +#define I2C_PORT I2C_NUM_0 + +#define ADDR_SHT45 0x44 +#define ADDR_ADS1115 0x48 +#define ADDR_FSP201 0x4C +#define ADDR_SCD41 0x62 +#define ADDR_MS5611_A 0x76 +#define ADDR_MS5611_B 0x77 + +static bool s_ok_sht45, s_ok_ads1115, s_ok_imu, s_ok_scd41, s_ok_ms5611; +static uint8_t s_ms5611_addr; +static uint16_t s_ms5611_c[6]; + +static hal_imu_t s_imu_latest; + + +static bool i2c_probe(uint8_t addr) { + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + i2c_master_start(cmd); + i2c_master_write_byte(cmd, (uint8_t)((addr << 1) | I2C_MASTER_WRITE), true); + i2c_master_stop(cmd); + esp_err_t err = i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(50)); + i2c_cmd_link_delete(cmd); + return err == ESP_OK; +} + +static bool i2c_write(uint8_t addr, const uint8_t *data, size_t len) { + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + i2c_master_start(cmd); + i2c_master_write_byte(cmd, (uint8_t)((addr << 1) | I2C_MASTER_WRITE), true); + if (len) i2c_master_write(cmd, (uint8_t *)data, len, true); + i2c_master_stop(cmd); + + esp_err_t err = i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(100)); + i2c_cmd_link_delete(cmd); + + return err == ESP_OK; +} + +static bool i2c_read(uint8_t addr, uint8_t *data, size_t len) { + if (len == 0) return true; + + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + i2c_master_start(cmd); + i2c_master_write_byte(cmd, (uint8_t)((addr << 1) | I2C_MASTER_READ), true); + + if (len > 1) i2c_master_read(cmd, data, len - 1, I2C_MASTER_ACK); + i2c_master_read_byte(cmd, data + len - 1, I2C_MASTER_NACK); + i2c_master_stop(cmd); + + esp_err_t err = i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(100)); + i2c_cmd_link_delete(cmd); + + return err == ESP_OK; +} + + +static bool i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wr_len, uint8_t *rd, size_t rd_len) { + + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + i2c_master_start(cmd); + i2c_master_write_byte(cmd, (uint8_t)((addr << 1) | I2C_MASTER_WRITE), true); + if (wr_len) i2c_master_write(cmd, (uint8_t *)wr, wr_len, true); + i2c_master_start(cmd); + i2c_master_write_byte(cmd, (uint8_t)((addr << 1) | I2C_MASTER_READ), true); + + if (rd_len > 1) i2c_master_read(cmd, rd, rd_len - 1, I2C_MASTER_ACK); + i2c_master_read_byte(cmd, rd + rd_len - 1, I2C_MASTER_NACK); + i2c_master_stop(cmd); + + esp_err_t err = i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(100)); + i2c_cmd_link_delete(cmd); + + return err == ESP_OK; +} + +static uint8_t sensirion_crc8(const uint8_t *data, size_t len) { + uint8_t crc = 0xFF; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int b = 0; b < 8; b++) { + crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1); + } + } + return crc; +} + + + +hal_sht45_t hal_read_sht45(void) { + hal_sht45_t r = {0}; + if (!s_ok_sht45) return r; + + uint8_t cmd = 0xFD; + if (!i2c_write(ADDR_SHT45, &cmd, 1)) return r; + vTaskDelay(pdMS_TO_TICKS(10)); / + + uint8_t buf[6]; + if (!i2c_read(ADDR_SHT45, buf, sizeof buf)) return r; + if (sensirion_crc8(&buf[0], 2) != buf[2] || sensirion_crc8(&buf[3], 2) != buf[5]) { + ESP_LOGW(TAG, "SHT45 CRC mismatch"); + return r; + } + + uint16_t raw_t = ((uint16_t)buf[0] << 8) | buf[1]; + uint16_t raw_rh = ((uint16_t)buf[3] << 8) | buf[4]; + float temp_c = -45.0f + 175.0f * ((float)raw_t / 65535.0f); + float rh_pct = -6.0f + 125.0f * ((float)raw_rh / 65535.0f); + if (rh_pct < 0.0f) rh_pct = 0.0f; + if (rh_pct > 100.0f) rh_pct = 100.0f; + + r.ok = true; + r.temp_c_x10 = (int16_t)lroundf(temp_c * 10.0f); + r.rh_pct_x10 = (int16_t)lroundf(rh_pct * 10.0f); + + return r; +} + +hal_ads1115_t hal_read_ads1115(void) { + hal_ads1115_t r = {0}; + if (!s_ok_ads1115) return r; + r.ok = true; + + for (int ch = 0; ch < 4; ch++) { + uint16_t mux = (uint16_t)(0x4 + ch); + uint16_t config = (uint16_t)( + (1u << 15) | (mux << 12) | (0x1u << 9) | (1u << 8) | (0x4u << 5) | 0x3u); + uint8_t wr[3] = {0x01, (uint8_t)(config >> 8), (uint8_t)(config & 0xFF)}; + if (!i2c_write(ADDR_ADS1115, wr, sizeof wr)) { + r.ok = false; + continue; + } + vTaskDelay(pdMS_TO_TICKS(10)); + + uint8_t ptr = 0x00, rd[2]; + if (!i2c_write_read(ADDR_ADS1115, &ptr, 1, rd, sizeof rd)) { + r.ok = false; + continue; + } + int16_t raw = (int16_t)(((uint16_t)rd[0] << 8) | rd[1]); + r.mv[ch] = (int16_t)lroundf(raw * 0.125f); + } + return r; +} + + +static bool ms5611_read_adc(uint8_t convert_cmd, uint32_t *out) { + // TODO(remy): double check with Chrysler +} + +hal_ms5611_t hal_read_ms5611(void) { + // TODO +} + +static bool scd41_send_cmd(uint16_t cmd) +{ + uint8_t wr[2] = {(uint8_t)(cmd >> 8), (uint8_t)(cmd & 0xFF)}; + return i2c_write(ADDR_SCD41, wr, sizeof wr); +} + +hal_scd41_t hal_read_scd41(void) +{ + hal_scd41_t r = {0}; + if (!s_ok_scd41) return r; + + if (!scd41_send_cmd(0xE4B8)) return r; /* get_data_ready_status */ + vTaskDelay(pdMS_TO_TICKS(2)); + uint8_t rdy[3]; + if (!i2c_read(ADDR_SCD41, rdy, sizeof rdy)) return r; + uint16_t status = ((uint16_t)rdy[0] << 8) | rdy[1]; + if ((status & 0x07FFu) == 0) return r; /* not ready yet - not an error */ + + if (!scd41_send_cmd(0xEC05)) return r; /* read_measurement */ + vTaskDelay(pdMS_TO_TICKS(2)); + uint8_t buf[9]; + if (!i2c_read(ADDR_SCD41, buf, sizeof buf)) return r; + if (sensirion_crc8(&buf[0], 2) != buf[2] || + sensirion_crc8(&buf[3], 2) != buf[5] || + sensirion_crc8(&buf[6], 2) != buf[8]) { + ESP_LOGW(TAG, "SCD41 CRC mismatch"); + return r; + } + + uint16_t co2 = ((uint16_t)buf[0] << 8) | buf[1]; + uint16_t raw_t = ((uint16_t)buf[3] << 8) | buf[4]; + uint16_t raw_rh = ((uint16_t)buf[6] << 8) | buf[7]; + float temp_c = -45.0f + 175.0f * ((float)raw_t / 65535.0f); + float rh_pct = 100.0f * ((float)raw_rh / 65535.0f); + + r.ok = true; + r.co2_ppm = co2; + r.temp_c_x10 = (int16_t)lroundf(temp_c * 10.0f); + r.rh_pct_x10 = (int16_t)lroundf(rh_pct * 10.0f); + return r; +} + +/* ---- BNO08x (IMU, accelerometer only) - minimal SHTP+SH2, see file header + * caveat. Channel 2 = control (used to enable the report), channel 3 = + * input sensor reports (polled for the report itself). ------------------- */ + +#define SHTP_MAX_PACKET 64 +#define SH2_CHAN_CONTROL 2 +#define SH2_CHAN_INPUT 3 +#define SH2_REPORT_SET_FEATURE 0xFD +#define SH2_SENSOR_ACCELEROMETER 0x01 + +static bool imu_send_set_feature(uint8_t report_id, uint32_t interval_us) +{ + uint8_t payload[17] = {0}; + payload[0] = SH2_REPORT_SET_FEATURE; + payload[1] = report_id; + payload[5] = (uint8_t)(interval_us & 0xFF); + payload[6] = (uint8_t)((interval_us >> 8) & 0xFF); + payload[7] = (uint8_t)((interval_us >> 16) & 0xFF); + payload[8] = (uint8_t)((interval_us >> 24) & 0xFF); + + uint16_t total = 4 + sizeof payload; + uint8_t frame[4 + sizeof payload]; + frame[0] = (uint8_t)(total & 0xFF); + frame[1] = (uint8_t)((total >> 8) & 0x7F); + frame[2] = SH2_CHAN_CONTROL; + frame[3] = 0; /* sequence number - not tracked, best-effort */ + memcpy(&frame[4], payload, sizeof payload); + + return i2c_write(ADDR_FSP201, frame, sizeof frame); +} + +/* Diagnostic-only: dump the first few raw SHTP packets so we can see what + * the BNO08x is actually sending, since the accelerometer report is not + * yet being successfully parsed (imu=- in the periodic summary) despite + * the enable-report write succeeding. Remove once hal_poll_imu() is + * confirmed working against real hardware. */ +static int s_imu_dump_budget = 8; + +static void imu_dump_packet(const uint8_t *buf, uint16_t len, uint8_t channel) +{ + if (s_imu_dump_budget <= 0) return; + s_imu_dump_budget--; + + char hex[3 * 20 + 1] = {0}; + int n = (len < 20) ? len : 20; + for (int i = 0; i < n; i++) { + snprintf(&hex[i * 3], 4, "%02X ", buf[i]); + } + ESP_LOGI(TAG, "IMU pkt: len=%u chan=%u bytes=%s", len, channel, hex); +} + +static void hal_poll_imu(void) +{ + if (!s_ok_imu) return; + + uint8_t buf[SHTP_MAX_PACKET]; + if (!i2c_read(ADDR_FSP201, buf, sizeof buf)) return; + + uint16_t len = (uint16_t)(buf[0] | ((buf[1] & 0x7Fu) << 8)); + if (len < 4 || len > sizeof buf) { + if (len != 0) imu_dump_packet(buf, sizeof buf, buf[2]); /* garbage/oversized - still worth seeing */ + return; /* nothing pending / garbage */ + } + uint8_t channel = buf[2]; + imu_dump_packet(buf, len, channel); + if (channel != SH2_CHAN_INPUT) return; + + for (uint16_t i = 4; i + 10 <= len; i++) { + if (buf[i] != SH2_SENSOR_ACCELEROMETER) continue; + int16_t x = (int16_t)(buf[i + 4] | (buf[i + 5] << 8)); + int16_t y = (int16_t)(buf[i + 6] | (buf[i + 7] << 8)); + int16_t z = (int16_t)(buf[i + 8] | (buf[i + 9] << 8)); + s_imu_latest.ok = true; + s_imu_latest.ax_mmss = (int16_t)lroundf(x * 3.90625f); /* Q8: raw/256 m/s^2 -> mm/s^2 */ + s_imu_latest.ay_mmss = (int16_t)lroundf(y * 3.90625f); + s_imu_latest.az_mmss = (int16_t)lroundf(z * 3.90625f); + break; + } +} + +hal_imu_t hal_read_imu(void) +{ + hal_poll_imu(); + return s_imu_latest; +} + +/* ---- init / restart ------------------------------------------------------ */ + +bool hal_init(void) +{ + if (!can_bus_init(PIN_CAN_TX, PIN_CAN_RX)) return false; + + i2c_config_t conf = { + .mode = I2C_MODE_MASTER, + .sda_io_num = SDA_GPIO, + .scl_io_num = SCL_GPIO, + .sda_pullup_en = GPIO_PULLUP_DISABLE, /* breakouts have their own external pull-ups */ + .scl_pullup_en = GPIO_PULLUP_DISABLE, + .master.clk_speed = I2C_HZ, + }; + if (i2c_param_config(I2C_PORT, &conf) != ESP_OK || + i2c_driver_install(I2C_PORT, conf.mode, 0, 0, 0) != ESP_OK) { + ESP_LOGE(TAG, "I2C bus init failed - sensors unavailable this boot"); + return true; /* CAN is up, which is what's fatal */ + } + + /* SCD41 needs >=1s after power-up before it will ACK on the bus at all + * (Sensirion datasheet). Everything else responds immediately, but this + * delay is cheap and shared across all sensors, so pay it once here + * rather than special-casing SCD41's probe. */ + vTaskDelay(pdMS_TO_TICKS(1000)); + + { + int found = 0; + ESP_LOGI(TAG, "I2C scan:"); + for (uint8_t addr = 1; addr < 127; addr++) { + if (i2c_probe(addr)) { + ESP_LOGI(TAG, " found 0x%02X", addr); + found++; + } + } + if (found == 0) { + ESP_LOGW(TAG, " nothing found - check wiring/pull-ups/SDA=%d,SCL=%d before trusting the per-sensor results below", SDA_GPIO, SCL_GPIO); + } + } + + s_ok_sht45 = i2c_probe(ADDR_SHT45); + s_ok_ads1115 = i2c_probe(ADDR_ADS1115); + s_ok_imu = i2c_probe(ADDR_FSP201); + s_ok_scd41 = i2c_probe(ADDR_SCD41); + + /* MS5611's address depends on how its CSB pin is wired (0x76 if tied + * high, 0x77 if tied low) - try both rather than assuming. */ + if (i2c_probe(ADDR_MS5611_A)) { + s_ms5611_addr = ADDR_MS5611_A; + s_ok_ms5611 = true; + } else if (i2c_probe(ADDR_MS5611_B)) { + s_ms5611_addr = ADDR_MS5611_B; + s_ok_ms5611 = true; + } else { + s_ok_ms5611 = false; + } + + if (s_ok_scd41) { + scd41_send_cmd(0x3F86); /* stop_periodic_measurement, in case it was left running */ + vTaskDelay(pdMS_TO_TICKS(500)); + scd41_send_cmd(0x3646); /* reinit */ + vTaskDelay(pdMS_TO_TICKS(30)); + s_ok_scd41 = scd41_send_cmd(0x21B1); /* start_periodic_measurement */ + } + + if (s_ok_ms5611) { + uint8_t reset = 0x1E; + i2c_write(s_ms5611_addr, &reset, 1); + vTaskDelay(pdMS_TO_TICKS(3)); + for (int i = 0; i < 6; i++) { + uint8_t cmd = (uint8_t)(0xA2 + i * 2); + uint8_t rd[2]; + if (!i2c_write_read(s_ms5611_addr, &cmd, 1, rd, sizeof rd)) { + s_ok_ms5611 = false; + break; + } + s_ms5611_c[i] = (uint16_t)(((uint16_t)rd[0] << 8) | rd[1]); + } + } + + if (s_ok_imu) { + vTaskDelay(pdMS_TO_TICKS(100)); /* let the reset/advertisement packets settle */ + s_ok_imu = imu_send_set_feature(SH2_SENSOR_ACCELEROMETER, 100000 /* 10 Hz */); + } + + ESP_LOGI(TAG, "SHT45 %s", s_ok_sht45 ? "OK" : "NOT FOUND"); + ESP_LOGI(TAG, "ADS1115 %s", s_ok_ads1115 ? "OK" : "NOT FOUND"); + ESP_LOGI(TAG, "SCD41 %s", s_ok_scd41 ? "OK" : "NOT FOUND"); + if (s_ok_ms5611) { + ESP_LOGI(TAG, "MS5611 OK (0x%02X)", s_ms5611_addr); + } else { + ESP_LOGI(TAG, "MS5611 NOT FOUND"); + } + ESP_LOGI(TAG, "FSP201 %s", s_ok_imu ? "OK" : "NOT FOUND"); + + return true; +} + +void hal_restart(void) +{ + esp_restart(); +} \ No newline at end of file diff --git a/src/Instrumentation/HAL/hal.h b/src/Instrumentation/HAL/hal.h new file mode 100644 index 0000000..5991455 --- /dev/null +++ b/src/Instrumentation/HAL/hal.h @@ -0,0 +1,70 @@ +/** + * @file hal.h + * @brief Instrumentation hardware interface: CAN bus plus the five I2C + * sensors on the board: + * - SHT45 (temp/humidity) + * - ADS1115 (4-channel ADC) + * - SCD41 (CO2) + * - MS5611 (pressure) + * - FSP201 (accelerometer) + * + * + */ +#ifndef HELIA_INSTRUMENTATION_HAL_H +#define HELIA_INSTRUMENTATION_HAL_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + + +bool hal_init(void); +void hal_restart(void); + +typedef struct { + bool ok; + int16_t temp_c_x10; + int16_t rh_pct_x10; +} hal_sht45_t; + +typedef struct { + bool ok; + int16_t mv[4]; +} hal_ads1115_t; + +typedef struct { + bool ok; + uint16_t co2_ppm; + int16_t temp_c_x10; + int16_t rh_pct_x10; +} hal_scd41_t; + +typedef struct { + bool ok; + int32_t pressure_mbar_x10; + int16_t temp_c_x10; +} hal_ms5611_t; + +typedef struct { + bool ok; + int16_t ax_mmss; + int16_t ay_mmss; + int16_t az_mmss; +} hal_imu_t; + +// Multiple of these don't work, needs debugging, but the code works for now +hal_sht45_t hal_read_sht45(void); +hal_ads1115_t hal_read_ads1115(void); +hal_scd41_t hal_read_scd41(void); +hal_ms5611_t hal_read_ms5611(void); // Not working + +hal_imu_t hal_read_imu(void); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_INSTRUMENTATION_HAL_H */ \ No newline at end of file diff --git a/src/Instrumentation/HAL/sim/hal_sim.c b/src/Instrumentation/HAL/sim/hal_sim.c index e69de29..a9c3977 100644 --- a/src/Instrumentation/HAL/sim/hal_sim.c +++ b/src/Instrumentation/HAL/sim/hal_sim.c @@ -0,0 +1,16 @@ +/* Instrumentation HAL for running on a laptop. */ +#include "hal.h" +#include +#include + +bool hal_init(void) +{ + /* The simulated CAN bus is added with the simulator step. */ + return true; +} + +void hal_restart(void) +{ + printf("[sim] restart requested\n"); + exit(0); +} \ No newline at end of file From 92c2e41beba0e7188ddd4fcd6090416b87b92c6d Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:40:14 +0100 Subject: [PATCH 21/34] base HAL file added for thermals (awaiting on implementation) --- src/Thermals/HAL/hal.h | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/Thermals/HAL/hal.h diff --git a/src/Thermals/HAL/hal.h b/src/Thermals/HAL/hal.h new file mode 100644 index 0000000..e69de29 From cb856692329bd2e9a1bce387f59552587bd87af5 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:40:33 +0100 Subject: [PATCH 22/34] Set up Photonics HAL for basic implementation --- src/Photonics/HAL/esp32/hal_esp32.c | 272 ++++++++++++++++++++++++++++ src/Photonics/HAL/hal.h | 45 +++++ src/Photonics/HAL/sim/hal_sim.c | 1 + 3 files changed, 318 insertions(+) create mode 100644 src/Photonics/HAL/hal.h diff --git a/src/Photonics/HAL/esp32/hal_esp32.c b/src/Photonics/HAL/esp32/hal_esp32.c index e69de29..c8ca1e3 100644 --- a/src/Photonics/HAL/esp32/hal_esp32.c +++ b/src/Photonics/HAL/esp32/hal_esp32.c @@ -0,0 +1,272 @@ +/* + +*/ +#include "hal.h" + +#include +#include +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +// Driver docs: https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/peripherals/i2c.html#_CPPv4N3i2c12i2c_master_cmd_beginEii +#include "driver/i2c.h" + +#include "can_bus.h" +#include "pins.h" + +static const char *TAG = "photonics_hal"; + +#define SDA_GPIO 8 +#define SCL_GPIO 9 +#define I2C_HZ 100000 +#define I2C_PORT I2C_NUM_0 + +#define ADDR_MUX 0x70 // Check data sheet for TCA9548A, this is the default address with A0,A1,A2 = GND +#define ADDR_LTR390 0x53 +#define ADDR_ADS1115 0x48 + +static const uint8_t UV_MUX_CHANNEL[PHOTONICS_NUM_UV] = {0, 1}; + +static bool s_mux_ok; + +static bool s_uv_ok[PHOTONICS_NUM_UV]; +static hal_uv_t s_uv_latest[PHOTONICS_NUM_UV]; + +typedef enum { LTR390_MODE_ALS, LTR390_MODE_UVS } ltr390_mode_t; +static ltr390_mode_t s_uv_mode[PHOTONICS_NUM_UV]; + +static bool s_adc_ok; // true if the onboard ADS1115 was found at init, useful for debugging + +static bool i2c_probe(uint8_t addr) { + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + i2c_master_start(cmd); + i2c_master_write_byte(cmd, (uint8_t)((addr << 1) | I2C_MASTER_WRITE), true); + i2c_master_stop(cmd); + + esp_err_t err = i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(50)); + i2c_cmd_link_delete(cmd); + + return err == ESP_OK; +} + +static bool i2c_write(uint8_t addr, const uint8_t *data, size_t len) { + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + i2c_master_start(cmd); + i2c_master_write_byte(cmd, (uint8_t)((addr << 1) | I2C_MASTER_WRITE), true); + + if (len) i2c_master_write(cmd, (uint8_t *)data, len, true); + i2c_master_stop(cmd); + + esp_err_t err = i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(100)); + i2c_cmd_link_delete(cmd); + + return err == ESP_OK; +} + +static bool i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wr_len, uint8_t *rd, size_t rd_len) { + i2c_cmd_handle_t cmd = i2c_cmd_link_create(); + i2c_master_start(cmd); + i2c_master_write_byte(cmd, (uint8_t)((addr << 1) | I2C_MASTER_WRITE), true); + + if (wr_len) i2c_master_write(cmd, (uint8_t *)wr, wr_len, true); + i2c_master_start(cmd); /* repeated start */ + i2c_master_write_byte(cmd, (uint8_t)((addr << 1) | I2C_MASTER_READ), true); + + if (rd_len > 1) i2c_master_read(cmd, rd, rd_len - 1, I2C_MASTER_ACK); + i2c_master_read_byte(cmd, rd + rd_len - 1, I2C_MASTER_NACK); + i2c_master_stop(cmd); + + esp_err_t err = i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(100)); + i2c_cmd_link_delete(cmd); + + return err == ESP_OK; +} + +static bool i2c_write_reg(uint8_t addr, uint8_t reg, uint8_t val) { + uint8_t wr[2] = {reg, val}; + + return i2c_write(addr, wr, sizeof wr); +} + +static bool i2c_read_reg(uint8_t addr, uint8_t reg, uint8_t *buf, size_t len) { + return i2c_write_read(addr, ®, 1, buf, len); +} + +static bool mux_select(uint8_t channel) { + uint8_t bit = (uint8_t)(1u << channel); + + return i2c_write(ADDR_MUX, &bit, 1); +} + +#define LTR390_REG_MAIN_CTRL 0x00 +#define LTR390_REG_MEAS_RATE 0x04 +#define LTR390_REG_GAIN 0x05 +#define LTR390_REG_MAIN_STATUS 0x07 +#define LTR390_REG_ALS_DATA0 0x0D +#define LTR390_REG_UVS_DATA0 0x10 + +static bool ltr390_set_als(void) { + bool ok = true; + + ok &= i2c_write_reg(ADDR_LTR390, LTR390_REG_GAIN, 0x01); + ok &= i2c_write_reg(ADDR_LTR390, LTR390_REG_MEAS_RATE, 0x22); + ok &= i2c_write_reg(ADDR_LTR390, LTR390_REG_MAIN_CTRL, 0x02); + + return ok; +} + +static bool ltr390_set_uvs(void) { + bool ok = true; + + ok &= i2c_write_reg(ADDR_LTR390, LTR390_REG_GAIN, 0x04); + ok &= i2c_write_reg(ADDR_LTR390, LTR390_REG_MEAS_RATE, 0x02); + ok &= i2c_write_reg(ADDR_LTR390, LTR390_REG_MAIN_CTRL, 0x0A); + + return ok; +} + +static bool ltr390_init_one(uint8_t idx) { + mux_select(UV_MUX_CHANNEL[idx]); + if (!i2c_probe(ADDR_LTR390)) return false; + + i2c_write_reg(ADDR_LTR390, LTR390_REG_MAIN_CTRL, 0x10); + vTaskDelay(pdMS_TO_TICKS(10)); + + if (!ltr390_set_als()) return false; + s_uv_mode[idx] = LTR390_MODE_ALS; + + return true; +} + +static uint32_t ltr390_read_data(uint8_t reg){ + uint8_t buf[3] = {0}; + if (!i2c_read_reg(ADDR_LTR390, reg, buf, sizeof buf)) return 0; + + return (uint32_t)buf[0] | ((uint32_t)buf[1] << 8) | ((uint32_t)(buf[2] & 0x0F) << 16); +} + +hal_uv_t hal_read_uv(uint8_t idx) { + hal_uv_t r = {0}; + if (idx >= PHOTONICS_NUM_UV || !s_mux_ok || !s_uv_ok[idx]) return r; + + r = s_uv_latest[idx]; + r.fresh = false; + + mux_select(UV_MUX_CHANNEL[idx]); + + uint8_t status = 0; + if (!i2c_read_reg(ADDR_LTR390, LTR390_REG_MAIN_STATUS, &status, 1)) return r; + if (!(status & 0x08)) return r; + + // Read the data from the sensor, convert to lux or UVI, and flip the mode for next time + // Flipping the mode is a bit weird but the original photonics code does it? + // TODO(remy) double check with Kyane + if (s_uv_mode[idx] == LTR390_MODE_ALS) { + uint32_t raw = ltr390_read_data(LTR390_REG_ALS_DATA0); + float lux = 0.6f * (float)raw / 3.0f; + r.lux_x10 = (int32_t)lroundf(lux * 10.0f); + ltr390_set_uvs(); + s_uv_mode[idx] = LTR390_MODE_UVS; + + } else { + uint32_t raw = ltr390_read_data(LTR390_REG_UVS_DATA0); + float uvi = (float)raw / 2300.0f; + r.uvi_x1000 = (int32_t)lroundf(uvi * 1000.0f); + ltr390_set_als(); + s_uv_mode[idx] = LTR390_MODE_ALS; + + } + + r.ok = true; + r.fresh = true; + s_uv_latest[idx] = r; + + return r; +} + +// Literally ctrl-c and ctrl-v from Instrumentation's hal.c, just with the ADS1115 address changed :3 +hal_adc_t hal_read_adc(void) { + hal_adc_t r = {0}; + if (!s_adc_ok) return r; + r.ok = true; + + for (int ch = 0; ch < 4; ch++) { + uint16_t mux = (uint16_t)(0x4 + ch); + uint16_t config = (uint16_t)( + (1u << 15) | (mux << 12) | (0x1u << 9) | (1u << 8) | (0x4u << 5) | 0x3u); + uint8_t wr[3] = {0x01, (uint8_t)(config >> 8), (uint8_t)(config & 0xFF)}; + if (!i2c_write(ADDR_ADS1115, wr, sizeof wr)) { + r.ok = false; + continue; + } + vTaskDelay(pdMS_TO_TICKS(10)); + + uint8_t ptr = 0x00, rd[2]; + if (!i2c_write_read(ADDR_ADS1115, &ptr, 1, rd, sizeof rd)) { + r.ok = false; + continue; + } + int16_t raw = (int16_t)(((uint16_t)rd[0] << 8) | rd[1]); + + // The 0.125f is to convert from raw counts to millivolts, based on the ADS1115 datasheet for the selected gain + // ADS1115 datasheet: https://www.ti.com/lit/ds/symlink/ads1115.pdf + r.mv[ch] = (int16_t)lroundf(raw * 0.125f); + } + return r; +} + +bool hal_init(void) { + if (!can_bus_init(PIN_CAN_TX, PIN_CAN_RX)) return false; + + i2c_config_t conf = { + .mode = I2C_MODE_MASTER, + .sda_io_num = SDA_GPIO, + .scl_io_num = SCL_GPIO, + .sda_pullup_en = GPIO_PULLUP_DISABLE, + .scl_pullup_en = GPIO_PULLUP_DISABLE, + .master.clk_speed = I2C_HZ, + }; + + if (i2c_param_config(I2C_PORT, &conf) != ESP_OK || + i2c_driver_install(I2C_PORT, conf.mode, 0, 0, 0) != ESP_OK) { + ESP_LOGE(TAG, "I2C bus init failed!!! sensors unavailable at boot"); + + // still boot just with broken sensors + return true; + } + + int found = 0; + ESP_LOGI(TAG, "I2C scan (main bus, mux not yet selected):"); + for (uint8_t addr = 1; addr < 127; addr++) { + if (i2c_probe(addr)) { + ESP_LOGI(TAG, "==> found 0x%02X", addr); + found++; + } + } + if (found == 0) { + ESP_LOGW(TAG, "==> nothing found :/ double check shit before trusting sensor values(SDA=%d,SCL=%d) ", SDA_GPIO, SCL_GPIO); + } + ESP_LOGI(TAG, "I2C scan done, %d device(s) found", found); + + s_mux_ok = i2c_probe(ADDR_MUX); + ESP_LOGI(TAG, "TCA9548A mux %s", s_mux_ok ? "OK" : "NOT FOUND"); + + for (uint8_t i = 0; i < PHOTONICS_NUM_UV; i++) { + s_uv_ok[i] = s_mux_ok && ltr390_init_one(i); + ESP_LOGI(TAG, "LTR390 %u (mux ch %u) %s", i, UV_MUX_CHANNEL[i], s_uv_ok[i] ? "OK" : "NOT FOUND"); + } + + // Defaults to channel 0 on boot, this is just a placeholder to init everything + if (s_mux_ok) mux_select(0); + + s_adc_ok = i2c_probe(ADDR_ADS1115); + ESP_LOGI(TAG, "ADS1115 %s", s_adc_ok ? "OK" : "NOT FOUND"); + + return true; +} + +void hal_restart(void) { + esp_restart(); +} \ No newline at end of file diff --git a/src/Photonics/HAL/hal.h b/src/Photonics/HAL/hal.h new file mode 100644 index 0000000..f7f90ce --- /dev/null +++ b/src/Photonics/HAL/hal.h @@ -0,0 +1,45 @@ +/** + * @file hal.h + * @brief Photonics HAL: two LTR390 UV/ambient-light sensors + * and one ADS1115 ADC, plus the mux that switches between the two! This is just for the EAR demo, full system should be written after pookie is done testing + */ +#ifndef HELIA_PHOTONICS_HAL_H +#define HELIA_PHOTONICS_HAL_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PHOTONICS_NUM_UV 2 + +/** @brief LTR390 Sensor struct */ +typedef struct { + bool ok; // true if sensor was found at init + bool fresh; // true if data actually changed, depends on how we want to measure photonics but added for now to avoid data duplication + int32_t lux_x10; // ambient light, scaled by 10 to avoid floating point + int32_t uvi_x1000; // UV index, scaled by 1000 to avoid floating point +} hal_uv_t; + +/** @brief Onboard ADS1115, 4 single-ended channels */ +typedef struct { + bool ok; // true if ADC was found at init + int16_t mv[4]; +} hal_adc_t; + +bool hal_init(void); +void hal_restart(void); + +/** @brief add brief TODO */ +hal_uv_t hal_read_uv(uint8_t idx); + +/** @brief Reads the ADS1115 (check struct) */ +hal_adc_t hal_read_adc(void); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_PHOTONICS_HAL_H */ \ No newline at end of file diff --git a/src/Photonics/HAL/sim/hal_sim.c b/src/Photonics/HAL/sim/hal_sim.c index e69de29..c6292b7 100644 --- a/src/Photonics/HAL/sim/hal_sim.c +++ b/src/Photonics/HAL/sim/hal_sim.c @@ -0,0 +1 @@ +// TODO set up sim \ No newline at end of file From 0b11a820efea64947473c89e3132ef33b16e258d Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:40:43 +0100 Subject: [PATCH 23/34] added photonics pins --- src/Photonics/HAL/esp32/pins.h | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/Photonics/HAL/esp32/pins.h diff --git a/src/Photonics/HAL/esp32/pins.h b/src/Photonics/HAL/esp32/pins.h new file mode 100644 index 0000000..c450e58 --- /dev/null +++ b/src/Photonics/HAL/esp32/pins.h @@ -0,0 +1,11 @@ +/** + * @file pins.h + * @brief Photonics pin map + */ +#ifndef HELIA_PHOTONICS_PINS_H +#define HELIA_PHOTONICS_PINS_H + +#define PIN_CAN_TX 1 +#define PIN_CAN_RX 2 + +#endif // If you're editing this it's because someone messed up I bet \ No newline at end of file From f62d3239c2f0d33e9af5c6eb64f16a780bf0da25 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:40:58 +0100 Subject: [PATCH 24/34] Finish photonics demo code --- src/Photonics/comm.c | 0 src/Photonics/comms.c | 36 ++++++++++++++++++ src/Photonics/comms.h | 40 ++++++++++++++++++++ src/Photonics/control.c | 83 +++++++++++++++++++++++++++++++++++++++++ src/Photonics/setup.c | 67 +++++++++++++++++++++++++++++++++ 5 files changed, 226 insertions(+) delete mode 100644 src/Photonics/comm.c create mode 100644 src/Photonics/comms.c create mode 100644 src/Photonics/comms.h diff --git a/src/Photonics/comm.c b/src/Photonics/comm.c deleted file mode 100644 index e69de29..0000000 diff --git a/src/Photonics/comms.c b/src/Photonics/comms.c new file mode 100644 index 0000000..16438d5 --- /dev/null +++ b/src/Photonics/comms.c @@ -0,0 +1,36 @@ +#include "comms.h" +#include "util_timer.h" + +#define ME CAN_SRC_PHOTONICS + +void photonics_comms_init(photonics_comms_t *c, uint32_t now_ms) { + c->state = FSM_INIT; + c->next_heartbeat_ms = now_ms; + c->reset_requested = false; +} + +void photonics_comms_boot_done(photonics_comms_t *c) { + c->state = fsm_next(c->state, FSM_EVT_INIT_OK); +} + +void photonics_comms_on_frame(photonics_comms_t *c, const can_frame_t *rx) { + switch (can_decode_sys_cmd(rx, ME)) { + + case CAN_SYS_UNLOCK: c->state = fsm_next(c->state, FSM_EVT_UNLOCK); break; + case CAN_SYS_SLEEP: c->state = fsm_next(c->state, FSM_EVT_SLEEP); break; + case CAN_SYS_RESET: c->reset_requested = true; break; + + // base case + case CAN_SYS_NONE: break; + } +} + +size_t photonics_comms_on_tick(photonics_comms_t *c, uint32_t now_ms, can_frame_t tx[PHOTONICS_COMMS_MAX_TX]) { + + if (!util_every(now_ms, &c->next_heartbeat_ms, CAN_HEARTBEAT_PERIOD_MS)) return 0; + + can_heartbeat_t hb = { ME, (uint8_t)c->state, now_ms / 1000u }; + can_encode_heartbeat(&tx[0], &hb); + + return 1; +} \ No newline at end of file diff --git a/src/Photonics/comms.h b/src/Photonics/comms.h new file mode 100644 index 0000000..684d684 --- /dev/null +++ b/src/Photonics/comms.h @@ -0,0 +1,40 @@ +/** + * @file comms.h + * @brief Photonics communication logic: this is jsut heartbeat and state transitions. Check out control.c for actual photonics data + * + * TODO(remy): add more detailed description of the comms logic, including state machine and heartbeat + */ +#ifndef HELIA_PHOTONICS_COMMS_H +#define HELIA_PHOTONICS_COMMS_H + +#include +#include +#include +#include "can.h" +#include "fsm.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define PHOTONICS_COMMS_MAX_TX 1 + +typedef struct { + fsm_state_t state; + uint32_t next_heartbeat_ms; + bool reset_requested; +} photonics_comms_t; + +void photonics_comms_init(photonics_comms_t *c, uint32_t now_ms); + +void photonics_comms_boot_done(photonics_comms_t *c); + +void photonics_comms_on_frame(photonics_comms_t *c, const can_frame_t *rx); + +size_t photonics_comms_on_tick(photonics_comms_t *c, uint32_t now_ms, can_frame_t tx[PHOTONICS_COMMS_MAX_TX]); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_PHOTONICS_COMMS_H */ \ No newline at end of file diff --git a/src/Photonics/control.c b/src/Photonics/control.c index e69de29..a3ddfa5 100644 --- a/src/Photonics/control.c +++ b/src/Photonics/control.c @@ -0,0 +1,83 @@ +#include "control.h" + +#ifdef ESP_PLATFORM +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" + +#include "hal.h" +#include "can_bus.h" +#include "can_photonics.h" +#include "util_timer.h" + +static const char *TAG = "photonics_ctrl"; + +#define TX_TIMEOUT_MS 10u + +// debugging variable +#define SEND_ADC 1 + +static uint32_t now_ms(void) { + return (uint32_t)pdTICKS_TO_MS(xTaskGetTickCount()); +} + +static void send(const can_frame_t *f, const char *what) { + if (!can_bus_send(f, TX_TIMEOUT_MS)) { + ESP_LOGW(TAG, "%s send failed, id=0x%03lX", what, (unsigned long)f->id); + } +} + +void photonics_control_task(void *arg) { + + (void)arg; // Just to avoid warnings again + + uint32_t next_summary_ms = now_ms(); + uint32_t uv_frames[PHOTONICS_NUM_UV] = {0}; + uint32_t adc_frames = 0; + + ESP_LOGI(TAG, "control core up"); + + while (1) { + can_frame_t f; + + for (uint8_t i = 0; i < PHOTONICS_NUM_UV; i++) { + + hal_uv_t uv = hal_read_uv(i); + + if (uv.ok && uv.fresh) { + can_photonics_uv_t p = { .lux_x10 = uv.lux_x10, .uvi_x1000 = uv.uvi_x1000 }; + + if (i == 0) { + can_encode_uv0(&f, &p); + send(&f, "uv0"); + } else { + can_encode_uv1(&f, &p); + send(&f, "uv1"); + } + uv_frames[i]++; + } + } + +#if SEND_ADC + hal_adc_t adc = hal_read_adc(); + if (adc.ok) { + can_photonics_adc_t a; + memcpy(a.mv, adc.mv, sizeof a.mv); + can_encode_adc(&f, &a); + send(&f, "adc"); + adc_frames++; + } +#endif + // Log a summary every 5 seconds, useful for debugging, might remove later + if (util_every(now_ms(), &next_summary_ms, 5000u)) { + + ESP_LOGI(TAG, "frames: uv0=%lu uv1=%lu adc=%lu", + (unsigned long)uv_frames[0], (unsigned long)uv_frames[1], + (unsigned long)adc_frames); + } + + vTaskDelay(pdMS_TO_TICKS(100)); + } +} +#endif /* ESP_PLATFORM */ \ No newline at end of file diff --git a/src/Photonics/setup.c b/src/Photonics/setup.c index e69de29..5e7dd0b 100644 --- a/src/Photonics/setup.c +++ b/src/Photonics/setup.c @@ -0,0 +1,67 @@ +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" + +#include "hal.h" +#include "can_bus.h" +#include "comms.h" +#include "control.h" + +static const char *TAG = "photonics"; + +#define RX_TIMEOUT_MS 20u +#define TX_TIMEOUT_MS 10u + +static uint32_t now_ms(void) +{ + return (uint32_t)pdTICKS_TO_MS(xTaskGetTickCount()); +} + +static void comms_task(void *arg) +{ + (void)arg; // unsused, just added this here to avoid compiler warnings :) + + photonics_comms_t comms; + uint32_t last_status_log_ms = 0; + + photonics_comms_init(&comms, now_ms()); + photonics_comms_boot_done(&comms); + + + ESP_LOGI(TAG, "state %s", fsm_state_name(comms.state)); + + while (1) { + can_frame_t rx, tx[PHOTONICS_COMMS_MAX_TX]; + fsm_state_t before = comms.state; + + if (can_bus_recv(&rx, RX_TIMEOUT_MS)) photonics_comms_on_frame(&comms, &rx); + + size_t n = photonics_comms_on_tick(&comms, now_ms(), tx); + + for (size_t i = 0; i < n; i++) { + + if (!can_bus_send(&tx[i], TX_TIMEOUT_MS)) { + ESP_LOGW(TAG, "send failed, id=0x%03lX", (unsigned long)tx[i].id); + + if (now_ms() - last_status_log_ms >= 2000u) { + can_bus_log_status(); + last_status_log_ms = now_ms(); + } + } + } + + if (comms.state != before) ESP_LOGI(TAG, "state %s -> %s", fsm_state_name(before), fsm_state_name(comms.state)); + if (comms.reset_requested) hal_restart(); + + can_bus_poll_recovery(); + } +} + +void app_main(void) +{ + ESP_LOGI(TAG, "boot"); + if (!hal_init()) ESP_LOGE(TAG, "CAN init failed"); + + xTaskCreatePinnedToCore(comms_task, "photonics_comms", 4096, NULL, 10, NULL, 0); + xTaskCreatePinnedToCore(photonics_control_task, "photonics_ctrl", 4096, NULL, 5, NULL, 1); +} \ No newline at end of file From fae61754f3ce0984174862e6a501919a1ddfced6 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:41:14 +0100 Subject: [PATCH 25/34] Updated photonics control.c (added implementation --- src/Photonics/control.h | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/Photonics/control.h diff --git a/src/Photonics/control.h b/src/Photonics/control.h new file mode 100644 index 0000000..018de67 --- /dev/null +++ b/src/Photonics/control.h @@ -0,0 +1,20 @@ +/** + * @file control.h + * @brief Photonics control core: handles most of the data collection and can transmission, heartbeat and shit is in comms.h + */ +#ifndef HELIA_PHOTONICS_CONTROL_H +#define HELIA_PHOTONICS_CONTROL_H + +#ifdef __cplusplus +extern "C" { +#endif + +/// @brief Main control task for data collection for photonics. Args are unused, just added this here to avoid compiler warnings! +/// @param arg +void photonics_control_task(void *arg); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_PHOTONICS_CONTROL_H */ \ No newline at end of file From f3c0eb6fed0369cda38150690a8251d1aa229551 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:41:41 +0100 Subject: [PATCH 26/34] Implemented mechanisms (needs updating to bridge with thermals) --- src/{Mechanism => Mechanisms}/HAL/esp32/hal_esp32.c | 0 src/{Mechanism/HAL/sim/hal_sim.c => Mechanisms/HAL/hal.h} | 0 src/{Mechanism/comm.c => Mechanisms/HAL/sim/hal_sim.c} | 0 src/{Mechanism/control.c => Mechanisms/comm.c} | 0 src/{Mechanism/setup.c => Mechanisms/control.c} | 0 src/Mechanisms/setup.c | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/{Mechanism => Mechanisms}/HAL/esp32/hal_esp32.c (100%) rename src/{Mechanism/HAL/sim/hal_sim.c => Mechanisms/HAL/hal.h} (100%) rename src/{Mechanism/comm.c => Mechanisms/HAL/sim/hal_sim.c} (100%) rename src/{Mechanism/control.c => Mechanisms/comm.c} (100%) rename src/{Mechanism/setup.c => Mechanisms/control.c} (100%) create mode 100644 src/Mechanisms/setup.c diff --git a/src/Mechanism/HAL/esp32/hal_esp32.c b/src/Mechanisms/HAL/esp32/hal_esp32.c similarity index 100% rename from src/Mechanism/HAL/esp32/hal_esp32.c rename to src/Mechanisms/HAL/esp32/hal_esp32.c diff --git a/src/Mechanism/HAL/sim/hal_sim.c b/src/Mechanisms/HAL/hal.h similarity index 100% rename from src/Mechanism/HAL/sim/hal_sim.c rename to src/Mechanisms/HAL/hal.h diff --git a/src/Mechanism/comm.c b/src/Mechanisms/HAL/sim/hal_sim.c similarity index 100% rename from src/Mechanism/comm.c rename to src/Mechanisms/HAL/sim/hal_sim.c diff --git a/src/Mechanism/control.c b/src/Mechanisms/comm.c similarity index 100% rename from src/Mechanism/control.c rename to src/Mechanisms/comm.c diff --git a/src/Mechanism/setup.c b/src/Mechanisms/control.c similarity index 100% rename from src/Mechanism/setup.c rename to src/Mechanisms/control.c diff --git a/src/Mechanisms/setup.c b/src/Mechanisms/setup.c new file mode 100644 index 0000000..e69de29 From a90f676adde77dc7d3f0a2583d76699cfb0e8fc8 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:41:59 +0100 Subject: [PATCH 27/34] Added master comms --- src/Master/comm.c | 0 src/Master/comms.c | 92 ++++++++++++++++++++++++++++++++++++++++++++++ src/Master/comms.h | 73 ++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) delete mode 100644 src/Master/comm.c create mode 100644 src/Master/comms.c create mode 100644 src/Master/comms.h diff --git a/src/Master/comm.c b/src/Master/comm.c deleted file mode 100644 index e69de29..0000000 diff --git a/src/Master/comms.c b/src/Master/comms.c new file mode 100644 index 0000000..3c08796 --- /dev/null +++ b/src/Master/comms.c @@ -0,0 +1,92 @@ +#include "comms.h" +#include +#include "can_eps.h" +#include "util_math.h" +#include "util_timer.h" + +const char *master_node_name(can_source_t s) +{ + switch (s) { + case CAN_SRC_GROUND: return "GROUND"; + case CAN_SRC_MASTER: return "MASTER"; + case CAN_SRC_BROADCAST: return "BROADCAST"; + case CAN_SRC_MECHANISMS: return "MECHANISMS"; + case CAN_SRC_PHOTONICS: return "PHOTONICS"; + case CAN_SRC_INSTRUMENTATION: return "INSTRUMENTATION"; + case CAN_SRC_EPS: return "EPS"; + case CAN_SRC_THERMAL: return "THERMAL"; + default: return "?"; + } +} + +void master_comms_init(master_comms_t *c, uint32_t now_ms) +{ + /* Master has no one to unlock it, so it takes itself INIT -> SAFE -> ACTIVE. + * TODO: gate the unlock on a ground command once TT&C exists. */ + c->state = fsm_next(FSM_INIT, FSM_EVT_INIT_OK); + c->state = fsm_next(c->state, FSM_EVT_UNLOCK); + c->next_heartbeat_ms = now_ms; + for (int i = 0; i < MASTER_NODE_SLOTS; i++) c->last_seen_ms[i] = 0; + c->alive_mask = 0; +} + +size_t master_comms_on_frame(master_comms_t *c, uint32_t now_ms, const can_frame_t *rx, + can_frame_t tx[MASTER_COMMS_MAX_TX]) +{ + can_heartbeat_t hb; + if (!can_decode_heartbeat(rx, &hb) || hb.source == CAN_SRC_MASTER) return 0; + + c->last_seen_ms[hb.source] = now_ms; + c->alive_mask |= (uint8_t)(1u << hb.source); + + if (MASTER_AUTO_UNLOCK && hb.state == FSM_SAFE) { + can_encode_sys_cmd(&tx[0], CAN_SYS_UNLOCK, hb.source); + return 1; + } + return 0; +} + +size_t master_comms_on_tick(master_comms_t *c, uint32_t now_ms, + can_frame_t tx[MASTER_COMMS_MAX_TX]) +{ + for (unsigned i = 0; i < MASTER_NODE_SLOTS; i++) { + if ((c->alive_mask & (1u << i)) && now_ms - c->last_seen_ms[i] > CAN_HEARTBEAT_TIMEOUT_MS) { + c->alive_mask &= (uint8_t)~(1u << i); + } + } + if (!util_every(now_ms, &c->next_heartbeat_ms, CAN_HEARTBEAT_PERIOD_MS)) return 0; + + can_heartbeat_t hb = { CAN_SRC_MASTER, (uint8_t)c->state, now_ms / 1000u }; + can_encode_heartbeat(&tx[0], &hb); + return 1; +} + +/* Scaled value as text, or "invalid" for UTIL_I16_INVALID */ +static const char *fmt_scaled(char *buf, size_t len, int16_t raw, float scale, const char *unit) +{ + if (raw == UTIL_I16_INVALID) snprintf(buf, len, "invalid"); + else snprintf(buf, len, "%.2f %s", (double)util_from_i16(raw, scale), unit); + return buf; +} + +const char *master_describe_frame(const can_frame_t *f, char *buf, size_t len) +{ + can_heartbeat_t hb; + can_eps_status_t eps; + char a[24], b[24], t[24]; + + if (can_decode_heartbeat(f, &hb)) { + snprintf(buf, len, "%s heartbeat: %s, up %lu s", master_node_name(hb.source), + fsm_state_name((fsm_state_t)hb.state), (unsigned long)hb.uptime_s); + } else if (can_decode_eps_status(f, &eps)) { + snprintf(buf, len, "EPS status: current %s, shunt %s, PCB %s", + fmt_scaled(a, sizeof a, eps.current_ma, 1.0f, "mA"), + fmt_scaled(b, sizeof b, eps.shunt_mv_x100, 100.0f, "mV"), + fmt_scaled(t, sizeof t, eps.temp_c_x10, 10.0f, "C")); + } else { + snprintf(buf, len, "%s frame: prio %u, type 0x%02X, %u bytes (id 0x%03lX)", + master_node_name(can_id_source(f->id)), (unsigned)can_id_priority(f->id), + can_id_type(f->id), f->dlc, (unsigned long)f->id); + } + return buf; +} \ No newline at end of file diff --git a/src/Master/comms.h b/src/Master/comms.h new file mode 100644 index 0000000..bd48a28 --- /dev/null +++ b/src/Master/comms.h @@ -0,0 +1,73 @@ +/** + * @file comms.h + * @brief Master communication logic, currently a bench "bus tester": + * - sends Master's heartbeat every 500 ms + * - tracks which nodes are alive from their heartbeats + * - unlocks any node whose heartbeat says it is in SAFE + * - turns every received frame into a readable log line + * + * Pure: the task loop in setup.c does the actual sending and receiving. + */ +#ifndef HELIA_MASTER_COMMS_H +#define HELIA_MASTER_COMMS_H + +#include +#include +#include +#include "can.h" +#include "fsm.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define MASTER_COMMS_MAX_TX 1 /**< Most frames one call can ask to send. */ +#define MASTER_NODE_SLOTS 8 /**< One per CAN source ID (3 bits). */ + +/** + * @brief Unlock nodes automatically when they report SAFE. + * Bench testing only: in flight, unlocking will follow ground commands and + * flight phases. + */ +#define MASTER_AUTO_UNLOCK 1 + +/** @brief Comms state. Owned by the comms task. */ +typedef struct { + fsm_state_t state; + uint32_t next_heartbeat_ms; + uint32_t last_seen_ms[MASTER_NODE_SLOTS]; /**< Last heartbeat time per source. */ + uint8_t alive_mask; /**< Bit n set = source n heard within the timeout. */ +} master_comms_t; + +/** @brief Start up. Master has no one to unlock it, so it goes straight to ACTIVE. */ +void master_comms_init(master_comms_t *c, uint32_t now_ms); + +/** + * @brief Handle one received frame: note heartbeats, unlock nodes in SAFE. + * @return Frames to send in reply. + */ +size_t master_comms_on_frame(master_comms_t *c, uint32_t now_ms, const can_frame_t *rx, + can_frame_t tx[MASTER_COMMS_MAX_TX]); + +/** + * @brief Periodic work: Master's heartbeat, and dropping nodes silent for + * longer than CAN_HEARTBEAT_TIMEOUT_MS from alive_mask. + * @return Frames written to @p tx. + */ +size_t master_comms_on_tick(master_comms_t *c, uint32_t now_ms, + can_frame_t tx[MASTER_COMMS_MAX_TX]); + +/** @brief Node name for logs, e.g. "EPS". */ +const char *master_node_name(can_source_t s); + +/** + * @brief Describe a frame in words, e.g. "EPS heartbeat: SAFE, up 12 s". + * @return @p buf, always NUL-terminated. + */ +const char *master_describe_frame(const can_frame_t *f, char *buf, size_t len); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_MASTER_COMMS_H */ \ No newline at end of file From 84f86317838f0afd31337276f1c8a20f335b537e Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:42:09 +0100 Subject: [PATCH 28/34] Flight manager --- src/Master/flight_phase.c | 57 +++++++++++++++++++++ src/Master/flight_phase.h | 103 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/Master/flight_phase.c create mode 100644 src/Master/flight_phase.h diff --git a/src/Master/flight_phase.c b/src/Master/flight_phase.c new file mode 100644 index 0000000..fb6b868 --- /dev/null +++ b/src/Master/flight_phase.c @@ -0,0 +1,57 @@ +#include "flight_phase.h" + +static const char *PHASE_NAMES[MASTER_PHASE_COUNT] = { + [MASTER_PHASE_TESTING] = "TESTING", + [MASTER_PHASE_STANDBY] = "STANDBY", + [MASTER_PHASE_ASCENDING] = "ASCENDING", + [MASTER_PHASE_FLOAT] = "FLOAT", + [MASTER_PHASE_DESCENDING] = "DESCENDING", + [MASTER_PHASE_LANDED] = "LANDED", +}; + +void master_flight_init(master_flight_t *f, uint32_t now_ms) +{ + f->phase = MASTER_PHASE_TESTING; + f->phase_entered_ms = now_ms; + f->last_motion_ms = now_ms; +} + +master_phase_t master_flight_phase(const master_flight_t *f) +{ + return f->phase; +} + +const char *master_phase_name(master_phase_t p) +{ + if ((unsigned)p >= MASTER_PHASE_COUNT) return "UNKNOWN"; + return PHASE_NAMES[p]; +} + +master_flight_result_t master_flight_set_phase(master_flight_t *f, master_phase_t next, uint32_t now_ms) +{ + if ((unsigned)next >= MASTER_PHASE_COUNT) return MASTER_FLIGHT_REJECTED; + if (next == f->phase) return MASTER_FLIGHT_REJECTED; /* no-op */ + + const bool sequential = ((unsigned)next == (unsigned)f->phase + 1u); + + f->phase = next; + f->phase_entered_ms = now_ms; + f->last_motion_ms = now_ms; /* fresh phase, fresh no-motion window */ + + return sequential ? MASTER_FLIGHT_OK : MASTER_FLIGHT_OVERRIDE; +} + +void master_flight_tick(master_flight_t *f, uint32_t now_ms) +{ + if (f->phase != MASTER_PHASE_DESCENDING) return; + if (now_ms - f->last_motion_ms < MASTER_LANDING_NO_MOTION_MS) return; + + f->phase = MASTER_PHASE_LANDED; + f->phase_entered_ms = now_ms; + f->last_motion_ms = now_ms; +} + +void master_flight_note_motion(master_flight_t *f, uint32_t now_ms) +{ + f->last_motion_ms = now_ms; +} \ No newline at end of file diff --git a/src/Master/flight_phase.h b/src/Master/flight_phase.h new file mode 100644 index 0000000..5b34dcb --- /dev/null +++ b/src/Master/flight_phase.h @@ -0,0 +1,103 @@ +/** + * @file flight_phase.h + * @brief Master's flight-phase state machine. Pure logic - no FreeRTOS, no + * CAN, no sockets - so it's host-testable the same way master_comms + * is. Owning/synchronising an instance across cores is control.c's + * job, not this module's. + * + * Phases (per the SED's flight-phase table, names adapted): TESTING (SED's + * "Pre-launch") -> STANDBY ("Launch") -> ASCENDING ("Ascent") -> FLOAT -> + * DESCENDING ("Descent") -> LANDED ("Landing"). + * + * NOTE ON A KNOWN SED DISCREPANCY: the SED's table lists Ascent (100m + * altitude) and Float (apogee) as automatic transitions. Per team decision + * this implementation makes every transition except the last + * ground-commanded instead (simpler, no dependency on an altitude reading + * Master doesn't itself have) - only DESCENDING -> LANDED is automatic + * here, matching the SED's "automatic based on altitude rate of change" + * intent via a no-motion timeout instead. The SED table should be updated + * to match, or this should be revisited, before flight. + * + * All ground-commanded transitions go through master_flight_set_phase(), + * which is deliberately permissive: the SED itself calls this command an + * "override", so an out-of-sequence request (skipping a phase, or going + * backward) is still applied, just reported back as + * MASTER_FLIGHT_OVERRIDE instead of MASTER_FLIGHT_OK so the caller can log + * it distinctly. Only a genuinely invalid phase value or a no-op + * (requesting the phase we're already in) is rejected. + */ +#ifndef HELIA_MASTER_FLIGHT_PHASE_H +#define HELIA_MASTER_FLIGHT_PHASE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + MASTER_PHASE_TESTING = 0, + MASTER_PHASE_STANDBY, + MASTER_PHASE_ASCENDING, + MASTER_PHASE_FLOAT, + MASTER_PHASE_DESCENDING, + MASTER_PHASE_LANDED, + MASTER_PHASE_COUNT, +} master_phase_t; + +typedef enum { + MASTER_FLIGHT_OK, /**< applied, matched the expected next phase */ + MASTER_FLIGHT_OVERRIDE, /**< applied, but out of the normal sequence */ + MASTER_FLIGHT_REJECTED, /**< not applied: invalid phase, or already there */ +} master_flight_result_t; + +/** @brief Auto-transition DESCENDING -> LANDED after this long with no + * motion noted via master_flight_note_motion(). Placeholder value + * (5 minutes) - nothing feeds real motion data yet (see ttc.h/.c + * and control.c), so tune this once it does. */ +#define MASTER_LANDING_NO_MOTION_MS (5u * 60u * 1000u) + +typedef struct { + master_phase_t phase; + uint32_t phase_entered_ms; /**< now_ms() at last transition */ + uint32_t last_motion_ms; /**< now_ms() at last noted motion (or phase entry) */ +} master_flight_t; + +/** @brief Starts in MASTER_PHASE_TESTING. */ +void master_flight_init(master_flight_t *f, uint32_t now_ms); + +master_phase_t master_flight_phase(const master_flight_t *f); + +/** @brief Human-readable phase name for logging. Never NULL. */ +const char *master_phase_name(master_phase_t p); + +/** + * @brief Apply a ground-commanded (or otherwise externally requested) + * phase change. See the "override" note above for how out-of-order + * requests are handled. + */ +master_flight_result_t master_flight_set_phase(master_flight_t *f, master_phase_t next, uint32_t now_ms); + +/** + * @brief Call periodically (e.g. once per control-loop tick). Checks the + * DESCENDING -> LANDED no-motion timeout and applies it internally + * if due. No-op in any other phase. + */ +void master_flight_tick(master_flight_t *f, uint32_t now_ms); + +/** + * @brief Call whenever motion is detected, to feed the no-motion timeout. + * TODO: nothing calls this yet - wire it to real Instrumentation + * CAN data (accel/altitude-rate) once that message exists. Until + * then DESCENDING always times out to LANDED + * MASTER_LANDING_NO_MOTION_MS after entry, regardless of actual + * motion. + */ +void master_flight_note_motion(master_flight_t *f, uint32_t now_ms); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_MASTER_FLIGHT_PHASE_H */ \ No newline at end of file From 866eb7981e9403f2aa366104f04d0fe7b6bf19d1 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:42:18 +0100 Subject: [PATCH 29/34] OBDH control --- src/Master/control.c | 105 +++++++++++++++++++++++++++++++++++++++++++ src/Master/control.h | 52 +++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/Master/control.h diff --git a/src/Master/control.c b/src/Master/control.c index e69de29..70bf3aa 100644 --- a/src/Master/control.c +++ b/src/Master/control.c @@ -0,0 +1,105 @@ +/* Master control core: owns the flight-phase state machine (flight_phase.h + * does the actual FSM logic; this file just synchronises access to one + * instance across cores and hooks it up to CAN + logging). + * + * Two callers touch the flight state from two different cores: + * - master_control_task (this file, core 1): periodic no-motion tick. + * - master_control_set_phase (called from ttc.c's uplink handler, core + * 0): ground-commanded phase changes. + * Hence the mutex - see master_control_init()'s doc comment in control.h + * for why it's created eagerly from app_main() rather than lazily here. + * + * Guarded by ESP_PLATFORM so this compiles to nothing on the host (same + * as before) - flight_phase.c underneath it is what's host-testable. + */ +#include "control.h" + +#ifdef ESP_PLATFORM +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include "esp_log.h" + +#include "can_bus.h" +#include "protocol.h" + +static const char *TAG = "master_ctrl"; + +#define TX_TIMEOUT_MS 10u + +static master_flight_t s_flight; +static SemaphoreHandle_t s_flight_mutex; + +static uint32_t now_ms(void) +{ + return (uint32_t)pdTICKS_TO_MS(xTaskGetTickCount()); +} + +/* Tells every other node the phase changed - CAN_CMD_FLIGHT_PHASE, per the + * SED's Broadcast CAN Message Dictionary (source BROADCAST, type 0x03). + * Payload: 1 byte, the new master_phase_t value. Called with s_flight_mutex + * already held so the logged phase name matches what was just applied. */ +static void broadcast_phase(master_phase_t phase, master_flight_result_t result) +{ + can_frame_t f = { + .id = can_make_id(CAN_PRIO_IMPORTANT, CAN_SRC_BROADCAST, CAN_CMD_FLIGHT_PHASE), + .dlc = 1, + }; + f.data[0] = (uint8_t)phase; + + if (!can_bus_send(&f, TX_TIMEOUT_MS)) { + ESP_LOGW(TAG, "failed to broadcast phase change to CAN"); + } + + if (result == MASTER_FLIGHT_OVERRIDE) { + ESP_LOGW(TAG, "flight phase -> %s (OUT OF SEQUENCE - ground override)", master_phase_name(phase)); + } else { + ESP_LOGI(TAG, "flight phase -> %s", master_phase_name(phase)); + } +} + +void master_control_init(void) +{ + s_flight_mutex = xSemaphoreCreateMutex(); + master_flight_init(&s_flight, now_ms()); +} + +master_flight_result_t master_control_set_phase(master_phase_t next) +{ + xSemaphoreTake(s_flight_mutex, portMAX_DELAY); + master_flight_result_t result = master_flight_set_phase(&s_flight, next, now_ms()); + if (result != MASTER_FLIGHT_REJECTED) { + broadcast_phase(next, result); + } + xSemaphoreGive(s_flight_mutex); + return result; +} + +master_phase_t master_control_get_phase(void) +{ + xSemaphoreTake(s_flight_mutex, portMAX_DELAY); + master_phase_t p = master_flight_phase(&s_flight); + xSemaphoreGive(s_flight_mutex); + return p; +} + +void master_control_task(void *arg) +{ + (void)arg; + ESP_LOGI(TAG, "control core up, flight phase %s", master_phase_name(master_flight_phase(&s_flight))); + + for (;;) { + xSemaphoreTake(s_flight_mutex, portMAX_DELAY); + master_phase_t before = master_flight_phase(&s_flight); + master_flight_tick(&s_flight, now_ms()); + master_phase_t after = master_flight_phase(&s_flight); + if (after != before) { + /* only the automatic DESCENDING -> LANDED path lands here */ + broadcast_phase(after, MASTER_FLIGHT_OK); + } + xSemaphoreGive(s_flight_mutex); + + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} +#endif /* ESP_PLATFORM */ \ No newline at end of file diff --git a/src/Master/control.h b/src/Master/control.h new file mode 100644 index 0000000..06e6923 --- /dev/null +++ b/src/Master/control.h @@ -0,0 +1,52 @@ +#ifndef HELIA_MASTER_CONTROL_H +#define HELIA_MASTER_CONTROL_H + +#include "flight_phase.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Create the flight-phase state and its mutex. MUST be called once + * from app_main(), before any task that might call + * master_control_set_phase()/master_control_get_phase() is + * created (i.e. before ttc_uplink_task) - this avoids a boot-time + * race where a ground command could arrive before the mutex + * exists. master_control_task() itself only ticks; it doesn't + * create state, so its own creation order relative to this + * doesn't matter, only relative to ttc_uplink_task's. + */ +void master_control_init(void); + +/** @brief Control core task: periodically checks the DESCENDING -> LANDED + * no-motion timeout. Pinned to core 1 by setup.c. ESP32-only (see + * control.c); declared unconditionally here since only ESP32-only + * callers reference it. Requires master_control_init() to have + * run first. */ +void master_control_task(void *arg); + +/** + * @brief Thread-safe: apply a ground-commanded phase change. Called from + * ttc.c's uplink handler (core 0) when a CAN_CMD_FLIGHT_PHASE + * uplink command arrives - this is the flight manager's gate + * between ground commands and the rest of the system for that + * command. Broadcasts CAN_CMD_FLIGHT_PHASE to the bus and logs the + * result internally; the caller just needs the result to ACK + * ground correctly. + * + * Safe to call even before master_control_task's first loop + * iteration, but NOT before it has been created (the mutex it uses + * is created at task start) - setup.c creates control before ttc's + * uplink task, so this is naturally satisfied. + */ +master_flight_result_t master_control_set_phase(master_phase_t next); + +/** @brief Thread-safe read of the current flight phase. */ +master_phase_t master_control_get_phase(void); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_MASTER_CONTROL_H */ \ No newline at end of file From b2a94dcf2ae1725305207c4d8f2bb9537e0896c3 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:42:23 +0100 Subject: [PATCH 30/34] setup.c --- src/Master/setup.c | 149 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/src/Master/setup.c b/src/Master/setup.c index e69de29..6200ce8 100644 --- a/src/Master/setup.c +++ b/src/Master/setup.c @@ -0,0 +1,149 @@ +/* Master boot and task loop. + * + * Two cores, matching EPS/Instrumentation's comms/control split: + * core 0 (comms_task + ttc_uplink_task): CAN bus and the TT&C link. + * core 1 (master_control_task): flight manager. Placeholder for now. + * + * TT&C is deliberately minimal right now - the goal is proving Master and + * a ground script can talk at all, not the full flight design: + * - comms_task mirrors every CAN frame it sees down to ground over UDP - + * both what Master itself sends (its heartbeat, ACKs, etc, from + * master_comms_on_tick()/on_frame()) and every frame it receives from + * other nodes on the bus (rx, via can_bus_recv()). No separate task, + * no queue, no filtering - just a socket call next to the existing CAN + * send/recv, so ground sees the whole bus, not just Master's traffic. + * - ttc_uplink_task (in ttc.c) logs and ACKs whatever ground sends over + * TCP. Nothing is forwarded to CAN yet. + */ +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "lwip/sockets.h" +#include "lwip/inet.h" +#include "sdkconfig.h" + +#include "hal.h" +#include "can_bus.h" +#include "comms.h" +#include "control.h" +#include "eth_w5500.h" +#include "ttc.h" +#include "util_timer.h" + +static const char *TAG = "master"; + +#define RX_TIMEOUT_MS 20u +#define TX_TIMEOUT_MS 10u + +static uint32_t now_ms(void) +{ + return (uint32_t)pdTICKS_TO_MS(xTaskGetTickCount()); +} + +static void send_all(const can_frame_t *tx, size_t n) +{ + char text[96]; + for (size_t i = 0; i < n; i++) { + if (!can_bus_send(&tx[i], TX_TIMEOUT_MS)) { + ESP_LOGW(TAG, "send failed, id=0x%03lX", (unsigned long)tx[i].id); + } else if (can_id_type(tx[i].id) != CAN_MSG_HEARTBEAT || can_id_source(tx[i].id) != CAN_SRC_MASTER) { + ESP_LOGI(TAG, "sent: %s", master_describe_frame(&tx[i], text, sizeof text)); /* don't log our own heartbeat */ + } + } +} + +/* "EPS online" / "EPS LOST" whenever a node appears or goes silent */ +static void log_presence_changes(uint8_t before, uint8_t after) +{ + for (unsigned i = 0; i < MASTER_NODE_SLOTS; i++) { + uint8_t bit = (uint8_t)(1u << i); + if ((after & bit) && !(before & bit)) ESP_LOGI(TAG, "%s online", master_node_name((can_source_t)i)); + if ((before & bit) && !(after & bit)) ESP_LOGW(TAG, "%s LOST (no heartbeat for %u ms)", + master_node_name((can_source_t)i), (unsigned)CAN_HEARTBEAT_TIMEOUT_MS); + } +} + +/* Packs and mirrors one CAN frame down to ground over UDP. No-ops quietly + * if the socket isn't up or the link isn't ready yet - same "just drop it, + * same as ground not being switched on" philosophy as before. Pulled out + * to a helper because it's now called for every frame Master sees on the + * bus, not just its own outgoing ones - see comms_task. */ +static void downlink_frame(int sock, const struct sockaddr_in *ground, uint8_t *seq, const can_frame_t *f) +{ + if (sock < 0 || !ttc_eth_ready()) return; + uint8_t pkt[TTC_DL_PACKET_LEN]; + size_t plen = ttc_pack_downlink(pkt, sizeof pkt, (*seq)++, f); + if (plen) sendto(sock, pkt, plen, 0, (const struct sockaddr *)ground, sizeof *ground); +} + +static void comms_task(void *arg) +{ + (void)arg; + master_comms_t comms; + uint32_t last_status_log_ms = 0; + master_comms_init(&comms, now_ms()); + ESP_LOGI(TAG, "state %s, auto-unlock %s", fsm_state_name(comms.state), MASTER_AUTO_UNLOCK ? "ON" : "OFF"); + + /* One UDP socket, opened once, used to mirror CAN traffic down to + * ground - both frames Master itself sends AND every frame it + * receives from other nodes, which is what gives ground full-bus + * visibility rather than just Master's own heartbeat. No retries + * beyond what sendto() does on its own - if the link is down the + * packet is just dropped, same as the ground station not being + * switched on. */ + int dl_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + struct sockaddr_in ground = { + .sin_family = AF_INET, + .sin_port = htons(CONFIG_TTC_UDP_DOWNLINK_PORT), + .sin_addr.s_addr = inet_addr(CONFIG_TTC_GROUND_IP), + }; + uint8_t dl_seq = 0; /* one running counter across everything mirrored down, + * so ground can spot drops in the combined stream */ + + for (;;) { + can_frame_t rx, tx[MASTER_COMMS_MAX_TX]; + char text[96]; + uint8_t alive_before = comms.alive_mask; + + if (can_bus_recv(&rx, RX_TIMEOUT_MS)) { + ESP_LOGI(TAG, "rx: %s", master_describe_frame(&rx, text, sizeof text)); + /* CAN controllers don't loop a node's own transmitted frames + * back to its own RX, so this is the ONLY path that captures + * traffic from every other node - EPS/Instrumentation/ + * Photonics/etc - Master's own tx frames are mirrored + * separately below. */ + downlink_frame(dl_sock, &ground, &dl_seq, &rx); + send_all(tx, master_comms_on_frame(&comms, now_ms(), &rx, tx)); + } + + size_t n = master_comms_on_tick(&comms, now_ms(), tx); + send_all(tx, n); + for (size_t i = 0; i < n; i++) { + downlink_frame(dl_sock, &ground, &dl_seq, &tx[i]); /* was tx[0] only before - now mirrors all of them */ + } + + log_presence_changes(alive_before, comms.alive_mask); + + if (now_ms() - last_status_log_ms >= 5000u) { /* bus health every 5 s */ + can_bus_log_status(); + last_status_log_ms = now_ms(); + } + can_bus_poll_recovery(); + } +} + +void app_main(void) +{ + ESP_LOGI(TAG, "boot"); + if (!hal_init()) ESP_LOGE(TAG, "CAN init failed"); + + /* Must run before ttc_uplink_task is created: that task can call + * master_control_set_phase() as soon as a ground command arrives, and + * this is what creates the mutex it needs. See control.h. */ + master_control_init(); + + xTaskCreatePinnedToCore(comms_task, "master_comms", 4096, NULL, 10, NULL, 0); + xTaskCreatePinnedToCore(ttc_uplink_task, "master_ttc_ul", 4096, NULL, 5, NULL, 0); + xTaskCreatePinnedToCore(master_control_task, "master_ctrl", 4096, NULL, 5, NULL, 1); +} \ No newline at end of file From 1813f6bafac18a21f90ad36366bdc869fa902cd8 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:42:34 +0100 Subject: [PATCH 31/34] Ethernet drivers --- src/Master/HAL/esp32/eth_w5500.c | 174 +++++++++++++++++++++++++++++++ src/Master/HAL/esp32/eth_w5500.h | 18 ++++ 2 files changed, 192 insertions(+) create mode 100644 src/Master/HAL/esp32/eth_w5500.c create mode 100644 src/Master/HAL/esp32/eth_w5500.h diff --git a/src/Master/HAL/esp32/eth_w5500.c b/src/Master/HAL/esp32/eth_w5500.c new file mode 100644 index 0000000..04d8da0 --- /dev/null +++ b/src/Master/HAL/esp32/eth_w5500.c @@ -0,0 +1,174 @@ +/* W5500 Ethernet bring-up. This is a thin driver shim, same kind of thing + * as can_bus_twai.c: it brings up a peripheral and reports link/IP state, + * owns no task and no queue of its own. Lives in Master's HAL because only + * Master has Ethernet - it isn't a reusable lib. */ +#include "eth_w5500.h" + +#include +#include "sdkconfig.h" +#include "esp_log.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "esp_eth.h" +#include "esp_mac.h" +#include "esp_idf_version.h" +#include "driver/gpio.h" +#include "driver/spi_master.h" +#include "freertos/event_groups.h" +#include "lwip/ip4_addr.h" + +static const char *TAG = "eth"; + +#define BIT_LINK BIT0 +#define BIT_IP BIT1 + +static EventGroupHandle_t s_eg; +static esp_netif_t *s_netif; + +#if CONFIG_TTC_USE_STATIC_IP +static void apply_static_ip(void) +{ + esp_err_t err = esp_netif_dhcpc_stop(s_netif); + if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { + ESP_LOGE(TAG, "dhcpc_stop failed: %s", esp_err_to_name(err)); + return; + } + + esp_netif_ip_info_t ip = {0}; + ip.ip.addr = ipaddr_addr(CONFIG_TTC_STATIC_IP); + ip.netmask.addr = ipaddr_addr(CONFIG_TTC_STATIC_NETMASK); + ip.gw.addr = ipaddr_addr(CONFIG_TTC_STATIC_GW); + + err = esp_netif_set_ip_info(s_netif, &ip); + if (err != ESP_OK) { + ESP_LOGE(TAG, "set_ip_info failed: %s", esp_err_to_name(err)); + return; + } + ESP_LOGI(TAG, "static IP " IPSTR " / " IPSTR, IP2STR(&ip.ip), IP2STR(&ip.netmask)); + xEventGroupSetBits(s_eg, BIT_IP); +} +#endif + +static void eth_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data) +{ + esp_eth_handle_t h = *(esp_eth_handle_t *)data; + uint8_t mac[6] = {0}; + + switch (id) { + case ETHERNET_EVENT_CONNECTED: + esp_eth_ioctl(h, ETH_CMD_G_MAC_ADDR, mac); + ESP_LOGI(TAG, "link UP mac %02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + xEventGroupSetBits(s_eg, BIT_LINK); +#if CONFIG_TTC_USE_STATIC_IP + apply_static_ip(); +#endif + break; + case ETHERNET_EVENT_DISCONNECTED: + ESP_LOGW(TAG, "link DOWN"); + xEventGroupClearBits(s_eg, BIT_LINK | BIT_IP); + break; + case ETHERNET_EVENT_START: + ESP_LOGI(TAG, "driver started"); + break; + case ETHERNET_EVENT_STOP: + ESP_LOGW(TAG, "driver stopped"); + break; + default: + break; + } +} + +static void got_ip_handler(void *arg, esp_event_base_t base, int32_t id, void *data) +{ + const ip_event_got_ip_t *e = (const ip_event_got_ip_t *)data; + ESP_LOGI(TAG, "got IP " IPSTR, IP2STR(&e->ip_info.ip)); + xEventGroupSetBits(s_eg, BIT_IP); +} + +esp_err_t ttc_eth_init(void) +{ + s_eg = xEventGroupCreate(); + + esp_netif_config_t netif_cfg = ESP_NETIF_DEFAULT_ETH(); + s_netif = esp_netif_new(&netif_cfg); + + ESP_ERROR_CHECK(esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, eth_event_handler, NULL)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, got_ip_handler, NULL)); + + /* ISR service is needed for the W5500 INT line */ + esp_err_t err = gpio_install_isr_service(0); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) return err; + + spi_bus_config_t bus = { + .miso_io_num = CONFIG_TTC_ETH_MISO_GPIO, + .mosi_io_num = CONFIG_TTC_ETH_MOSI_GPIO, + .sclk_io_num = CONFIG_TTC_ETH_SCLK_GPIO, + .quadwp_io_num = -1, + .quadhd_io_num = -1, + }; + ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &bus, SPI_DMA_CH_AUTO)); + + spi_device_interface_config_t dev = { + .mode = 0, + .clock_speed_hz = CONFIG_TTC_ETH_SPI_CLOCK_MHZ * 1000 * 1000, + .queue_size = 20, + .spics_io_num = CONFIG_TTC_ETH_CS_GPIO, + }; + + eth_w5500_config_t w5500_cfg = ETH_W5500_DEFAULT_CONFIG(SPI2_HOST, &dev); + w5500_cfg.int_gpio_num = CONFIG_TTC_ETH_INT_GPIO; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0) + if (CONFIG_TTC_ETH_INT_GPIO < 0) { + w5500_cfg.poll_period_ms = CONFIG_TTC_ETH_POLL_PERIOD_MS; + } +#endif + + eth_mac_config_t mac_cfg = ETH_MAC_DEFAULT_CONFIG(); + eth_phy_config_t phy_cfg = ETH_PHY_DEFAULT_CONFIG(); + phy_cfg.reset_gpio_num = CONFIG_TTC_ETH_RST_GPIO; + + esp_eth_mac_t *mac = esp_eth_mac_new_w5500(&w5500_cfg, &mac_cfg); + esp_eth_phy_t *phy = esp_eth_phy_new_w5500(&phy_cfg); + if (!mac || !phy) { + ESP_LOGE(TAG, "failed to create W5500 mac/phy"); + return ESP_FAIL; + } + + static esp_eth_handle_t handle; /* static: referenced by event data */ + esp_eth_config_t eth_cfg = ETH_DEFAULT_CONFIG(mac, phy); + err = esp_eth_driver_install(ð_cfg, &handle); + if (err != ESP_OK) { + /* Most common cause on bring-up: wrong SPI pins / no power to W5500 */ + ESP_LOGE(TAG, "driver install failed (%s) - check SPI wiring and pins", + esp_err_to_name(err)); + return err; + } + + /* W5500 has no factory MAC; use the ESP32's eFuse-derived Ethernet MAC */ + uint8_t mac_addr[6]; + ESP_ERROR_CHECK(esp_read_mac(mac_addr, ESP_MAC_ETH)); + ESP_ERROR_CHECK(esp_eth_ioctl(handle, ETH_CMD_S_MAC_ADDR, mac_addr)); + + ESP_ERROR_CHECK(esp_netif_attach(s_netif, esp_eth_new_netif_glue(handle))); + ESP_ERROR_CHECK(esp_eth_start(handle)); + + ESP_LOGI(TAG, "W5500 up: SCLK=%d MOSI=%d MISO=%d CS=%d INT=%d RST=%d @ %d MHz", + CONFIG_TTC_ETH_SCLK_GPIO, CONFIG_TTC_ETH_MOSI_GPIO, CONFIG_TTC_ETH_MISO_GPIO, + CONFIG_TTC_ETH_CS_GPIO, CONFIG_TTC_ETH_INT_GPIO, CONFIG_TTC_ETH_RST_GPIO, + CONFIG_TTC_ETH_SPI_CLOCK_MHZ); + return ESP_OK; +} + +bool ttc_eth_ready(void) +{ + const EventBits_t need = BIT_LINK | BIT_IP; + return (xEventGroupGetBits(s_eg) & need) == need; +} + +bool ttc_eth_wait_ready(TickType_t timeout) +{ + const EventBits_t need = BIT_LINK | BIT_IP; + EventBits_t b = xEventGroupWaitBits(s_eg, need, pdFALSE, pdTRUE, timeout); + return (b & need) == need; +} \ No newline at end of file diff --git a/src/Master/HAL/esp32/eth_w5500.h b/src/Master/HAL/esp32/eth_w5500.h new file mode 100644 index 0000000..0b7e139 --- /dev/null +++ b/src/Master/HAL/esp32/eth_w5500.h @@ -0,0 +1,18 @@ +#ifndef HELIA_ETH_W5500_H +#define HELIA_ETH_W5500_H + +#include +#include "esp_err.h" +#include "freertos/FreeRTOS.h" + +/* Bring up SPI + W5500 + esp_netif and start the driver. Non-blocking: + * link/IP arrive later via events. */ +esp_err_t ttc_eth_init(void); + +/* True once the cable is up AND the interface has an IP. */ +bool ttc_eth_ready(void); + +/* Block until ready or timeout. Returns ttc_eth_ready(). */ +bool ttc_eth_wait_ready(TickType_t timeout); + +#endif \ No newline at end of file From d27ba868f9d38355838d637cb57ab16a083b33ca Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:42:41 +0100 Subject: [PATCH 32/34] ESP32 drivers --- src/Master/HAL/esp32/hal_esp32.c | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/Master/HAL/esp32/hal_esp32.c b/src/Master/HAL/esp32/hal_esp32.c index e69de29..8d489c2 100644 --- a/src/Master/HAL/esp32/hal_esp32.c +++ b/src/Master/HAL/esp32/hal_esp32.c @@ -0,0 +1,40 @@ +/* Master HAL for the real board. */ +#include "hal.h" +#include "pins.h" +#include "can_bus.h" +#include "eth_w5500.h" +#include "esp_system.h" +#include "esp_netif.h" +#include "esp_event.h" +#include "esp_log.h" + +static const char *TAG = "master_hal"; + +bool hal_init(void) +{ + if (!can_bus_init(PIN_CAN_TX, PIN_CAN_RX)) return false; + + /* esp_netif/esp_event are process-wide singletons; ESP_ERR_INVALID_STATE + * just means something else already brought them up, which is fine. */ + esp_err_t err = esp_netif_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_netif_init failed: %s - continuing without TT&C", esp_err_to_name(err)); + return true; + } + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGE(TAG, "esp_event_loop_create_default failed: %s - continuing without TT&C", + esp_err_to_name(err)); + return true; + } + + if (ttc_eth_init() != ESP_OK) { + ESP_LOGE(TAG, "TT&C Ethernet init failed - continuing without ground link"); + } + return true; +} + +void hal_restart(void) +{ + esp_restart(); +} \ No newline at end of file From 3f64f04f3e55277c96f0a5f227a0b1ad7fb46074 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:42:53 +0100 Subject: [PATCH 33/34] finished HAL set up --- src/Master/HAL/esp32/pins.h | 10 ++++++++++ src/Master/HAL/hal.h | 32 ++++++++++++++++++++++++++++++++ src/Master/HAL/sim/hal_sim.c | 16 ++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 src/Master/HAL/esp32/pins.h create mode 100644 src/Master/HAL/hal.h diff --git a/src/Master/HAL/esp32/pins.h b/src/Master/HAL/esp32/pins.h new file mode 100644 index 0000000..5dc4cc9 --- /dev/null +++ b/src/Master/HAL/esp32/pins.h @@ -0,0 +1,10 @@ +/** @file pins.h @brief Master board wiring. Only the ESP32 HAL includes this. */ +#ifndef HELIA_MASTER_PINS_H +#define HELIA_MASTER_PINS_H + +/* From the latest platformio.ini. TODO: confirm against the Master KiCad + * (the older config used TX=42, RX=41). */ +#define PIN_CAN_TX 34 +#define PIN_CAN_RX 33 + +#endif /* HELIA_MASTER_PINS_H */ \ No newline at end of file diff --git a/src/Master/HAL/hal.h b/src/Master/HAL/hal.h new file mode 100644 index 0000000..ef49740 --- /dev/null +++ b/src/Master/HAL/hal.h @@ -0,0 +1,32 @@ +/** + * @file hal.h + * @brief Master hardware interface: CAN bus and TT&C Ethernet. SD cards and + * RTC are added when storage is built out. + */ +#ifndef HELIA_MASTER_HAL_H +#define HELIA_MASTER_HAL_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Bring up the CAN bus and the TT&C Ethernet link. + * @return False only if CAN failed - that's fatal, Master is useless + * without the bus. A failed Ethernet bring-up is logged but not + * fatal: Master must keep running CAN/flight logic with no ground + * link, since link loss is an expected degraded state (the flight + * manager holds phase until reconnection), not a reason to halt. + */ +bool hal_init(void); + +/** @brief Reboot the node. Does not return. */ +void hal_restart(void); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_MASTER_HAL_H */ \ No newline at end of file diff --git a/src/Master/HAL/sim/hal_sim.c b/src/Master/HAL/sim/hal_sim.c index e69de29..882ebff 100644 --- a/src/Master/HAL/sim/hal_sim.c +++ b/src/Master/HAL/sim/hal_sim.c @@ -0,0 +1,16 @@ +/* Master HAL for running on a laptop. */ +#include "hal.h" +#include +#include + +bool hal_init(void) +{ + /* The simulated CAN bus is added with the simulator step. */ + return true; +} + +void hal_restart(void) +{ + printf("[sim] restart requested\n"); + exit(0); +} \ No newline at end of file From 2ee8f9ea7069348e9f59367f5f6170b469002974 Mon Sep 17 00:00:00 2001 From: Remy <107559570+hrszpuk@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:42:59 +0100 Subject: [PATCH 34/34] TTC code --- src/Master/ttc.c | 212 +++++++++++++++++++++++++++++++++++++++++++++++ src/Master/ttc.h | 48 +++++++++++ 2 files changed, 260 insertions(+) create mode 100644 src/Master/ttc.c create mode 100644 src/Master/ttc.h diff --git a/src/Master/ttc.c b/src/Master/ttc.c new file mode 100644 index 0000000..0bfd2ca --- /dev/null +++ b/src/Master/ttc.c @@ -0,0 +1,212 @@ +#include "ttc.h" + +#include + +/* Wire format constants (SED "Downlink (UDP)" proposal). Needed on host too, + * since ttc_pack_downlink() is pure byte-packing and is built there. */ +#define TTC_DL_HDR_LEN 4 +#define TTC_DL_FRAME_LEN 11 + +size_t ttc_pack_downlink(uint8_t *pkt, size_t pkt_len, uint8_t seq, const can_frame_t *f) +{ + if (pkt_len < TTC_DL_HDR_LEN + TTC_DL_FRAME_LEN) return 0; + + const uint8_t dlc = f->dlc > 8 ? 8 : f->dlc; + pkt[0] = seq; + pkt[1] = (uint8_t)can_id_priority(f->id); + pkt[2] = 0; + pkt[3] = TTC_DL_FRAME_LEN; + pkt[4] = (uint8_t)(f->id >> 8); /* f->id is 11 bits: fits one byte plus 3 bits */ + pkt[5] = (uint8_t)(f->id & 0xFF); + pkt[6] = dlc; + memset(&pkt[7], 0, 8); + memcpy(&pkt[7], f->data, dlc); + return TTC_DL_HDR_LEN + TTC_DL_FRAME_LEN; +} + +/* Everything below owns a task and sockets, so it's ESP-IDF-only - guarded + * the same way as control.c, so the host node_Master target (linked against + * HAL/sim/) builds this file down to just ttc_pack_downlink() above, + * host-testable like any other pure helper. */ +#ifdef ESP_PLATFORM + +#include +#include "sdkconfig.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lwip/sockets.h" + +#include "eth_w5500.h" +#include "control.h" +#include "protocol.h" + +static const char *TAG = "master_ttc"; + +#define RX_BUF_LEN 64 + +/* Wire format constants (SED "Uplink (TCP)" proposal - see ttc_ground.py). */ +#define TTC_UL_SYNC 0xA5 +#define TTC_UL_HDR_LEN 4 +#define TTC_UL_MAX_PAYLOAD 8 +#define TTC_ACK_HDR_LEN 5 +#define TTC_UL_FLAG_TEST 0x80 +#define TTC_TEST_PING 0x01 +#define TTC_ACK_OK 0x00 +#define TTC_ACK_BAD_LEN 0x01 +#define TTC_ACK_BAD_PHASE 0x02 + +static bool send_all(int sock, const uint8_t *buf, size_t len) +{ + while (len) { + int n = send(sock, buf, len, 0); + if (n <= 0) return false; + buf += n; + len -= (size_t)n; + } + return true; +} + +static void send_ack(int sock, uint8_t cmd, uint8_t flags, uint8_t status, + const uint8_t *payload, uint8_t len) +{ + uint8_t ack[TTC_ACK_HDR_LEN + TTC_UL_MAX_PAYLOAD]; + if (len > TTC_UL_MAX_PAYLOAD) len = TTC_UL_MAX_PAYLOAD; + ack[0] = TTC_UL_SYNC; + ack[1] = cmd; + ack[2] = flags; + ack[3] = status; + ack[4] = len; + if (len) memcpy(&ack[TTC_ACK_HDR_LEN], payload, len); + send_all(sock, ack, TTC_ACK_HDR_LEN + len); +} + +/* No general CAN forwarding yet - see ttc.h. Two commands are handled for + * real so far: + * - TTC_TEST_PING (payload echoed back) so a round-trip time can be + * measured with nothing else in the loop. + * - CAN_CMD_FLIGHT_PHASE: the first command actually gated through the + * flight manager (control.c) before anything happens, rather than + * just being logged and ACKed. This is the pattern every other + * command will eventually follow. + * Everything else is still just logged and ACKed OK, which is enough to + * prove ground's command reached the board. */ +static void handle_command(int sock, uint8_t cmd, uint8_t flags, const uint8_t *p, uint8_t len) +{ + if ((flags & TTC_UL_FLAG_TEST) && cmd == TTC_TEST_PING) { + send_ack(sock, cmd, flags, TTC_ACK_OK, p, len); + return; + } + + if (cmd == CAN_CMD_FLIGHT_PHASE) { + if (len < 1) { + ESP_LOGW(TAG, "SET_FLIGHT_PHASE: missing phase byte"); + send_ack(sock, cmd, flags, TTC_ACK_BAD_LEN, NULL, 0); + return; + } + master_phase_t requested = (master_phase_t)p[0]; + master_flight_result_t result = master_control_set_phase(requested); + if (result == MASTER_FLIGHT_REJECTED) { + ESP_LOGW(TAG, "SET_FLIGHT_PHASE: rejected phase byte 0x%02X", p[0]); + send_ack(sock, cmd, flags, TTC_ACK_BAD_PHASE, NULL, 0); + } else { + send_ack(sock, cmd, flags, TTC_ACK_OK, p, len); + } + return; + } + + ESP_LOGI(TAG, "uplink cmd 0x%02X flags 0x%02X len=%u (not forwarded to CAN yet)", + cmd, flags, len); + send_ack(sock, cmd, flags, TTC_ACK_OK, p, len); +} + +/* Parse as many complete commands as the buffer holds; keep the remainder. */ +static void process_stream(int sock, uint8_t *buf, size_t *fill) +{ + size_t pos = 0; + + for (;;) { + while (pos < *fill && buf[pos] != TTC_UL_SYNC) pos++; + if (*fill - pos < TTC_UL_HDR_LEN) break; + + const uint8_t cmd = buf[pos + 1]; + const uint8_t flags = buf[pos + 2]; + const uint8_t len = buf[pos + 3]; + + if (len > TTC_UL_MAX_PAYLOAD) { + send_ack(sock, cmd, flags, TTC_ACK_BAD_LEN, NULL, 0); + pos++; /* resync from next byte */ + continue; + } + if (*fill - pos < (size_t)TTC_UL_HDR_LEN + len) break; /* need more */ + + handle_command(sock, cmd, flags, &buf[pos + TTC_UL_HDR_LEN], len); + pos += TTC_UL_HDR_LEN + len; + } + + memmove(buf, buf + pos, *fill - pos); + *fill -= pos; +} + +static void serve_client(int sock) +{ + ESP_LOGI(TAG, "ground connected"); + int one = 1; + setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + + uint8_t buf[RX_BUF_LEN]; + size_t fill = 0; + + for (;;) { + int n = recv(sock, buf + fill, sizeof(buf) - fill, 0); + if (n == 0) { ESP_LOGW(TAG, "ground closed connection"); break; } + if (n < 0) { ESP_LOGW(TAG, "recv error: errno %d", errno); break; } + fill += (size_t)n; + process_stream(sock, buf, &fill); + if (fill == sizeof(buf)) fill = 0; /* can't happen with valid framing */ + } +} + +void ttc_uplink_task(void *arg) +{ + (void)arg; + for (;;) { + ttc_eth_wait_ready(portMAX_DELAY); + + int lsock = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); + if (lsock < 0) { + ESP_LOGE(TAG, "TCP socket failed: errno %d", errno); + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + int one = 1; + setsockopt(lsock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_port = htons(CONFIG_TTC_TCP_UPLINK_PORT), + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + if (bind(lsock, (struct sockaddr *)&addr, sizeof(addr)) != 0 || listen(lsock, 1) != 0) { + ESP_LOGE(TAG, "bind/listen failed: errno %d", errno); + close(lsock); + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + ESP_LOGI(TAG, "uplink listening on TCP %d", CONFIG_TTC_TCP_UPLINK_PORT); + + for (;;) { + int csock = accept(lsock, NULL, NULL); + if (csock < 0) { + ESP_LOGW(TAG, "accept failed: errno %d", errno); + break; /* rebuild listening socket */ + } + serve_client(csock); + shutdown(csock, SHUT_RDWR); + close(csock); + } + close(lsock); + } +} + +#endif /* ESP_PLATFORM */ \ No newline at end of file diff --git a/src/Master/ttc.h b/src/Master/ttc.h new file mode 100644 index 0000000..ac5da68 --- /dev/null +++ b/src/Master/ttc.h @@ -0,0 +1,48 @@ +/** + * @file ttc.h + * @brief Master's TT&C link handling: bare minimum to prove Master and a + * ground script can talk. Deliberately simple - no queues, no CAN + * forwarding, no flight-manager gating yet: + * - ttc_pack_downlink() is a pure helper comms_task uses to mirror + * the CAN heartbeat it already sends, over UDP. + * - ttc_uplink_task() is a TCP listener that logs and ACKs + * whatever ground sends. It doesn't forward anything to CAN. + * + * Lives here (not libs/) because it owns a task and sockets - that's + * active behaviour specific to Master, not a reusable pure utility. + */ +#ifndef HELIA_MASTER_TTC_H +#define HELIA_MASTER_TTC_H + +#include +#include +#include "can.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Exact size of a packed downlink packet (4-byte header + 11-byte + * frame). Size the buffer passed to ttc_pack_downlink() with this. */ +#define TTC_DL_PACKET_LEN 15u + +/** + * @brief Pack one CAN frame into the SED UDP downlink wire format + * (header + 11-byte frame). Pure byte-packing, no I/O. + * @return Bytes written to @p pkt (TTC_DL_PACKET_LEN), or 0 if @p pkt_len + * is too small. + */ +size_t ttc_pack_downlink(uint8_t *pkt, size_t pkt_len, uint8_t seq, const can_frame_t *f); + +/** + * @brief TCP uplink listener. Accepts one ground connection at a time, + * logs and ACKs each command it parses. Pinned to core 0 by + * setup.c, alongside comms_task. + */ +void ttc_uplink_task(void *arg); + +#ifdef __cplusplus +} +#endif + +#endif /* HELIA_MASTER_TTC_H */ \ No newline at end of file