diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 0e09fc0..be846ef 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -124,7 +124,8 @@ jobs: run: | apt-get update apt-get install -y --no-install-recommends git ca-certificates g++ meson ninja-build pkg-config \ - libboost1.81-dev libboost-json1.81-dev libboost-thread1.81-dev libboost-test1.81-dev + libboost1.81-dev libboost-json1.81-dev libboost-thread1.81-dev libboost-test1.81-dev \ + libpaho-mqtt-dev libpaho-mqttpp-dev libcurl4-openssl-dev mosquitto mosquitto-clients python3 - uses: actions/checkout@v5 @@ -132,4 +133,11 @@ jobs: run: meson setup build-test -Dhardware=false -Db_sanitize=${{ matrix.sanitizer }} -Db_lundef=false && meson compile -C build-test - name: Test + if: matrix.sanitizer != 'thread' run: meson test -C build-test --print-errorlogs + + # paho and mosquitto are not built with ThreadSanitizer, so the novaMock + # end-to-end test (suite "integration") runs in the other jobs only. + - name: Test (unit tests only) + if: matrix.sanitizer == 'thread' + run: meson test -C build-test --print-errorlogs --no-suite integration diff --git a/docs/setup.md b/docs/setup.md index 5500497..20a2fe2 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -86,7 +86,7 @@ This builds three executables from the same code: |---|---|---|---| | `novaGround` | 1 ms | 50 ms | The main GSS Pi | | `novaThermo` | 100 ms | 250 ms | The thermocouple Pi (MCC 134) | -| `novaMock` | 100 ms | 250 ms | No hardware initialisation: development and simulator tests | +| `novaMock` | 100 ms | 250 ms | The full program on simulated hardware (see [simulation.md](simulation.md)): development and simulator tests | ## Run diff --git a/docs/simulation.md b/docs/simulation.md new file mode 100644 index 0000000..b034cb8 --- /dev/null +++ b/docs/simulation.md @@ -0,0 +1,74 @@ +# Simulated hardware (novaMock) + +`novaMock` is the real novaGround program, built against simulated hardware +instead of the Pi's. The sampler, logger, publisher, command router and the +relay, servo, GPIO and data-file controllers are the same code that runs on the +Pi; only the bottom layer is replaced: + +| Layer | On the Pi | In novaMock | +|---|---|---| +| MCC 128 / 134 | MCC's `libdaqhats` | `libdaqhats_sim` (`src/sim/daqhats_sim.cpp`), the same `MccDaqHats` driver on top | +| Relays (TCA9535) | `LinuxI2cBus` | `SimRelayBank` (in memory) | +| Servos (PCA9685) | `LinuxI2cBus`, SMBus | `SimPwmDriver` (in memory) | +| GPIO | libgpiod | `SimDigitalIo` (in memory) | + +`main()` asks `build_hardware()` (declared in `src/interfaces/hardware.hpp`) for +a `Hardware`: the DAQ, the opened HATs, the servo, the relays and the GPIO. Which +machine that is gets decided when building: `novaGround` and `novaThermo` link +`src/drivers/hardware_real.cpp`, `novaMock` links `src/sim/hardware_sim.cpp`. + +It starts the same way (`./build/novaMock --broker localhost`) and needs only +paho and Boost to build, so it builds on a laptop or in CI. + +## Scenario files + +The simulated sensors and faults come from a JSON file named by the +`NOVA_SIM_SCENARIO` environment variable. Without it, a default scenario is +used: two MCC 128s with the same sine waves as the backend's `novaSim.py` GCS +sensors. If the variable names a file that cannot be read, the simulator fails +(and says why) rather than quietly simulating something else. + +```json +{ + "seed": 1, + "hats": [ + { "address": 0, "type": "mcc128", + "channels": { "0": { "wave": "sine", "min": 0.99, "max": 3.99, "period": 20, "noise": 0.02 }, + "3": { "wave": "flat", "value": 2.5 } } }, + { "address": 1, "type": "mcc134", "channels": { "0": { "wave": "flat", "value": 21.5 } } } + ], + "faults": { + "read_errors": [ { "address": 0, "channel": 5 } ], + "missing_hats": [ 2 ], + "read_delay_us": 0 + }, + "gpio_inputs": { "5": 1, "6": 0 } +} +``` + +- **`hats`**: the HATs on the bus. `type` is `mcc128` (8 channels) or `mcc134` + (4 channels). A channel with no entry reads as a flat 0. +- **Waveforms** use the backend simulator's model: `flat`, `sine`, `square`, + `triangle`, `sawtooth`, `s_curve`, `ramp` and `noise`, with `min`, `max`, + `period` (seconds), `noise` (added +/- amplitude) and, for `flat`, `value`. + `seed` makes the noise repeatable. Time is measured from the start of the + program. +- **`faults`**: `read_errors` make one channel fail every read (published as + NaN, like a real failed read); `missing_hats` lists hats that are in the file + but not found on the bus; `read_delay_us` slows every read, to provoke + sample-period overruns. +- **`gpio_inputs`**: the level each input pin reads (default low). + +## Tests + +- `meson test -C build` (with `-Dhardware=false` if you do not have the Pi's + libraries) runs the unit tests, including the simulated library and the real + `MccDaqHats` driver over it. +- `novamock_smoke` (suite `integration`) starts a private mosquitto on a free + loopback port, runs novaMock with a scenario, and checks the published + telemetry and a recorded CSV. It needs `mosquitto`, `mosquitto_sub`, + `mosquitto_pub` and `python3`, and never touches port 1883. + +The simulated headers in `src/sim/include/daqhats/` are not MCC's; they declare +only what novaGround calls, with this simulator's own constants. A real build +uses MCC's headers. diff --git a/meson.build b/meson.build index ab9cc58..cdbee20 100644 --- a/meson.build +++ b/meson.build @@ -12,7 +12,6 @@ boost_dep = dependency('boost', modules : ['thread','json']) threads_dep = dependency('threads') #### Grab source files -src = [] core_src = [] include = [] @@ -20,7 +19,8 @@ include += include_directories('src') subdir('src') -# Hardware-free code: core/ and utils/ need only Boost and the standard library. +# Hardware-free code: core/, utils/, the controllers, the chip drivers (over their +# bus/interface seams) and the simulator need only Boost and the standard library. nova_core = static_library( 'nova_core', sources: core_src, @@ -28,26 +28,109 @@ nova_core = static_library( include_directories: include, ) +# The simulated libdaqhats (MCC's C API, backed by a scenario file), and the real +# MCC driver compiled against its headers, so the same driver code runs with and +# without the HATs. +sim_include = include_directories('src/sim/include') +daqhats_sim = static_library( + 'daqhats_sim', + sim_daqhats_src, + dependencies: [boost_dep, threads_dep], + include_directories: [sim_include] + include, + link_with: nova_core, +) +mcc_driver_sim = static_library( + 'mcc_driver_sim', + mcc_driver_src, + include_directories: [sim_include] + include, + link_with: daqhats_sim, +) +sim_libs = [mcc_driver_sim, daqhats_sim, nova_core] + subdir('tests') +# paho (MQTT) and libcurl: needed by the application, not by the library or tests. +pahomqttc_lib1 = cpp.find_library('paho-mqttpp3', required: hardware) +pahomqttc_lib2 = cpp.find_library('paho-mqtt3as', required: hardware) +have_paho = pahomqttc_lib1.found() and pahomqttc_lib2.found() +libpaho = declare_dependency(dependencies: [pahomqttc_lib1, pahomqttc_lib2]) + +# libcurl library (optional) +curl_dep = dependency('libcurl', required: false) + +common_cpp_args = [ + '-DNOVA_DEFAULT_BROKER="localhost:1883"', + '-DNOVA_DEFAULT_BACKEND="http://localhost:8000"', + '-DNOVA_DEFAULT_VERBOSITY=1', +] + +if curl_dep.found() + common_cpp_args += '-DHAVE_LIBCURL' +endif + +# novaMock: the real application on simulated hardware. Needs only paho, so it +# builds on a laptop or in CI. See docs/simulation.md. +if have_paho + mock_deps = [boost_dep, libpaho, threads_dep] + if curl_dep.found() + mock_deps += curl_dep + endif + novamock = executable( + 'novaMock', + sources: app_src + sim_app_src, + dependencies: mock_deps, + include_directories: include, + link_with: sim_libs, + cpp_args: common_cpp_args + [ + '-DNOVA_BUILD_ID="novaMock"', + '-DNOVA_DEFAULT_SAMPLE_MS=100', + '-DNOVA_DEFAULT_PUBLISH_MS=250', + ], + ) + + # End to end on a private broker: telemetry carries the scenario's values and + # a data-saving command records them. Needs mosquitto and python3. + smoke_tools = [ + find_program('mosquitto', required: false), + find_program('mosquitto_sub', required: false), + find_program('mosquitto_pub', required: false), + find_program('python3', required: false), + ] + smoke_possible = true + foreach tool : smoke_tools + smoke_possible = smoke_possible and tool.found() + endforeach + if smoke_possible + test( + 'novamock_smoke', + find_program('bash'), + args: [files('tests/novamock_smoke.sh'), novamock], + suite: 'integration', + timeout: 90, + ) + else + message('mosquitto or python3 not found: not registering the novaMock smoke test') + endif +else + message('paho MQTT not found: not building novaMock') +endif + if not hardware subdir_done() endif # MCC Daq libraries daqlib = cpp.find_library('daqhats', dirs: '/usr/local/lib', required: false) -pahomqttc_lib1 = cpp.find_library('paho-mqttpp3', required: true) -pahomqttc_lib2 = cpp.find_library('paho-mqtt3as', required: true) -libpaho = declare_dependency(dependencies: [pahomqttc_lib1, pahomqttc_lib2]) -# WiringPi library +# WiringPi library. The servo driver no longer uses it (it goes through +# LinuxI2cBus); the dependency stays until during or after the virtual-device +# work so that change can be reversed. wiringpi_dep = cpp.find_library('wiringPi', required: true) # libgpiod library gpiod_dep = dependency('libgpiod', version: '>=1.6', required: true) -# libcurl library (optional) -curl_dep = dependency('libcurl', required: false) +src = core_src + app_src + real_src #### TODO: Set the clang-tidy and clang-format paths as env variables clangtidy = find_program('clang-tidy', '/usr/local/Cellar/llvm/17.0.6_1/bin/clang-tidy', required: false) @@ -87,11 +170,10 @@ else endif -deps = [boost_dep,libpaho, wiringpi_dep, gpiod_dep] +deps = [boost_dep, libpaho, wiringpi_dep, gpiod_dep, threads_dep] if curl_dep.found() deps += curl_dep - add_project_arguments('-DHAVE_LIBCURL', language: 'cpp') endif #### Check for library import success @@ -99,17 +181,12 @@ if daqlib.found() deps += daqlib endif -common_cpp_args = [ - '-DNOVA_DEFAULT_BROKER="localhost:1883"', - '-DNOVA_DEFAULT_BACKEND="http://localhost:8000"', - '-DNOVA_DEFAULT_VERBOSITY=1', -] - executable( 'novaGround', - sources: src, + sources: app_src + real_src, dependencies: deps, include_directories: include, + link_with: nova_core, cpp_args: common_cpp_args + [ '-DNOVA_BUILD_ID="novaGround"', '-DNOVA_DEFAULT_SAMPLE_MS=1', @@ -119,25 +196,13 @@ executable( executable( 'novaThermo', - sources: src, + sources: app_src + real_src, dependencies: deps, include_directories: include, + link_with: nova_core, cpp_args: common_cpp_args + [ '-DNOVA_BUILD_ID="novaThermo"', '-DNOVA_DEFAULT_SAMPLE_MS=100', '-DNOVA_DEFAULT_PUBLISH_MS=250', ], ) - -executable( - 'novaMock', - sources: src, - dependencies: deps, - include_directories: include, - cpp_args: common_cpp_args + [ - '-DNOVA_BUILD_ID="novaMock"', - '-DNOVA_DEFAULT_SAMPLE_MS=100', - '-DNOVA_DEFAULT_PUBLISH_MS=250', - '-DNOVA_MOCK_MODE=1', - ], -) diff --git a/src/controllers/gpio_controller.cpp b/src/controllers/gpio_controller.cpp index 4d220b9..c2fb7d5 100644 --- a/src/controllers/gpio_controller.cpp +++ b/src/controllers/gpio_controller.cpp @@ -4,11 +4,11 @@ #include "utils/json_utils.hpp" -GpioController::GpioController(GPIO_Manager* manager, DataLogger* logger) - : manager_(manager), logger_(logger) {} +GpioController::GpioController(DigitalIo* gpio, DataLogger* logger) + : gpio_(gpio), logger_(logger) {} bool GpioController::available() const { - return manager_ != nullptr; + return gpio_ != nullptr; } void GpioController::handle_command(const boost::json::object& cmd) { @@ -27,16 +27,16 @@ void GpioController::handle_command(const boost::json::object& cmd) { if (auto mode_opt = get_string(cmd, "mode")) { std::string mode = to_lower(*mode_opt); if (mode == "input") { - manager_->set_direction(pin, "in"); + gpio_->set_direction(pin, "in"); } else if (mode == "output") { - manager_->set_direction(pin, "out"); + gpio_->set_direction(pin, "out"); } else { std::cerr << "Invalid GPIO mode: " << mode << std::endl; } } if (auto state_opt = get_bool(cmd, "state")) { - manager_->write(pin, *state_opt ? 1 : 0); + gpio_->write(pin, *state_opt ? 1 : 0); if (logger_) { logger_->update_gpio(pin, *state_opt ? 1 : 0); } diff --git a/src/controllers/gpio_controller.hpp b/src/controllers/gpio_controller.hpp index fac582d..a6ac60b 100644 --- a/src/controllers/gpio_controller.hpp +++ b/src/controllers/gpio_controller.hpp @@ -3,17 +3,17 @@ #include #include "core/data_logger.hpp" -#include "interfaces/gpio_manager.hpp" +#include "interfaces/digital_io.hpp" class GpioController { public: - GpioController(GPIO_Manager* manager, DataLogger* logger); + GpioController(DigitalIo* gpio, DataLogger* logger); void handle_command(const boost::json::object& cmd); private: bool available() const; - GPIO_Manager* manager_ = nullptr; + DigitalIo* gpio_ = nullptr; DataLogger* logger_ = nullptr; }; diff --git a/src/controllers/loops.cpp b/src/controllers/loops.cpp index 8f82aec..f158946 100644 --- a/src/controllers/loops.cpp +++ b/src/controllers/loops.cpp @@ -6,6 +6,8 @@ #include #include +#include "utils/interruptible_sleep.hpp" + namespace json = boost::json; using namespace std::chrono; @@ -13,12 +15,14 @@ namespace { const std::string kTelemetryTopic = "nova/telemetry/engine"; } -void publisher_loop(mqtt::async_client_ptr cli, +void publisher_loop(std::stop_token stop, + mqtt::async_client_ptr cli, TelemetryStore& telemetry, std::string source_id, int publish_interval_ms) { int interval_ms = publish_interval_ms > 0 ? publish_interval_ms : 1; - while (true) { + InterruptibleSleep sleeper; + while (!stop.stop_requested()) { try { json::array json_sensor_data; json::array json_gpio_data; @@ -59,16 +63,24 @@ void publisher_loop(mqtt::async_client_ptr cli, std::cerr << "Publisher error: unknown exception" << std::endl; } - std::this_thread::sleep_for(milliseconds(interval_ms)); + if (sleeper.sleep_for(stop, milliseconds(interval_ms))) { + break; + } } } -void consumer_loop(mqtt::async_client_ptr cli, CommandRouter& router) { - while (true) { +void consumer_loop(std::stop_token stop, mqtt::async_client_ptr cli, CommandRouter& router) { + InterruptibleSleep sleeper; + while (!stop.stop_requested()) { try { - auto msg = cli ? cli->consume_message() : nullptr; - if (!msg) { - std::this_thread::sleep_for(milliseconds(1)); + if (!cli) { + sleeper.sleep_for(stop, milliseconds(1)); + continue; + } + // Wake up regularly so a stop request is noticed even when no + // message arrives. + mqtt::const_message_ptr msg; + if (!cli->try_consume_message_for(&msg, milliseconds(100)) || !msg) { continue; } @@ -81,17 +93,3 @@ void consumer_loop(mqtt::async_client_ptr cli, CommandRouter& router) { } } } - -void gpio_sampler_loop(GPIO_Manager& manager, TelemetryStore& telemetry) { - while (true) { - try { - auto input_vals = manager.read_all_inputs(); - telemetry.set_gpio_states(std::move(input_vals)); - } catch (const std::exception& e) { - std::cerr << "GPIO sampler error: " << e.what() << std::endl; - } catch (...) { - std::cerr << "GPIO sampler error: unknown exception" << std::endl; - } - std::this_thread::sleep_for(milliseconds(50)); - } -} diff --git a/src/controllers/loops.hpp b/src/controllers/loops.hpp index 13e71d7..0046108 100644 --- a/src/controllers/loops.hpp +++ b/src/controllers/loops.hpp @@ -2,17 +2,19 @@ #include "mqtt/async_client.h" +#include #include #include "core/command_router.hpp" #include "core/telemetry.hpp" -#include "interfaces/gpio_manager.hpp" -void publisher_loop(mqtt::async_client_ptr cli, +// Both loops run until a stop is requested; as the first parameter the token +// is passed automatically by std::jthread. +void publisher_loop(std::stop_token stop, + mqtt::async_client_ptr cli, TelemetryStore& telemetry, std::string source_id, int publish_interval_ms); -void consumer_loop(mqtt::async_client_ptr cli, CommandRouter& router); +void consumer_loop(std::stop_token stop, mqtt::async_client_ptr cli, CommandRouter& router); -void gpio_sampler_loop(GPIO_Manager& manager, TelemetryStore& telemetry); diff --git a/src/controllers/meson.build b/src/controllers/meson.build deleted file mode 100644 index ea943f0..0000000 --- a/src/controllers/meson.build +++ /dev/null @@ -1,7 +0,0 @@ -src += files( - 'servo_controller.cpp', - 'relay_controller.cpp', - 'gpio_controller.cpp', - 'data_file_controller.cpp', - 'loops.cpp' -) diff --git a/src/controllers/relay_controller.cpp b/src/controllers/relay_controller.cpp index 527483f..a7c69a2 100644 --- a/src/controllers/relay_controller.cpp +++ b/src/controllers/relay_controller.cpp @@ -4,7 +4,7 @@ #include "utils/json_utils.hpp" -RelayController::RelayController(TCA9535* expander, TelemetryStore* telemetry, DataLogger* logger) +RelayController::RelayController(RelayBank* expander, TelemetryStore* telemetry, DataLogger* logger) : expander_(expander), telemetry_(telemetry), logger_(logger) {} bool RelayController::available() const { diff --git a/src/controllers/relay_controller.hpp b/src/controllers/relay_controller.hpp index fe2031d..6138f9e 100644 --- a/src/controllers/relay_controller.hpp +++ b/src/controllers/relay_controller.hpp @@ -6,11 +6,11 @@ #include "core/data_logger.hpp" #include "core/telemetry.hpp" -#include "interfaces/io_expander.hpp" +#include "interfaces/relay_bank.hpp" class RelayController { public: - RelayController(TCA9535* expander, TelemetryStore* telemetry, DataLogger* logger); + RelayController(RelayBank* expander, TelemetryStore* telemetry, DataLogger* logger); void handle_command(const boost::json::object& cmd); void set_initial_state(const std::bitset<16>& state); @@ -18,7 +18,7 @@ class RelayController { private: bool available() const; - TCA9535* expander_ = nullptr; + RelayBank* expander_ = nullptr; TelemetryStore* telemetry_ = nullptr; DataLogger* logger_ = nullptr; std::bitset<16> relay_state_; diff --git a/src/controllers/sampler.cpp b/src/controllers/sampler.cpp new file mode 100644 index 0000000..26985ba --- /dev/null +++ b/src/controllers/sampler.cpp @@ -0,0 +1,100 @@ +#include "sampler.hpp" + +#include +#include +#include +#include + +#include "utils/interruptible_sleep.hpp" + +std::vector build_sensor_headers(const std::vector& hats) { + std::vector headers; + headers.push_back("timestamp"); + for (const auto& hat : hats) { + if (hat.channels.empty()) { + continue; + } + std::string prefix = "hat" + std::to_string(hat.address); + for (int channel : hat.channels) { + headers.push_back(prefix + "_ch" + std::to_string(channel)); + } + } + return headers; +} + +void daq_sample_once(AnalogIo& daq, + const std::vector& daq_hats, + TelemetryStore& store, + DataLogger* logger) { + std::vector new_data; + std::vector values; + size_t total_channels = 0; + for (const auto& hat : daq_hats) { + total_channels += hat.channels.size(); + } + new_data.reserve(total_channels); + values.reserve(total_channels); + + auto now = std::chrono::system_clock::now(); + double timestamp_ms = + std::chrono::duration_cast( + now.time_since_epoch()) + .count(); + + for (const auto& hat : daq_hats) { + for (int channel : hat.channels) { + double value = std::numeric_limits::quiet_NaN(); + bool ok = daq.read(hat, channel, value); + + SensorSample sd; + sd.hat_id = hat.address; + sd.channel_id = channel; + sd.value = ok ? value : std::numeric_limits::quiet_NaN(); + sd.timestamp_ms = timestamp_ms; + new_data.push_back(sd); + values.push_back(sd.value); + } + } + + store.set_sensors(std::move(new_data)); + if (logger && logger->is_active()) { + logger->log_sensor_row(timestamp_ms, values); + } +} + +void daq_sample_loop(std::stop_token stop, + AnalogIo& daq, + const std::vector& daq_hats, + TelemetryStore& store, + DataLogger* logger, + int sample_interval_ms) { + int interval_ms = sample_interval_ms > 0 ? sample_interval_ms : 1; + InterruptibleSleep sleeper; + while (!stop.stop_requested()) { + daq_sample_once(daq, daq_hats, store, logger); + if (sleeper.sleep_for(stop, std::chrono::milliseconds(interval_ms))) { + break; + } + } +} + +void gpio_sample_once(DigitalIo& gpio, TelemetryStore& store) { + try { + auto input_vals = gpio.read_all_inputs(); + store.set_gpio_states(std::move(input_vals)); + } catch (const std::exception& e) { + std::cerr << "GPIO sampler error: " << e.what() << std::endl; + } catch (...) { + std::cerr << "GPIO sampler error: unknown exception" << std::endl; + } +} + +void gpio_sample_loop(std::stop_token stop, DigitalIo& gpio, TelemetryStore& store, int interval_ms) { + InterruptibleSleep sleeper; + while (!stop.stop_requested()) { + gpio_sample_once(gpio, store); + if (sleeper.sleep_for(stop, std::chrono::milliseconds(interval_ms))) { + break; + } + } +} diff --git a/src/controllers/sampler.hpp b/src/controllers/sampler.hpp new file mode 100644 index 0000000..9f27608 --- /dev/null +++ b/src/controllers/sampler.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +#include "core/data_logger.hpp" +#include "core/telemetry.hpp" +#include "interfaces/analog_io.hpp" +#include "interfaces/digital_io.hpp" + +// Samplers: poll hardware and write the result into the TelemetryStore. Each +// has a *_once function (one pass, testable) and a *_loop that repeats it. + +// "timestamp", then hat
_ch for every channel of every HAT +// that has channels. Matches the order daq_sample_once logs values in. +std::vector build_sensor_headers(const std::vector& hats); + +// One pass over every channel: read it, store the samples, and log a row if +// the logger is recording. A failed read is NaN. +void daq_sample_once(AnalogIo& daq, + const std::vector& daq_hats, + TelemetryStore& store, + DataLogger* logger); + +// daq_sample_once every sample_interval_ms until a stop is requested. As the +// first parameter the token is passed automatically by std::jthread. +void daq_sample_loop(std::stop_token stop, + AnalogIo& daq, + const std::vector& daq_hats, + TelemetryStore& store, + DataLogger* logger, + int sample_interval_ms); + +// One pass: read every input pin and replace the stored GPIO states. An +// exception from the hardware is logged and swallowed, so the loop survives. +void gpio_sample_once(DigitalIo& gpio, TelemetryStore& store); + +// gpio_sample_once every interval_ms until a stop is requested. (No default +// for the interval: std::jthread cannot use default arguments.) +void gpio_sample_loop(std::stop_token stop, DigitalIo& gpio, TelemetryStore& store, int interval_ms); diff --git a/src/controllers/servo_controller.cpp b/src/controllers/servo_controller.cpp index 00e655f..59194c6 100644 --- a/src/controllers/servo_controller.cpp +++ b/src/controllers/servo_controller.cpp @@ -8,7 +8,7 @@ namespace { const int kServoDefaultMicros = 1500; } -ServoController::ServoController(Adafruit_PWMServoDriver* driver, TelemetryStore* telemetry, DataLogger* logger) +ServoController::ServoController(PwmDriver* driver, TelemetryStore* telemetry, DataLogger* logger) : driver_(driver), telemetry_(telemetry), logger_(logger) {} bool ServoController::available() const { diff --git a/src/controllers/servo_controller.hpp b/src/controllers/servo_controller.hpp index c81ab1f..c2db2bc 100644 --- a/src/controllers/servo_controller.hpp +++ b/src/controllers/servo_controller.hpp @@ -7,11 +7,11 @@ #include "core/data_logger.hpp" #include "core/telemetry.hpp" -#include "interfaces/servo.hpp" +#include "interfaces/pwm_driver.hpp" class ServoController { public: - ServoController(Adafruit_PWMServoDriver* driver, TelemetryStore* telemetry, DataLogger* logger); + ServoController(PwmDriver* driver, TelemetryStore* telemetry, DataLogger* logger); void handle_command(const boost::json::object& cmd); @@ -24,7 +24,7 @@ class ServoController { bool available() const; - Adafruit_PWMServoDriver* driver_ = nullptr; + PwmDriver* driver_ = nullptr; TelemetryStore* telemetry_ = nullptr; DataLogger* logger_ = nullptr; std::unordered_map states_; diff --git a/src/drivers/hardware_real.cpp b/src/drivers/hardware_real.cpp new file mode 100644 index 0000000..246b731 --- /dev/null +++ b/src/drivers/hardware_real.cpp @@ -0,0 +1,63 @@ +#include "interfaces/hardware.hpp" + +#include + +#include "drivers/io_expander.hpp" +#include "drivers/linux_gpio.hpp" +#include "drivers/linux_i2c_bus.hpp" +#include "drivers/mcc_daqhats.hpp" +#include "drivers/servo.hpp" + +namespace { +const int kI2CAddr = 0x20; +} + +Hardware build_hardware(const std::vector& output_pins, + const std::vector& input_pins) { + Hardware hw; + + hw.daq = std::make_unique(); + hw.daq_hats = discover_and_open(*hw.daq); + + // One bus (one file descriptor) per chip, as before the drivers took a bus. + hw.buses.push_back(std::make_unique("/dev/i2c-1", LinuxI2cBus::Transfer::Smbus)); + auto servo_driver = std::make_unique(*hw.buses.back()); + if (servo_driver->begin()) { + servo_driver->setPWMFreq(50); + hw.servo = std::move(servo_driver); + } + + hw.buses.push_back(std::make_unique("/dev/i2c-1")); + auto io_expander = std::make_unique(*hw.buses.back(), kI2CAddr); + if (io_expander->is_ready()) { + bool ok = io_expander->configure_port(0, 0x00) && + io_expander->configure_port(1, 0x00); + for (int i = 0; i < 16; i++) { + hw.relay_state.set(i, true); + } + ok = ok && io_expander->write_output(hw.relay_state); + if (ok) { + hw.relays = std::move(io_expander); + } else { + std::cerr << "IO expander setup failed; disabling relay control." << std::endl; + } + } else { + std::cerr << "IO expander not available." << std::endl; + } + + try { + auto gpio = std::make_unique("/dev/gpiochip0"); + for (auto pin : output_pins) { + gpio->set_direction(pin, "out"); + gpio->write(pin, 0); + } + for (auto pin : input_pins) { + gpio->set_direction(pin, "in"); + } + hw.gpio = std::move(gpio); + } catch (const std::exception& e) { + std::cerr << "GPIO Manager initialization failed: " << e.what() << std::endl; + } + + return hw; +} diff --git a/src/interfaces/io_expander.cpp b/src/drivers/io_expander.cpp similarity index 69% rename from src/interfaces/io_expander.cpp rename to src/drivers/io_expander.cpp index c2d63d4..00c1d56 100644 --- a/src/interfaces/io_expander.cpp +++ b/src/drivers/io_expander.cpp @@ -1,31 +1,23 @@ #include "io_expander.hpp" -TCA9535::TCA9535(const char* i2c_bus, uint8_t address) - : device_address(address) { - i2c_fd = open(i2c_bus, O_RDWR); - if (i2c_fd < 0) { +#include +#include + +TCA9535::TCA9535(I2cBus& bus, uint8_t address, std::chrono::milliseconds retry_delay) + : bus_(bus), device_address(address), retry_delay_(retry_delay) { + if (!bus_.is_ready()) { set_error("Failed to open I2C bus"); return; } - - if (ioctl(i2c_fd, I2C_SLAVE, device_address) < 0) { + if (!bus_.attach(device_address)) { set_error("Failed to set I2C device address"); - close(i2c_fd); - i2c_fd = -1; return; } - ready_ = true; } -TCA9535::~TCA9535() { - if (i2c_fd >= 0) { - close(i2c_fd); - } -} - bool TCA9535::is_ready() const { - return ready_ && i2c_fd >= 0; + return ready_ && bus_.is_ready(); } const std::string& TCA9535::last_error() const { @@ -61,7 +53,7 @@ bool TCA9535::write_output(const std::bitset<16>& state) { std::cout << "Relay state written successfully after retry." << std::endl; return true; } - usleep(50000); + std::this_thread::sleep_for(retry_delay_); } std::cerr << "Failed to write relay state after retries." << std::endl; @@ -77,13 +69,7 @@ bool TCA9535::read_input(uint8_t port, uint8_t& value) { } bool TCA9535::write_register(uint8_t reg, uint8_t value) { - if (i2c_fd < 0) { - set_error("I2C device not ready"); - return false; - } - - uint8_t buffer[2] = {reg, value}; - if (write(i2c_fd, buffer, 2) != 2) { + if (!bus_.write_reg8(device_address, reg, value)) { set_error("Failed to write I2C register"); return false; } @@ -91,17 +77,8 @@ bool TCA9535::write_register(uint8_t reg, uint8_t value) { } bool TCA9535::read_register(uint8_t reg, uint8_t& value) { - if (i2c_fd < 0) { - set_error("I2C device not ready"); - return false; - } - - if (write(i2c_fd, ®, 1) != 1) { - set_error("Failed to select I2C register"); - return false; - } - if (read(i2c_fd, &value, 1) != 1) { - set_error("Failed to read from I2C register"); + if (!bus_.read_reg8(device_address, reg, value)) { + set_error("Failed to read I2C register"); return false; } return true; diff --git a/src/interfaces/io_expander.hpp b/src/drivers/io_expander.hpp similarity index 58% rename from src/interfaces/io_expander.hpp rename to src/drivers/io_expander.hpp index 979f03a..dfc3b37 100644 --- a/src/interfaces/io_expander.hpp +++ b/src/drivers/io_expander.hpp @@ -1,29 +1,31 @@ #pragma once #include +#include #include -#include #include -#include -#include -#include -#include -class TCA9535 { +#include "interfaces/i2c_bus.hpp" +#include "interfaces/relay_bank.hpp" + +class TCA9535 : public RelayBank { public: - TCA9535(const char* i2c_bus, uint8_t address); - ~TCA9535(); + // The bus must outlive this object. `retry_delay` is the pause between + // write retries (50 ms on the Pi; tests pass zero). + TCA9535(I2cBus& bus, uint8_t address, + std::chrono::milliseconds retry_delay = std::chrono::milliseconds(50)); - bool is_ready() const; + bool is_ready() const override; const std::string& last_error() const; bool configure_port(uint8_t port, uint8_t direction); - bool write_output(const std::bitset<16>& state); + bool write_output(const std::bitset<16>& state) override; bool read_input(uint8_t port, uint8_t& value); private: - int i2c_fd = -1; + I2cBus& bus_; uint8_t device_address = 0; + std::chrono::milliseconds retry_delay_; bool ready_ = false; std::string last_error_; diff --git a/src/interfaces/gpio_manager.cpp b/src/drivers/linux_gpio.cpp similarity index 88% rename from src/interfaces/gpio_manager.cpp rename to src/drivers/linux_gpio.cpp index c30b6b7..2af1e33 100644 --- a/src/interfaces/gpio_manager.cpp +++ b/src/drivers/linux_gpio.cpp @@ -1,7 +1,7 @@ -#include "gpio_manager.hpp" +#include "linux_gpio.hpp" #include -GPIO_Manager::GPIO_Manager(const std::string& chipname) { +LinuxGpio::LinuxGpio(const std::string& chipname) { chip_ = gpiod_chip_open(chipname.c_str()); if (!chip_) { throw std::runtime_error("Failed to open GPIO chip: " + chipname); @@ -11,7 +11,7 @@ GPIO_Manager::GPIO_Manager(const std::string& chipname) { } } -GPIO_Manager::~GPIO_Manager() { +LinuxGpio::~LinuxGpio() { std::lock_guard lock(mutex_); for (auto& [pin, info] : pins_) { if (info.line) { @@ -23,7 +23,7 @@ GPIO_Manager::~GPIO_Manager() { } } -bool GPIO_Manager::set_direction(int pin, const std::string& direction) { +bool LinuxGpio::set_direction(int pin, const std::string& direction) { std::lock_guard lock(mutex_); if (pins_.count(pin) && pins_[pin].line) { @@ -57,7 +57,7 @@ bool GPIO_Manager::set_direction(int pin, const std::string& direction) { return true; } -bool GPIO_Manager::write(int pin, int value) { +bool LinuxGpio::write(int pin, int value) { std::lock_guard lock(mutex_); if (!pins_.count(pin) || pins_[pin].direction != "out") { std::cerr << "Pin " << pin << " not configured as output\n"; @@ -72,7 +72,7 @@ bool GPIO_Manager::write(int pin, int value) { return success; } -bool GPIO_Manager::read(int pin, int& value) { +bool LinuxGpio::read(int pin, int& value) { std::lock_guard lock(mutex_); if (!pins_.count(pin) || pins_[pin].direction != "in") { std::cerr << "Pin " << pin << " not configured as input\n"; @@ -88,7 +88,7 @@ bool GPIO_Manager::read(int pin, int& value) { return true; } -std::vector GPIO_Manager::get_input_pins() { +std::vector LinuxGpio::get_input_pins() { std::lock_guard lock(mutex_); std::vector result; for (const auto& [pin, info] : pins_) { @@ -96,7 +96,7 @@ std::vector GPIO_Manager::get_input_pins() { } return result; } -std::map GPIO_Manager::read_all_inputs() { +std::map LinuxGpio::read_all_inputs() { std::map result; std::lock_guard lock(mutex_); for (const auto& [pin, info] : pins_) { diff --git a/src/interfaces/gpio_manager.hpp b/src/drivers/linux_gpio.hpp similarity index 56% rename from src/interfaces/gpio_manager.hpp rename to src/drivers/linux_gpio.hpp index c8702a9..19ee9ec 100644 --- a/src/interfaces/gpio_manager.hpp +++ b/src/drivers/linux_gpio.hpp @@ -8,19 +8,21 @@ #include #include -class GPIO_Manager { +#include "interfaces/digital_io.hpp" + +class LinuxGpio : public DigitalIo { // This class is a wrapper for GPIO operations using the gpiod library. // It provides methods to set pin direction, read and write pin values, // and manage multiple GPIO pins in a thread-safe manner. public: - GPIO_Manager(const std::string& chipname = "/dev/gpiochip0"); - ~GPIO_Manager(); + LinuxGpio(const std::string& chipname = "/dev/gpiochip0"); + ~LinuxGpio() override; - bool set_direction(int pin, const std::string& direction); - bool write(int pin, int value); - bool read(int pin, int& value); - std::vector get_input_pins(); - std::map read_all_inputs(); + bool set_direction(int pin, const std::string& direction) override; + bool write(int pin, int value) override; + bool read(int pin, int& value) override; + std::vector get_input_pins() override; + std::map read_all_inputs() override; private: struct PinInfo { diff --git a/src/drivers/linux_i2c_bus.cpp b/src/drivers/linux_i2c_bus.cpp new file mode 100644 index 0000000..a8d88f2 --- /dev/null +++ b/src/drivers/linux_i2c_bus.cpp @@ -0,0 +1,110 @@ +#include "linux_i2c_bus.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace { +// One SMBus byte-data transaction, the same ioctl wiringPi's +// wiringPiI2CReadReg8/WriteReg8 make. +int smbus_byte_data(int fd, char read_write, uint8_t reg, i2c_smbus_data* data) { + i2c_smbus_ioctl_data args; + args.read_write = read_write; + args.command = reg; + args.size = I2C_SMBUS_BYTE_DATA; + args.data = data; + return ioctl(fd, I2C_SMBUS, &args); +} +} + +LinuxI2cBus::LinuxI2cBus(const char* device, Transfer transfer) : transfer_(transfer) { + fd_ = open(device, O_RDWR); + if (fd_ < 0) { + set_error("Failed to open I2C bus"); + } +} + +LinuxI2cBus::~LinuxI2cBus() { + if (fd_ >= 0) { + close(fd_); + } +} + +bool LinuxI2cBus::is_ready() const { + return fd_ >= 0; +} + +const std::string& LinuxI2cBus::last_error() const { + return last_error_; +} + +bool LinuxI2cBus::attach(uint8_t address) { + if (fd_ < 0) { + set_error("I2C device not ready"); + return false; + } + if (current_address_ == address) { + return true; + } + if (ioctl(fd_, I2C_SLAVE, address) < 0) { + set_error("Failed to set I2C device address"); + current_address_ = -1; + return false; + } + current_address_ = address; + return true; +} + +bool LinuxI2cBus::write_reg8(uint8_t address, uint8_t reg, uint8_t value) { + if (!attach(address)) { + return false; + } + if (transfer_ == Transfer::Smbus) { + i2c_smbus_data data; + data.byte = value; + if (smbus_byte_data(fd_, I2C_SMBUS_WRITE, reg, &data) < 0) { + set_error("Failed to write I2C register"); + return false; + } + return true; + } + uint8_t buffer[2] = {reg, value}; + if (write(fd_, buffer, 2) != 2) { + set_error("Failed to write I2C register"); + return false; + } + return true; +} + +bool LinuxI2cBus::read_reg8(uint8_t address, uint8_t reg, uint8_t& value) { + if (!attach(address)) { + return false; + } + if (transfer_ == Transfer::Smbus) { + i2c_smbus_data data; + if (smbus_byte_data(fd_, I2C_SMBUS_READ, reg, &data) < 0) { + set_error("Failed to read from I2C register"); + return false; + } + value = data.byte & 0xFF; + return true; + } + if (write(fd_, ®, 1) != 1) { + set_error("Failed to select I2C register"); + return false; + } + if (read(fd_, &value, 1) != 1) { + set_error("Failed to read from I2C register"); + return false; + } + return true; +} + +void LinuxI2cBus::set_error(const std::string& message) { + last_error_ = message; + std::cerr << message << std::endl; +} diff --git a/src/drivers/linux_i2c_bus.hpp b/src/drivers/linux_i2c_bus.hpp new file mode 100644 index 0000000..303fc69 --- /dev/null +++ b/src/drivers/linux_i2c_bus.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +#include "interfaces/i2c_bus.hpp" + +// I2C through /dev/i2c-N. +// +// Two transfer styles, because the two chips were driven differently before +// the bus was split out and a refactor must not change what a chip sees: +// Plain: write()/read() on the fd (the TCA9535 relay expander). +// Smbus: I2C_SMBUS byte-data ioctls (the PCA9685, which wiringPi drove). +// A register read is one repeated-start transaction. +// +// One object is one fd, so each chip gets its own LinuxI2cBus, as before. +class LinuxI2cBus : public I2cBus { +public: + enum class Transfer { Plain, Smbus }; + + explicit LinuxI2cBus(const char* device, Transfer transfer = Transfer::Plain); + ~LinuxI2cBus() override; + + LinuxI2cBus(const LinuxI2cBus&) = delete; + LinuxI2cBus& operator=(const LinuxI2cBus&) = delete; + + bool attach(uint8_t address) override; + bool write_reg8(uint8_t address, uint8_t reg, uint8_t value) override; + bool read_reg8(uint8_t address, uint8_t reg, uint8_t& value) override; + + bool is_ready() const override; + const std::string& last_error() const override; + +private: + int fd_ = -1; + int current_address_ = -1; + Transfer transfer_; + std::string last_error_; + + void set_error(const std::string& message); +}; diff --git a/src/interfaces/mcc_daqhats.cpp b/src/drivers/mcc_daqhats.cpp similarity index 62% rename from src/interfaces/mcc_daqhats.cpp rename to src/drivers/mcc_daqhats.cpp index 0f1d9a8..e411b37 100644 --- a/src/interfaces/mcc_daqhats.cpp +++ b/src/drivers/mcc_daqhats.cpp @@ -1,13 +1,11 @@ #include "mcc_daqhats.hpp" -#include +#include #include #include #include #include -#include #include -#include namespace { std::vector channels_for_hat(int hat_id) { @@ -33,7 +31,7 @@ const char* hat_type_label(int hat_id) { } } // namespace -std::vector initialize_daqs() { +std::vector MccDaqHats::discover() { std::vector connected_daqs; int count = hat_list(HAT_ID_ANY, NULL); @@ -71,7 +69,7 @@ std::vector initialize_daqs() { return connected_daqs; } -bool open_daq_hat(const DaqHatDevice& hat) { +bool MccDaqHats::open(const DaqHatDevice& hat) { int result = RESULT_SUCCESS; switch (hat.hat_id) { case HAT_ID_MCC_128: @@ -106,7 +104,7 @@ bool open_daq_hat(const DaqHatDevice& hat) { } } -bool get_daq_value(const DaqHatDevice& hat, int channel, double& value) { +bool MccDaqHats::read(const DaqHatDevice& hat, int channel, double& value) { int result = RESULT_SUCCESS; switch (hat.hat_id) { case HAT_ID_MCC_128: { @@ -131,63 +129,3 @@ bool get_daq_value(const DaqHatDevice& hat, int channel, double& value) { } return true; } - -std::vector build_sensor_headers(const std::vector& hats) { - std::vector headers; - headers.push_back("timestamp"); - for (const auto& hat : hats) { - if (hat.channels.empty()) { - continue; - } - std::string prefix = "hat" + std::to_string(hat.address); // + "_" + std::string(hat_type_label(hat.hat_id)); - for (int channel : hat.channels) { - headers.push_back(prefix + "_ch" + std::to_string(channel)); - } - } - return headers; -} - -void sample_func(const std::vector& daq_hats, - TelemetryStore& store, - DataLogger* logger, - int sample_interval_ms) { - int interval_ms = sample_interval_ms > 0 ? sample_interval_ms : 1; - while (true) { - std::vector new_data; - std::vector values; - size_t total_channels = 0; - for (const auto& hat : daq_hats) { - total_channels += hat.channels.size(); - } - new_data.reserve(total_channels); - values.reserve(total_channels); - - auto now = std::chrono::system_clock::now(); - double timestamp_ms = - std::chrono::duration_cast( - now.time_since_epoch()) - .count(); - - for (const auto& hat : daq_hats) { - for (int channel : hat.channels) { - double value = std::numeric_limits::quiet_NaN(); - bool ok = get_daq_value(hat, channel, value); - - SensorSample sd; - sd.hat_id = hat.address; - sd.channel_id = channel; - sd.value = ok ? value : std::numeric_limits::quiet_NaN(); - sd.timestamp_ms = timestamp_ms; - new_data.push_back(sd); - values.push_back(sd.value); - } - } - - store.set_sensors(std::move(new_data)); - if (logger && logger->is_active()) { - logger->log_sensor_row(timestamp_ms, values); - } - - std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms)); - } -} diff --git a/src/drivers/mcc_daqhats.hpp b/src/drivers/mcc_daqhats.hpp new file mode 100644 index 0000000..097e1af --- /dev/null +++ b/src/drivers/mcc_daqhats.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include "interfaces/analog_io.hpp" + +// MCC 128 (analog) and MCC 134 (thermocouple) HATs through libdaqhats. +class MccDaqHats : public AnalogIo { +public: + std::vector discover() override; + bool open(const DaqHatDevice& hat) override; + bool read(const DaqHatDevice& hat, int channel, double& value) override; +}; diff --git a/src/interfaces/servo.cpp b/src/drivers/servo.cpp similarity index 94% rename from src/interfaces/servo.cpp rename to src/drivers/servo.cpp index d5c7d1d..28d05ad 100644 --- a/src/interfaces/servo.cpp +++ b/src/drivers/servo.cpp @@ -13,16 +13,17 @@ #define ENABLE_DEBUG_OUTPUT // comment out to suppress debug level dumps -Adafruit_PWMServoDriver::Adafruit_PWMServoDriver(const uint8_t addr) - : _i2caddr(addr) {} +Adafruit_PWMServoDriver::Adafruit_PWMServoDriver(I2cBus& bus, const uint8_t addr) + : bus_(bus), _i2caddr(addr) {} bool Adafruit_PWMServoDriver::begin(uint8_t prescale) { - fd = wiringPiI2CSetup(_i2caddr); - if (fd < 0) { + begun_ = false; + if (!bus_.is_ready() || !bus_.attach(_i2caddr)) { std::cerr << "Failed to initialize I2C communication." << std::endl; return false; } + begun_ = true; std::cout << "I2C communication initialized successfully." << std::endl; reset(); if (prescale) { @@ -35,7 +36,7 @@ bool Adafruit_PWMServoDriver::begin(uint8_t prescale) { } bool Adafruit_PWMServoDriver::is_ready() const { - return fd >= 0; + return begun_ && bus_.is_ready(); } void Adafruit_PWMServoDriver::reset() { @@ -257,21 +258,21 @@ void Adafruit_PWMServoDriver::setOscillatorFrequency(uint32_t freq) { } uint8_t Adafruit_PWMServoDriver::read8(uint8_t addr) { - if (fd < 0) { + if (!begun_) { return 0; } - int value = wiringPiI2CReadReg8(fd, addr); - if (value < 0) { + uint8_t value = 0; + if (!bus_.read_reg8(_i2caddr, addr, value)) { return 0; } - return static_cast(value); + return value; } void Adafruit_PWMServoDriver::write8(uint8_t addr, uint8_t d) { - if (fd < 0) { + if (!begun_) { return; } - wiringPiI2CWriteReg8(fd, addr, d); + bus_.write_reg8(_i2caddr, addr, d); } void Adafruit_PWMServoDriver::delay(int ms) { diff --git a/src/interfaces/servo.hpp b/src/drivers/servo.hpp similarity index 86% rename from src/interfaces/servo.hpp rename to src/drivers/servo.hpp index 7f2807a..57027fd 100644 --- a/src/interfaces/servo.hpp +++ b/src/drivers/servo.hpp @@ -13,7 +13,9 @@ #include #include #include -#include + +#include "interfaces/i2c_bus.hpp" +#include "interfaces/pwm_driver.hpp" // REGISTER ADDRESSES #define PCA9685_MODE1 0x00 /**< Mode Register 1 */ @@ -63,11 +65,13 @@ * @brief Class that stores state and functions for interacting with PCA9685 * PWM chip */ -class Adafruit_PWMServoDriver { +class Adafruit_PWMServoDriver : public PwmDriver { public: - explicit Adafruit_PWMServoDriver(uint8_t addr = PCA9685_I2C_ADDRESS); + // The bus must outlive this object. Its transfers should be SMBus byte-data + // (LinuxI2cBus::Transfer::Smbus), which is what this driver used before. + explicit Adafruit_PWMServoDriver(I2cBus& bus, uint8_t addr = PCA9685_I2C_ADDRESS); bool begin(uint8_t prescale = 0); - bool is_ready() const; + bool is_ready() const override; void reset(); void sleep(); @@ -79,15 +83,16 @@ class Adafruit_PWMServoDriver { void setPWM(uint8_t num, uint16_t on, uint16_t off); void setPin(uint8_t num, uint16_t val, bool invert = false); uint8_t readPrescale(); - void writeMicroseconds(uint8_t num, uint16_t Microseconds); - bool disablePWM(uint8_t num); + void writeMicroseconds(uint8_t num, uint16_t Microseconds) override; + bool disablePWM(uint8_t num) override; void setOscillatorFrequency(uint32_t freq); uint32_t getOscillatorFrequency(); private: - int fd = -1; + I2cBus& bus_; uint8_t _i2caddr = 0; + bool begun_ = false; uint32_t _oscillator_freq = FREQUENCY_OSCILLATOR; bool warned_not_ready_ = false; diff --git a/src/interfaces/analog_io.cpp b/src/interfaces/analog_io.cpp new file mode 100644 index 0000000..4b3599d --- /dev/null +++ b/src/interfaces/analog_io.cpp @@ -0,0 +1,19 @@ +#include "analog_io.hpp" + +#include +#include + +std::vector discover_and_open(AnalogIo& daq) { + std::vector opened; + try { + for (const auto& hat : daq.discover()) { + if (daq.open(hat)) { + opened.push_back(hat); + } + } + } catch (const std::exception& e) { + std::cerr << "DAQ initialization failed: " << e.what() << std::endl; + opened.clear(); + } + return opened; +} diff --git a/src/interfaces/analog_io.hpp b/src/interfaces/analog_io.hpp new file mode 100644 index 0000000..8c1e6ef --- /dev/null +++ b/src/interfaces/analog_io.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +// One DAQ HAT found on the bus. `channels` is filled in by the driver that +// discovered it (empty for a HAT type the driver does not implement). +struct DaqHatDevice { + int address = 0; + int hat_id = 0; + std::vector channels; +}; + +// What the sampler needs from the analog hardware. Named AnalogIo, not +// AnalogInput, in case outputs are ever added. +class AnalogIo { +public: + virtual ~AnalogIo() = default; + + // Throws std::runtime_error if the bus cannot be listed. + virtual std::vector discover() = 0; + virtual bool open(const DaqHatDevice& hat) = 0; + virtual bool read(const DaqHatDevice& hat, int channel, double& value) = 0; +}; + +// Find the HATs on the bus and open each; returns the ones that opened. A +// failure to list the bus is logged and gives an empty list. +std::vector discover_and_open(AnalogIo& daq); diff --git a/src/interfaces/digital_io.hpp b/src/interfaces/digital_io.hpp new file mode 100644 index 0000000..f09247d --- /dev/null +++ b/src/interfaces/digital_io.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +// What GpioController and the GPIO sampler need from the GPIO hardware. +// Directions are the strings "in" and "out", as before. +class DigitalIo { +public: + virtual ~DigitalIo() = default; + + virtual bool set_direction(int pin, const std::string& direction) = 0; + virtual bool write(int pin, int value) = 0; + virtual bool read(int pin, int& value) = 0; + virtual std::vector get_input_pins() = 0; + virtual std::map read_all_inputs() = 0; +}; diff --git a/src/interfaces/hardware.hpp b/src/interfaces/hardware.hpp new file mode 100644 index 0000000..78a3546 --- /dev/null +++ b/src/interfaces/hardware.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +#include "interfaces/analog_io.hpp" +#include "interfaces/digital_io.hpp" +#include "interfaces/i2c_bus.hpp" +#include "interfaces/pwm_driver.hpp" +#include "interfaces/relay_bank.hpp" + +// Everything main() reads from or drives, behind the interfaces. Any of servo, +// relays and gpio is null if that hardware is not available, and daq_hats is +// empty if no HAT opened. Members are destroyed in reverse order, so the buses +// (declared first) outlive the drivers that use them. +struct Hardware { + std::vector> buses; + + std::unique_ptr daq; + std::vector daq_hats; // the HATs that opened + + std::unique_ptr servo; + + std::unique_ptr relays; + std::bitset<16> relay_state; // what was written at startup + + std::unique_ptr gpio; +}; + +// Brings the hardware up and puts the outputs in their startup state: all +// relays on, the output pins low. A device that is missing is logged and left +// null; it never stops the program. +// +// Which hardware this is gets decided when building. A target links exactly one +// implementation: +// drivers/hardware_real.cpp the Pi: MCC HATs, I2C chips, gpiochip +// (novaGround, novaThermo) +// sim/hardware_sim.cpp simulated: libdaqhats_sim and in-memory outputs +// (novaMock) +Hardware build_hardware(const std::vector& output_pins, + const std::vector& input_pins); diff --git a/src/interfaces/i2c_bus.hpp b/src/interfaces/i2c_bus.hpp new file mode 100644 index 0000000..1b67a70 --- /dev/null +++ b/src/interfaces/i2c_bus.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +// The transport under the I2C chips (TCA9535 relay expander, later the +// PCA9685 servo driver). The real one talks to /dev/i2c-N; tests use a fake +// that records register writes and can inject failures. +class I2cBus { +public: + virtual ~I2cBus() = default; + + // Make `address` the target of later transactions. False if the bus + // cannot address it. + virtual bool attach(uint8_t address) = 0; + virtual bool write_reg8(uint8_t address, uint8_t reg, uint8_t value) = 0; + virtual bool read_reg8(uint8_t address, uint8_t reg, uint8_t& value) = 0; + + virtual bool is_ready() const = 0; + virtual const std::string& last_error() const = 0; +}; diff --git a/src/interfaces/mcc_daqhats.hpp b/src/interfaces/mcc_daqhats.hpp deleted file mode 100644 index 59e8617..0000000 --- a/src/interfaces/mcc_daqhats.hpp +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include -#include - -#include "core/telemetry.hpp" -#include "core/data_logger.hpp" - -struct DaqHatDevice { - int address = 0; - int hat_id = 0; - std::vector channels; -}; - -std::vector initialize_daqs(); -bool open_daq_hat(const DaqHatDevice& hat); -bool get_daq_value(const DaqHatDevice& hat, int channel, double& value); -std::vector build_sensor_headers(const std::vector& hats); - -// Data sampling loop -void sample_func(const std::vector& daq_hats, - TelemetryStore& store, - DataLogger* logger, - int sample_interval_ms); diff --git a/src/interfaces/meson.build b/src/interfaces/meson.build deleted file mode 100644 index a3f9bbe..0000000 --- a/src/interfaces/meson.build +++ /dev/null @@ -1 +0,0 @@ -src += files('servo.cpp', 'io_expander.cpp', 'gpio_manager.cpp', 'mcc_daqhats.cpp') diff --git a/src/interfaces/pwm_driver.hpp b/src/interfaces/pwm_driver.hpp new file mode 100644 index 0000000..a986111 --- /dev/null +++ b/src/interfaces/pwm_driver.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +// What ServoController needs from the PWM hardware. The method names are the +// ones Adafruit_PWMServoDriver already had. +class PwmDriver { +public: + virtual ~PwmDriver() = default; + + virtual bool is_ready() const = 0; + virtual void writeMicroseconds(uint8_t channel, uint16_t microseconds) = 0; + virtual bool disablePWM(uint8_t channel) = 0; +}; diff --git a/src/interfaces/relay_bank.hpp b/src/interfaces/relay_bank.hpp new file mode 100644 index 0000000..09a0aa0 --- /dev/null +++ b/src/interfaces/relay_bank.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +// What RelayController needs from the relay hardware: write all 16 outputs. +class RelayBank { +public: + virtual ~RelayBank() = default; + + virtual bool is_ready() const = 0; + virtual bool write_output(const std::bitset<16>& state) = 0; +}; diff --git a/src/main.cpp b/src/main.cpp index 9b6c758..fab8c3a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,12 +1,10 @@ #include "mqtt/async_client.h" #include -#include #include #include -#include #include -#include +#include #include #include #include @@ -15,15 +13,14 @@ #include "controllers/gpio_controller.hpp" #include "controllers/loops.hpp" #include "controllers/relay_controller.hpp" +#include "controllers/sampler.hpp" #include "controllers/servo_controller.hpp" #include "core/command_router.hpp" #include "core/data_logger.hpp" #include "core/telemetry.hpp" -#include "interfaces/gpio_manager.hpp" -#include "interfaces/io_expander.hpp" -#include "interfaces/mcc_daqhats.hpp" -#include "interfaces/servo.hpp" +#include "interfaces/hardware.hpp" #include "utils/cli.hpp" +#include "utils/interruptible_sleep.hpp" using namespace std::chrono; @@ -49,35 +46,54 @@ using namespace std::chrono; namespace { const std::string kCommandTopic = "nova/command"; const std::string kCommandSourceId = "novaOps"; -const int kI2CAddr = 0x20; -} - -int main(int argc, char* argv[]) { - RuntimeConfig defaults; - defaults.node_id = NOVA_BUILD_ID; - defaults.broker = NOVA_DEFAULT_BROKER; - defaults.backend = NOVA_DEFAULT_BACKEND; - defaults.verbosity = NOVA_DEFAULT_VERBOSITY; - defaults.sample_interval_ms = NOVA_DEFAULT_SAMPLE_MS; - defaults.publish_interval_ms = NOVA_DEFAULT_PUBLISH_MS; +const int kGpioSampleIntervalMs = 50; + +// Everything the program owns once it is built. Members are destroyed in +// reverse order, so each one outlives what points at it. Not movable: the +// controllers and the router hold pointers into it. +struct App { + RuntimeConfig config; + std::string upload_url; + TelemetryStore telemetry; - ParseResult parsed = parse_args(std::vector(argv + 1, argv + argc), - defaults, argv[0], std::cout, std::cerr); - if (parsed.status == ParseStatus::ExitOk) { - return 0; + std::vector output_pins = {17, 27, 22}; + std::vector input_pins = {5, 6}; + Hardware hardware; + + std::unique_ptr logger; + std::unique_ptr servo_controller; + std::unique_ptr relay_controller; + std::unique_ptr gpio_controller; + std::unique_ptr data_file_controller; + std::unique_ptr router; + std::shared_ptr cli; +}; + +std::vector actuator_log_headers(const std::vector& output_pins) { + std::vector headers; + headers.push_back("timestamp"); + for (unsigned int pin : output_pins) { + headers.push_back("gpio_" + std::to_string(pin)); } - if (parsed.status == ParseStatus::ExitError) { - return 1; + for (int i = 0; i < 16; ++i) { + headers.push_back("relay_" + std::to_string(i)); } - const RuntimeConfig& config = parsed.config; - - if (config.verbosity <= 0) { - std::cout.setstate(std::ios::failbit); + for (int i = 0; i < 16; ++i) { + headers.push_back("servo_" + std::to_string(i)); } + headers.push_back("type_id"); + return headers; +} - std::string broker_address = normalize_broker_address(config.broker); - std::string backend_base = normalize_backend_base(config.backend); - std::string upload_url = build_upload_url(backend_base); +// Brings up the hardware, the logger, the controllers and the MQTT connection. +// Starts no threads: that is run(). +std::unique_ptr build_app(const RuntimeConfig& config) { + auto app = std::make_unique(); + app->config = config; + + const std::string broker_address = normalize_broker_address(config.broker); + const std::string backend_base = normalize_backend_base(config.backend); + app->upload_url = build_upload_url(backend_base); // Always say where data is going (issue #29): a wrong backend port used to // be invisible until the recordings went missing. @@ -85,7 +101,7 @@ int main(int argc, char* argv[]) { std::cout << "Build ID: " << config.node_id << " environment: " << (nova_env && *nova_env ? nova_env : "(not set)") << std::endl; std::cout << "Broker: " << broker_address << std::endl; - std::cout << "Backend: " << backend_base << " (uploads to " << upload_url << ")" << std::endl; + std::cout << "Backend: " << backend_base << " (uploads to " << app->upload_url << ")" << std::endl; check_backend(backend_base); if (config.verbosity >= 2) { @@ -93,145 +109,62 @@ int main(int argc, char* argv[]) { std::cout << "Publish interval (ms): " << config.publish_interval_ms << std::endl; } - TelemetryStore telemetry; - - const std::string node_id = config.node_id; - - std::vector output_pins = {17, 27, 22}; - std::vector input_pins = {5, 6}; - - std::vector daq_hats; - bool has_daq = false; - -#ifndef NOVA_MOCK_MODE - try { - daq_hats = initialize_daqs(); - std::vector opened_hats; - for (const auto& hat : daq_hats) { - if (open_daq_hat(hat)) { - opened_hats.push_back(hat); - } - } - daq_hats = std::move(opened_hats); - has_daq = !daq_hats.empty(); - } catch (const std::exception& e) { - std::cerr << "DAQ initialization failed: " << e.what() << std::endl; - } -#else - std::cout << "Mock mode enabled: skipping DAQ initialization." << std::endl; -#endif - - std::vector sensor_headers = build_sensor_headers(daq_hats); - - std::vector actuator_headers; - actuator_headers.push_back("timestamp"); - for (unsigned int pin : output_pins) { - actuator_headers.push_back("gpio_" + std::to_string(pin)); - } - for (int i = 0; i < 16; ++i) { - actuator_headers.push_back("relay_" + std::to_string(i)); - } - for (int i = 0; i < 16; ++i) { - actuator_headers.push_back("servo_" + std::to_string(i)); - } - actuator_headers.push_back("type_id"); + app->hardware = build_hardware(app->output_pins, app->input_pins); + Hardware& hw = app->hardware; std::vector gpio_pins_for_logging; - gpio_pins_for_logging.reserve(output_pins.size()); - for (unsigned int pin : output_pins) { + gpio_pins_for_logging.reserve(app->output_pins.size()); + for (unsigned int pin : app->output_pins) { gpio_pins_for_logging.push_back(static_cast(pin)); } - - DataLogger data_logger("data", sensor_headers, actuator_headers, gpio_pins_for_logging, node_id); - - Adafruit_PWMServoDriver servo_driver; - bool has_servo = false; - std::unique_ptr io_expander; - bool has_io_expander = false; - std::bitset<16> relay_state; - std::unique_ptr gpio_manager; - bool has_gpio_manager = false; - -#ifndef NOVA_MOCK_MODE - has_servo = servo_driver.begin(); - if (has_servo) { - servo_driver.setPWMFreq(50); - } - - io_expander = std::make_unique("/dev/i2c-1", kI2CAddr); - has_io_expander = io_expander->is_ready(); - if (has_io_expander) { - bool ok = io_expander->configure_port(0, 0x00) && - io_expander->configure_port(1, 0x00); - for (int i = 0; i < 16; i++) { - relay_state.set(i, true); - } - ok = ok && io_expander->write_output(relay_state); - if (!ok) { - std::cerr << "IO expander setup failed; disabling relay control." << std::endl; - has_io_expander = false; - } - } else { - std::cerr << "IO expander not available." << std::endl; - } - - data_logger.update_relay_state(relay_state); - - try { - gpio_manager = std::make_unique("/dev/gpiochip0"); - has_gpio_manager = true; - - for (auto pin : output_pins) { - gpio_manager->set_direction(pin, "out"); - gpio_manager->write(pin, 0); - data_logger.update_gpio(static_cast(pin), 0); - } - - for (auto pin : input_pins) { - gpio_manager->set_direction(pin, "in"); + app->logger = std::make_unique("data", + build_sensor_headers(hw.daq_hats), + actuator_log_headers(app->output_pins), + gpio_pins_for_logging, + config.node_id); + + // Tell the logger the state the hardware started in. + app->logger->update_relay_state(hw.relay_state); + if (hw.gpio) { + for (int pin : gpio_pins_for_logging) { + app->logger->update_gpio(pin, 0); } - } catch (const std::exception& e) { - std::cerr << "GPIO Manager initialization failed: " << e.what() << std::endl; - has_gpio_manager = false; } -#else - std::cout << "Mock mode enabled: skipping GPIO/relay/servo initialization." << std::endl; -#endif - ServoController servo_controller(has_servo ? &servo_driver : nullptr, - &telemetry, - &data_logger); - RelayController relay_controller(has_io_expander ? io_expander.get() : nullptr, - &telemetry, - &data_logger); - if (has_io_expander) { - relay_controller.set_initial_state(relay_state); + app->servo_controller = std::make_unique(hw.servo.get(), &app->telemetry, app->logger.get()); + app->relay_controller = std::make_unique(hw.relays.get(), &app->telemetry, app->logger.get()); + if (hw.relays) { + app->relay_controller->set_initial_state(hw.relay_state); } - GpioController gpio_controller(has_gpio_manager ? gpio_manager.get() : nullptr, - &data_logger); - DataFileController data_file_controller(&data_logger, upload_url); - - CommandRouter router(kCommandSourceId); - router.register_handler("servo", [&servo_controller](const boost::json::object& cmd) { - servo_controller.handle_command(cmd); + app->gpio_controller = std::make_unique(hw.gpio.get(), app->logger.get()); + app->data_file_controller = std::make_unique(app->logger.get(), app->upload_url); + + app->router = std::make_unique(kCommandSourceId); + ServoController* servo = app->servo_controller.get(); + RelayController* relay = app->relay_controller.get(); + GpioController* gpio = app->gpio_controller.get(); + DataFileController* data_file = app->data_file_controller.get(); + app->router->register_handler("servo", [servo](const boost::json::object& cmd) { + servo->handle_command(cmd); }); - router.register_handler("relay", [&relay_controller](const boost::json::object& cmd) { - relay_controller.handle_command(cmd); + app->router->register_handler("relay", [relay](const boost::json::object& cmd) { + relay->handle_command(cmd); }); - router.register_handler("gpio", [&gpio_controller](const boost::json::object& cmd) { - gpio_controller.handle_command(cmd); + app->router->register_handler("gpio", [gpio](const boost::json::object& cmd) { + gpio->handle_command(cmd); }); - router.register_handler("data_file", [&data_file_controller](const boost::json::object& cmd) { - data_file_controller.handle_command(cmd); + app->router->register_handler("data_file", [data_file](const boost::json::object& cmd) { + data_file->handle_command(cmd); }); - auto cli = std::make_shared(broker_address, node_id); + app->cli = std::make_shared(broker_address, config.node_id); + auto cli = app->cli; auto connOpts = mqtt::connect_options_builder() .clean_session(true) .automatic_reconnect(seconds(1), seconds(10)) .finalize(); - + { auto topics = mqtt::string_collection::create({kCommandTopic}); const std::vector qos{1}; @@ -265,31 +198,70 @@ int main(int argc, char* argv[]) { std::cerr << "MQTT connection unavailable; continuing without broker." << std::endl; } - std::thread publisher(publisher_loop, - cli, - std::ref(telemetry), - node_id, - config.publish_interval_ms); - publisher.detach(); - - if (has_daq) { - std::thread sampler(sample_func, - daq_hats, - std::ref(telemetry), - &data_logger, - config.sample_interval_ms); - sampler.detach(); + return app; +} + +// Starts the loops and runs until `stop` is requested, then stops and joins +// them (a std::jthread asks its thread to stop when it is destroyed). +void run(App& app, std::stop_token stop) { + std::vector threads; + Hardware& hw = app.hardware; + + threads.emplace_back(publisher_loop, + app.cli, + std::ref(app.telemetry), + app.config.node_id, + app.config.publish_interval_ms); + + if (!hw.daq_hats.empty()) { + threads.emplace_back(daq_sample_loop, + std::ref(*hw.daq), + hw.daq_hats, + std::ref(app.telemetry), + app.logger.get(), + app.config.sample_interval_ms); + } + + if (hw.gpio) { + threads.emplace_back(gpio_sample_loop, + std::ref(*hw.gpio), + std::ref(app.telemetry), + kGpioSampleIntervalMs); } - if (has_gpio_manager) { - std::thread gpio_sampler(gpio_sampler_loop, std::ref(*gpio_manager), std::ref(telemetry)); - gpio_sampler.detach(); + threads.emplace_back(consumer_loop, app.cli, std::ref(*app.router)); + + InterruptibleSleep sleeper; + while (!sleeper.sleep_for(stop, seconds(1))) { } +} +} // namespace + +int main(int argc, char* argv[]) { + RuntimeConfig defaults; + defaults.node_id = NOVA_BUILD_ID; + defaults.broker = NOVA_DEFAULT_BROKER; + defaults.backend = NOVA_DEFAULT_BACKEND; + defaults.verbosity = NOVA_DEFAULT_VERBOSITY; + defaults.sample_interval_ms = NOVA_DEFAULT_SAMPLE_MS; + defaults.publish_interval_ms = NOVA_DEFAULT_PUBLISH_MS; - std::thread consumer(consumer_loop, cli, std::ref(router)); - consumer.detach(); + ParseResult parsed = parse_args(std::vector(argv + 1, argv + argc), + defaults, argv[0], std::cout, std::cerr); + if (parsed.status == ParseStatus::ExitOk) { + return 0; + } + if (parsed.status == ParseStatus::ExitError) { + return 1; + } - while (true) { - std::this_thread::sleep_for(seconds(1)); + if (parsed.config.verbosity <= 0) { + std::cout.setstate(std::ios::failbit); } + + auto app = build_app(parsed.config); + + std::stop_source stop; + run(*app, stop.get_token()); + return 0; } diff --git a/src/meson.build b/src/meson.build index e9154e4..ec9370b 100644 --- a/src/meson.build +++ b/src/meson.build @@ -1,10 +1,39 @@ subdir('core') subdir('utils') -# Everything below needs the Pi's hardware libraries. -if get_option('hardware') - subdir('interfaces') - subdir('controllers') - src += core_src - src += files('main.cpp') -endif +# Hardware-free: the chip drivers talk to an I2cBus, the controllers and the +# sampler to RelayBank, PwmDriver, DigitalIo and AnalogIo, and the simulator +# (waveforms, scenario files, simulated outputs) needs only Boost. +core_src += files( + 'drivers/io_expander.cpp', + 'drivers/servo.cpp', + 'interfaces/analog_io.cpp', + 'controllers/relay_controller.cpp', + 'controllers/servo_controller.cpp', + 'controllers/gpio_controller.cpp', + 'controllers/sampler.cpp', + 'sim/waveform.cpp', + 'sim/scenario.cpp', + 'sim/sim_actuators.cpp', +) + +# The application: needs paho (MQTT) and, for uploads, libcurl, but no Pi hardware. +app_src = files( + 'main.cpp', + 'controllers/data_file_controller.cpp', + 'controllers/loops.cpp', +) + +# What only the Pi has: the real chips, libgpiod, MCC's libdaqhats. +real_src = files( + 'drivers/hardware_real.cpp', + 'drivers/linux_gpio.cpp', + 'drivers/linux_i2c_bus.cpp', + 'drivers/mcc_daqhats.cpp', +) + +# The simulator's side of the same seams. +sim_app_src = files('sim/hardware_sim.cpp') +sim_daqhats_src = files('sim/daqhats_sim.cpp') +# The real MCC driver, compiled against the simulated daqhats headers. +mcc_driver_src = files('drivers/mcc_daqhats.cpp') diff --git a/src/sim/daqhats_sim.cpp b/src/sim/daqhats_sim.cpp new file mode 100644 index 0000000..b0856c8 --- /dev/null +++ b/src/sim/daqhats_sim.cpp @@ -0,0 +1,207 @@ +#include "daqhats_sim.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "daqhats/mcc128.h" +#include "daqhats/mcc134.h" +#include "daqhats/daqhats.h" + +namespace { +using Clock = std::chrono::steady_clock; + +struct State { + std::mutex mutex; + bool loaded = false; + bool load_failed = false; + Scenario scenario; + std::set opened; + std::map, int> tc_types; + WaveformRng rng{1}; + Clock::time_point started = Clock::now(); + std::function clock; // empty: use `started` + + double now() const { + if (clock) return clock(); + return std::chrono::duration(Clock::now() - started).count(); + } +}; + +State& state() { + static State s; + return s; +} + +void install(State& s, const Scenario& scenario) { + s.scenario = scenario; + s.opened.clear(); + s.tc_types.clear(); + s.rng = WaveformRng(scenario.seed); + s.started = Clock::now(); + s.loaded = true; + s.load_failed = false; +} + +// Caller holds the lock. False if there is no usable scenario. +bool ensure_loaded(State& s) { + if (s.loaded) return true; + if (s.load_failed) return false; + const char* path = std::getenv("NOVA_SIM_SCENARIO"); + try { + install(s, path && *path ? load_scenario_file(path) : default_scenario()); + if (path && *path) { + std::cout << "libdaqhats_sim: scenario " << path << std::endl; + } else { + std::cout << "libdaqhats_sim: no NOVA_SIM_SCENARIO, using the default scenario" << std::endl; + } + return true; + } catch (const std::exception& e) { + std::cerr << "libdaqhats_sim: cannot load scenario: " << e.what() << std::endl; + s.load_failed = true; + return false; + } +} + +uint16_t id_for_type(const std::string& type) { + return type == "mcc134" ? HAT_ID_MCC_134 : HAT_ID_MCC_128; +} + +// A hat that is present on the bus (in the scenario and not missing). +const SimHat* find_hat(const State& s, int address) { + if (s.scenario.missing_hats.count(address)) return nullptr; + for (const SimHat& h : s.scenario.hats) { + if (h.address == address) return &h; + } + return nullptr; +} + +int open_hat(uint8_t address, uint16_t expected_id) { + State& s = state(); + std::lock_guard lock(s.mutex); + if (!ensure_loaded(s)) return RESULT_UNDEFINED; + const SimHat* hat = find_hat(s, address); + if (!hat || id_for_type(hat->type) != expected_id) return RESULT_INVALID_DEVICE; + s.opened.insert(address); + return RESULT_SUCCESS; +} + +int read_channel(uint8_t address, uint8_t channel, uint16_t expected_id, int channel_count, + double* value) { + if (!value) return RESULT_BAD_PARAMETER; + State& s = state(); + int delay_us = 0; + { + std::lock_guard lock(s.mutex); + if (!ensure_loaded(s)) return RESULT_UNDEFINED; + const SimHat* hat = find_hat(s, address); + if (!hat || id_for_type(hat->type) != expected_id || !s.opened.count(address)) { + return RESULT_INVALID_DEVICE; + } + if (channel >= channel_count) return RESULT_BAD_PARAMETER; + if (s.scenario.read_errors.count({address, channel})) return RESULT_COMMS_FAILURE; + + auto it = hat->channels.find(channel); + *value = it == hat->channels.end() ? 0.0 : wave_value(it->second, s.now(), s.rng); + delay_us = s.scenario.read_delay_us; + } + if (delay_us > 0) { + std::this_thread::sleep_for(std::chrono::microseconds(delay_us)); + } + return RESULT_SUCCESS; +} +} // namespace + +namespace daqhats_sim { +void load(const Scenario& scenario) { + State& s = state(); + std::lock_guard lock(s.mutex); + install(s, scenario); +} + +void reset() { + State& s = state(); + std::lock_guard lock(s.mutex); + s.loaded = false; + s.load_failed = false; + s.scenario = Scenario(); + s.opened.clear(); + s.tc_types.clear(); + s.clock = nullptr; +} + +std::map gpio_inputs() { + State& s = state(); + std::lock_guard lock(s.mutex); + if (!ensure_loaded(s)) return {}; + return s.scenario.gpio_inputs; +} + +void set_clock(std::function seconds) { + State& s = state(); + std::lock_guard lock(s.mutex); + s.clock = std::move(seconds); +} +} // namespace daqhats_sim + +extern "C" { + +int hat_list(uint16_t filter_by_id, struct HatInfo* list) { + State& s = state(); + std::lock_guard lock(s.mutex); + if (!ensure_loaded(s)) return RESULT_UNDEFINED; + + int count = 0; + for (const SimHat& hat : s.scenario.hats) { + if (s.scenario.missing_hats.count(hat.address)) continue; + const uint16_t id = id_for_type(hat.type); + if (filter_by_id != HAT_ID_ANY && filter_by_id != id) continue; + if (list) { + HatInfo& info = list[count]; + std::memset(&info, 0, sizeof info); + info.address = static_cast(hat.address); + info.id = id; + info.version = 0; + std::snprintf(info.product_name, sizeof info.product_name, "simulated %s", hat.type.c_str()); + } + ++count; + } + return count; +} + +int mcc128_open(uint8_t address) { + return open_hat(address, HAT_ID_MCC_128); +} + +int mcc128_a_in_read(uint8_t address, uint8_t channel, uint32_t /*options*/, double* value) { + return read_channel(address, channel, HAT_ID_MCC_128, 8, value); +} + +int mcc134_open(uint8_t address) { + return open_hat(address, HAT_ID_MCC_134); +} + +int mcc134_tc_type_write(uint8_t address, uint8_t channel, uint8_t type) { + State& s = state(); + std::lock_guard lock(s.mutex); + if (!ensure_loaded(s)) return RESULT_UNDEFINED; + if (!s.opened.count(address)) return RESULT_INVALID_DEVICE; + const SimHat* hat = find_hat(s, address); + if (!hat || id_for_type(hat->type) != HAT_ID_MCC_134) return RESULT_INVALID_DEVICE; + if (channel >= 4) return RESULT_BAD_PARAMETER; + s.tc_types[{address, channel}] = type; + return RESULT_SUCCESS; +} + +int mcc134_t_in_read(uint8_t address, uint8_t channel, double* value) { + return read_channel(address, channel, HAT_ID_MCC_134, 4, value); +} + +} // extern "C" diff --git a/src/sim/daqhats_sim.hpp b/src/sim/daqhats_sim.hpp new file mode 100644 index 0000000..abcccbb --- /dev/null +++ b/src/sim/daqhats_sim.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "sim/scenario.hpp" + +// Control surface of libdaqhats_sim, the simulated stand-in for MCC's libdaqhats +// (hat_list, mcc128_*, mcc134_*; see include/daqhats/). Without any setup the +// first API call loads the scenario named by the NOVA_SIM_SCENARIO environment +// variable, or default_scenario() if it is unset. A scenario file that cannot +// be read makes hat_list fail (negative result) and says why on stderr, so a +// typo in the path is loud rather than silently simulating something else. +namespace daqhats_sim { + +// Replace the simulated hardware. Nothing is opened afterwards and the +// waveform clock restarts at zero. +void load(const Scenario& scenario); + +// Forget everything; the next API call loads from the environment again. +void reset(); + +// The scenario's fixed GPIO input levels (pin -> level), so the simulated GPIO +// can share the scenario file. Empty if no scenario can be loaded. +std::map gpio_inputs(); + +// Seconds since the simulation started, for tests. An empty function goes back +// to the real (steady) clock. +void set_clock(std::function seconds); + +} // namespace daqhats_sim diff --git a/src/sim/hardware_sim.cpp b/src/sim/hardware_sim.cpp new file mode 100644 index 0000000..df8f350 --- /dev/null +++ b/src/sim/hardware_sim.cpp @@ -0,0 +1,40 @@ +#include "interfaces/hardware.hpp" + +#include + +#include "drivers/mcc_daqhats.hpp" +#include "sim/daqhats_sim.hpp" +#include "sim/sim_actuators.hpp" + +// The simulated machine. The DAQ is the real MccDaqHats driver, which novaMock +// links against libdaqhats_sim instead of MCC's library; the outputs are kept in +// memory. +Hardware build_hardware(const std::vector& output_pins, + const std::vector& input_pins) { + Hardware hw; + std::cout << "Simulated hardware: HATs from the scenario, outputs kept in memory." << std::endl; + + hw.daq = std::make_unique(); + hw.daq_hats = discover_and_open(*hw.daq); + + hw.servo = std::make_unique(); + + // The same startup state as the real chips: all relays on, output pins low. + auto relays = std::make_unique(); + for (int i = 0; i < 16; i++) { + hw.relay_state.set(i, true); + } + relays->write_output(hw.relay_state); + hw.relays = std::move(relays); + + auto gpio = std::make_unique(daqhats_sim::gpio_inputs()); + for (auto pin : output_pins) { + gpio->set_direction(pin, "out"); + gpio->write(pin, 0); + } + for (auto pin : input_pins) { + gpio->set_direction(pin, "in"); + } + hw.gpio = std::move(gpio); + return hw; +} diff --git a/src/sim/include/daqhats/daqhats.h b/src/sim/include/daqhats/daqhats.h new file mode 100644 index 0000000..c476ca8 --- /dev/null +++ b/src/sim/include/daqhats/daqhats.h @@ -0,0 +1,50 @@ +/* + * Simulated stand-in for the parts of MCC's that novaGround + * uses. This is NOT MCC's header: the declarations are written to match the + * documented C API (names, argument types, "negative result means error"), and + * the numeric constants are this simulator's own. It is used only together with + * libdaqhats_sim (src/sim/daqhats_sim.cpp); a real build includes MCC's header. + */ +#ifndef NOVA_SIM_DAQHATS_H +#define NOVA_SIM_DAQHATS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Result codes. Anything other than RESULT_SUCCESS is an error. */ +#define RESULT_SUCCESS 0 +#define RESULT_BAD_PARAMETER -1 +#define RESULT_BUSY -2 +#define RESULT_TIMEOUT -3 +#define RESULT_LOCK_TIMEOUT -4 +#define RESULT_INVALID_DEVICE -5 +#define RESULT_RESOURCE_UNAVAIL -6 +#define RESULT_COMMS_FAILURE -7 +#define RESULT_UNDEFINED -10 + +/* HAT product ids (simulator values). */ +#define HAT_ID_ANY 0 +#define HAT_ID_MCC_134 0x0143 +#define HAT_ID_MCC_128 0x0146 + +#define OPTS_DEFAULT 0 + +struct HatInfo { + uint8_t address; + uint16_t id; + uint16_t version; + char product_name[256]; +}; + +/* Number of HATs found whose id matches filter_by_id (HAT_ID_ANY matches all). + * If list is not NULL it receives that many entries. */ +int hat_list(uint16_t filter_by_id, struct HatInfo* list); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/sim/include/daqhats/mcc128.h b/src/sim/include/daqhats/mcc128.h new file mode 100644 index 0000000..9455163 --- /dev/null +++ b/src/sim/include/daqhats/mcc128.h @@ -0,0 +1,23 @@ +/* + * Simulated stand-in for the parts of MCC's that novaGround + * uses. See daqhats.h in this directory for what that means. + */ +#ifndef NOVA_SIM_MCC128_H +#define NOVA_SIM_MCC128_H + +#include + +#include "daqhats.h" + +#ifdef __cplusplus +extern "C" { +#endif + +int mcc128_open(uint8_t address); +int mcc128_a_in_read(uint8_t address, uint8_t channel, uint32_t options, double* value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/sim/include/daqhats/mcc134.h b/src/sim/include/daqhats/mcc134.h new file mode 100644 index 0000000..1f9452b --- /dev/null +++ b/src/sim/include/daqhats/mcc134.h @@ -0,0 +1,34 @@ +/* + * Simulated stand-in for the parts of MCC's that novaGround + * uses. See daqhats.h in this directory for what that means. + */ +#ifndef NOVA_SIM_MCC134_H +#define NOVA_SIM_MCC134_H + +#include + +#include "daqhats.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Thermocouple types (simulator values). */ +#define TC_TYPE_J 0 +#define TC_TYPE_K 1 +#define TC_TYPE_T 2 +#define TC_TYPE_E 3 +#define TC_TYPE_R 4 +#define TC_TYPE_S 5 +#define TC_TYPE_B 6 +#define TC_TYPE_N 7 + +int mcc134_open(uint8_t address); +int mcc134_tc_type_write(uint8_t address, uint8_t channel, uint8_t type); +int mcc134_t_in_read(uint8_t address, uint8_t channel, double* value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/sim/scenario.cpp b/src/sim/scenario.cpp new file mode 100644 index 0000000..7279787 --- /dev/null +++ b/src/sim/scenario.cpp @@ -0,0 +1,161 @@ +#include "scenario.hpp" + +#include +#include +#include + +namespace json = boost::json; + +namespace { +double as_double(const json::value& v, const std::string& what) { + if (v.is_double()) return v.as_double(); + if (v.is_int64()) return static_cast(v.as_int64()); + if (v.is_uint64()) return static_cast(v.as_uint64()); + throw ScenarioError(what + " must be a number"); +} + +int as_int(const json::value& v, const std::string& what) { + if (v.is_int64()) return static_cast(v.as_int64()); + if (v.is_uint64()) return static_cast(v.as_uint64()); + if (v.is_double()) return static_cast(v.as_double()); + throw ScenarioError(what + " must be a number"); +} + +int key_as_int(const std::string& key, const std::string& what) { + try { + size_t used = 0; + int n = std::stoi(key, &used); + if (used == key.size()) return n; + } catch (...) { + } + throw ScenarioError(what + " key '" + key + "' is not an integer"); +} + +const json::object& as_object(const json::value& v, const std::string& what) { + if (!v.is_object()) throw ScenarioError(what + " must be an object"); + return v.as_object(); +} + +const json::array& as_array(const json::value& v, const std::string& what) { + if (!v.is_array()) throw ScenarioError(what + " must be an array"); + return v.as_array(); +} + +Waveform parse_waveform(const json::value& v, const std::string& what) { + const json::object& o = as_object(v, what); + Waveform w; + w.min = 0.0; + w.max = 1.0; + if (auto it = o.if_contains("wave")) { + if (!it->is_string()) throw ScenarioError(what + ".wave must be a string"); + w.wave = std::string(it->as_string().c_str()); + } + if (auto it = o.if_contains("min")) w.min = as_double(*it, what + ".min"); + if (auto it = o.if_contains("max")) w.max = as_double(*it, what + ".max"); + if (auto it = o.if_contains("period")) w.period = as_double(*it, what + ".period"); + if (auto it = o.if_contains("noise")) w.noise = as_double(*it, what + ".noise"); + w.value = (w.min + w.max) / 2.0; + if (auto it = o.if_contains("value")) w.value = as_double(*it, what + ".value"); + return w; +} +} + +Scenario parse_scenario(const std::string& json_text) { + boost::system::error_code ec; + json::value root = json::parse(json_text, ec); + if (ec) throw ScenarioError("scenario is not valid JSON: " + ec.message()); + const json::object& top = as_object(root, "scenario"); + + Scenario s; + if (auto it = top.if_contains("seed")) s.seed = static_cast(as_int(*it, "seed")); + + if (auto it = top.if_contains("hats")) { + for (const json::value& hv : as_array(*it, "hats")) { + const json::object& ho = as_object(hv, "hat"); + SimHat hat; + auto addr = ho.if_contains("address"); + if (!addr) throw ScenarioError("hat is missing address"); + hat.address = as_int(*addr, "hat.address"); + auto type = ho.if_contains("type"); + if (!type || !type->is_string()) throw ScenarioError("hat is missing a string type"); + hat.type = std::string(type->as_string().c_str()); + if (hat.type != "mcc128" && hat.type != "mcc134") { + throw ScenarioError("unknown hat type '" + hat.type + "' (use mcc128 or mcc134)"); + } + if (auto ch = ho.if_contains("channels")) { + for (const auto& [key, value] : as_object(*ch, "hat.channels")) { + std::string name(key.data(), key.size()); + int channel = key_as_int(name, "channels"); + hat.channels[channel] = parse_waveform(value, "channel " + name); + } + } + s.hats.push_back(std::move(hat)); + } + } + + if (auto it = top.if_contains("faults")) { + const json::object& f = as_object(*it, "faults"); + if (auto re = f.if_contains("read_errors")) { + for (const json::value& ev : as_array(*re, "faults.read_errors")) { + const json::object& eo = as_object(ev, "read error"); + auto a = eo.if_contains("address"); + auto c = eo.if_contains("channel"); + if (!a || !c) throw ScenarioError("read error needs address and channel"); + s.read_errors.insert({as_int(*a, "read error address"), as_int(*c, "read error channel")}); + } + } + if (auto mh = f.if_contains("missing_hats")) { + for (const json::value& av : as_array(*mh, "faults.missing_hats")) { + s.missing_hats.insert(as_int(av, "missing hat address")); + } + } + if (auto rd = f.if_contains("read_delay_us")) s.read_delay_us = as_int(*rd, "faults.read_delay_us"); + } + + if (auto it = top.if_contains("gpio_inputs")) { + for (const auto& [key, value] : as_object(*it, "gpio_inputs")) { + std::string name(key.data(), key.size()); + s.gpio_inputs[key_as_int(name, "gpio_inputs")] = as_int(value, "gpio input " + name); + } + } + return s; +} + +Scenario load_scenario_file(const std::string& path) { + std::ifstream in(path); + if (!in) throw ScenarioError("cannot open scenario file: " + path); + std::stringstream ss; + ss << in.rdbuf(); + return parse_scenario(ss.str()); +} + +Scenario default_scenario() { + auto sine = [](double lo, double hi, double period, double noise) { + Waveform w; + w.wave = "sine"; + w.min = lo; + w.max = hi; + w.period = period; + w.noise = noise; + w.value = (lo + hi) / 2.0; + return w; + }; + + Scenario s; + SimHat h0; + h0.address = 0; + h0.type = "mcc128"; + h0.channels[0] = sine(0.99, 3.99, 20, 0.02); // PGSO + h0.channels[1] = sine(0.99, 3.99, 20, 0.02); // PGS + h0.channels[4] = sine(1.98, 7.98, 25, 0.02); // PVO + h0.channels[6] = sine(3.59, 5.20, 30, 0.05); // MOT + h0.channels[7] = sine(3.59, 5.20, 30, 0.05); // MFT + s.hats.push_back(h0); + + SimHat h1; + h1.address = 1; + h1.type = "mcc128"; + h1.channels[1] = sine(1.925, 2.06, 18, 0.01); // CC-LC + s.hats.push_back(h1); + return s; +} diff --git a/src/sim/scenario.hpp b/src/sim/scenario.hpp new file mode 100644 index 0000000..442fea5 --- /dev/null +++ b/src/sim/scenario.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "sim/waveform.hpp" + +// What the simulated hardware looks like and what it produces. Read from a +// JSON file (the NOVA_SIM_SCENARIO environment variable, see daqhats_sim.hpp), +// so the same file can describe the sensors for the Python simulator too: +// +// { +// "seed": 1, +// "hats": [ +// { "address": 0, "type": "mcc128", +// "channels": { "0": { "wave": "sine", "min": 0.99, "max": 3.99, +// "period": 20, "noise": 0.02 } } }, +// { "address": 1, "type": "mcc134", "channels": { "0": { "wave": "flat", "value": 21.5 } } } +// ], +// "faults": { +// "read_errors": [ { "address": 0, "channel": 3 } ], +// "missing_hats": [ 1 ], +// "read_delay_us": 0 +// }, +// "gpio_inputs": { "5": 1, "6": 0 } +// } +// +// A channel that is not listed reads as a flat 0. A "missing" hat is in the +// file but not found on the bus. +struct SimHat { + int address = 0; + std::string type; // "mcc128" or "mcc134" + std::map channels; +}; + +struct Scenario { + uint32_t seed = 1; + std::vector hats; + std::set> read_errors; // (address, channel) + std::set missing_hats; // addresses + int read_delay_us = 0; + std::map gpio_inputs; // pin -> level +}; + +struct ScenarioError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +// Throws ScenarioError on malformed JSON, an unknown hat type, or values of +// the wrong type. +Scenario parse_scenario(const std::string& json_text); +Scenario load_scenario_file(const std::string& path); + +// One MCC 128 at address 0 with the same sine channels as the Python +// simulator's default GCS sensors. +Scenario default_scenario(); diff --git a/src/sim/sim_actuators.cpp b/src/sim/sim_actuators.cpp new file mode 100644 index 0000000..43de258 --- /dev/null +++ b/src/sim/sim_actuators.cpp @@ -0,0 +1,99 @@ +#include "sim_actuators.hpp" + +#include + +bool SimRelayBank::write_output(const std::bitset<16>& state) { + std::lock_guard lock(mutex_); + state_ = state; + ++writes_; + return true; +} + +std::bitset<16> SimRelayBank::state() const { + std::lock_guard lock(mutex_); + return state_; +} + +int SimRelayBank::writes() const { + std::lock_guard lock(mutex_); + return writes_; +} + +void SimPwmDriver::writeMicroseconds(uint8_t channel, uint16_t microseconds) { + std::lock_guard lock(mutex_); + pulse_us_[channel] = microseconds; +} + +bool SimPwmDriver::disablePWM(uint8_t channel) { + std::lock_guard lock(mutex_); + pulse_us_[channel] = 0; + return true; +} + +uint16_t SimPwmDriver::microseconds(uint8_t channel) const { + std::lock_guard lock(mutex_); + auto it = pulse_us_.find(channel); + return it == pulse_us_.end() ? 0 : it->second; +} + +SimDigitalIo::SimDigitalIo(std::map input_levels) + : input_levels_(std::move(input_levels)) {} + +bool SimDigitalIo::set_direction(int pin, const std::string& direction) { + std::lock_guard lock(mutex_); + if (direction != "in" && direction != "out") { + std::cerr << "Invalid direction for GPIO " << pin << ": " << direction << "\n"; + return false; + } + directions_[pin] = direction; + return true; +} + +bool SimDigitalIo::write(int pin, int value) { + std::lock_guard lock(mutex_); + auto it = directions_.find(pin); + if (it == directions_.end() || it->second != "out") { + std::cerr << "Pin " << pin << " not configured as output\n"; + return false; + } + output_levels_[pin] = value; + return true; +} + +bool SimDigitalIo::read(int pin, int& value) { + std::lock_guard lock(mutex_); + auto it = directions_.find(pin); + if (it == directions_.end() || it->second != "in") { + std::cerr << "Pin " << pin << " not configured as input\n"; + return false; + } + auto level = input_levels_.find(pin); + value = level == input_levels_.end() ? 0 : level->second; + return true; +} + +std::vector SimDigitalIo::get_input_pins() { + std::lock_guard lock(mutex_); + std::vector pins; + for (const auto& [pin, direction] : directions_) { + if (direction == "in") pins.push_back(pin); + } + return pins; +} + +std::map SimDigitalIo::read_all_inputs() { + std::lock_guard lock(mutex_); + std::map result; + for (const auto& [pin, direction] : directions_) { + if (direction != "in") continue; + auto level = input_levels_.find(pin); + result[pin] = level == input_levels_.end() ? 0 : level->second; + } + return result; +} + +int SimDigitalIo::output_level(int pin) const { + std::lock_guard lock(mutex_); + auto it = output_levels_.find(pin); + return it == output_levels_.end() ? 0 : it->second; +} diff --git a/src/sim/sim_actuators.hpp b/src/sim/sim_actuators.hpp new file mode 100644 index 0000000..a5ee4e7 --- /dev/null +++ b/src/sim/sim_actuators.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "interfaces/digital_io.hpp" +#include "interfaces/pwm_driver.hpp" +#include "interfaces/relay_bank.hpp" + +// Simulated outputs for novaMock: they behave like the real drivers (same +// contracts, same failure answers) but only keep state in memory, so the real +// controllers, logger and telemetry run on top of them with nothing attached. + +class SimRelayBank : public RelayBank { +public: + bool is_ready() const override { return true; } + bool write_output(const std::bitset<16>& state) override; + + std::bitset<16> state() const; + int writes() const; + +private: + mutable std::mutex mutex_; + std::bitset<16> state_; + int writes_ = 0; +}; + +class SimPwmDriver : public PwmDriver { +public: + bool is_ready() const override { return true; } + void writeMicroseconds(uint8_t channel, uint16_t microseconds) override; + bool disablePWM(uint8_t channel) override; + + // 0 when the channel is off or was never written. + uint16_t microseconds(uint8_t channel) const; + +private: + mutable std::mutex mutex_; + std::map pulse_us_; +}; + +// Pins start unconfigured. Like the real driver: write needs an output pin, +// read an input pin. An input pin reads the level given for it (default 0). +class SimDigitalIo : public DigitalIo { +public: + explicit SimDigitalIo(std::map input_levels = {}); + + bool set_direction(int pin, const std::string& direction) override; + bool write(int pin, int value) override; + bool read(int pin, int& value) override; + std::vector get_input_pins() override; + std::map read_all_inputs() override; + + int output_level(int pin) const; // last value written, 0 if never written + +private: + mutable std::mutex mutex_; + std::map directions_; + std::map input_levels_; + std::map output_levels_; +}; diff --git a/src/sim/waveform.cpp b/src/sim/waveform.cpp new file mode 100644 index 0000000..9a37d17 --- /dev/null +++ b/src/sim/waveform.cpp @@ -0,0 +1,50 @@ +#include "waveform.hpp" + +#include +#include + +namespace { +double smoothstep(double x) { + x = std::max(0.0, std::min(1.0, x)); + return x * x * (3.0 - 2.0 * x); +} + +constexpr double kPi = 3.14159265358979323846; +} + +double wave_value(const Waveform& w, double t, WaveformRng& rng) { + const double lo = w.min; + const double hi = w.max; + const double per = std::max(w.period, 1e-6); + const double mid = (lo + hi) / 2.0; + const double amp = (hi - lo) / 2.0; + // Python's t % per is never negative; fmod of a negative t would be. + double wrapped = std::fmod(t, per); + if (wrapped < 0.0) wrapped += per; + const double phase = wrapped / per; // 0..1 within the period + + double base = mid; + if (w.wave == "flat") { + base = w.value; + } else if (w.wave == "sine") { + base = mid + amp * std::sin(2.0 * kPi * phase); + } else if (w.wave == "square") { + base = phase < 0.5 ? hi : lo; + } else if (w.wave == "triangle") { + base = lo + (hi - lo) * (phase < 0.5 ? 2.0 * phase : 2.0 * (1.0 - phase)); + } else if (w.wave == "sawtooth") { + base = lo + (hi - lo) * phase; + } else if (w.wave == "s_curve") { + base = lo + (hi - lo) * smoothstep(phase); + } else if (w.wave == "ramp") { + base = lo + (hi - lo) * std::min(t / per, 1.0); + } else if (w.wave == "noise") { + base = rng.uniform(std::min(lo, hi), std::max(lo, hi)); + } + + if (w.noise != 0.0) { + const double n = std::fabs(w.noise); + base += rng.uniform(-n, n); + } + return base; +} diff --git a/src/sim/waveform.hpp b/src/sim/waveform.hpp new file mode 100644 index 0000000..740fca5 --- /dev/null +++ b/src/sim/waveform.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +// The sensor waveform model of the Python simulator (tools/novaSim.py in the +// backend), so both simulators can be driven by the same scenario values. +// Names: flat, sine, square, triangle, sawtooth, s_curve, ramp, noise. +struct Waveform { + std::string wave = "flat"; + double min = 0.0; + double max = 1.0; + double period = 10.0; // seconds + double noise = 0.0; // +/- uniform noise added to the value + double value = 0.5; // the level of a "flat" wave + + static Waveform flat(double level) { + Waveform w; + w.wave = "flat"; + w.min = w.max = w.value = level; + return w; + } +}; + +// Deterministic given the seed. `noise` waves and noise amplitude draw from it. +class WaveformRng { +public: + explicit WaveformRng(uint32_t seed = 1) : engine_(seed) {} + double uniform(double lo, double hi) { + return std::uniform_real_distribution(lo, hi)(engine_); + } + +private: + std::mt19937 engine_; +}; + +// The value at `t` seconds after the simulator started. "ramp" is a one-shot +// ramp from min to max over `period`, then holds at max. An unknown wave name +// gives the midpoint, as in Python. +double wave_value(const Waveform& w, double t, WaveformRng& rng); diff --git a/src/utils/interruptible_sleep.hpp b/src/utils/interruptible_sleep.hpp new file mode 100644 index 0000000..b00b46e --- /dev/null +++ b/src/utils/interruptible_sleep.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include + +// A sleep that a stop request cuts short, so a loop can end promptly instead +// of after its next interval. Make one per loop and reuse it: constructing the +// condition variable allocates, waiting on it does not. +class InterruptibleSleep { +public: + // Sleeps for the duration, or until a stop is requested. Returns true if a + // stop was requested (so the caller should leave its loop). + bool sleep_for(std::stop_token stop, std::chrono::milliseconds duration) { + std::unique_lock lock(mutex_); + cv_.wait_for(lock, stop, duration, [] { return false; }); + return stop.stop_requested(); + } + +private: + std::mutex mutex_; + std::condition_variable_any cv_; +}; diff --git a/tests/fake_digital_io.hpp b/tests/fake_digital_io.hpp new file mode 100644 index 0000000..9fcd239 --- /dev/null +++ b/tests/fake_digital_io.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include + +#include "interfaces/digital_io.hpp" + +// Models the contract of the real driver: write needs an output pin, read an +// input pin. +class FakeDigitalIo : public DigitalIo { +public: + std::vector calls; // "dir::" and "write::" + std::map directions; + std::map levels; + bool throw_on_read_all = false; + + bool set_direction(int pin, const std::string& direction) override { + calls.push_back("dir:" + std::to_string(pin) + ":" + direction); + directions[pin] = direction; + return true; + } + bool write(int pin, int value) override { + calls.push_back("write:" + std::to_string(pin) + ":" + std::to_string(value)); + if (directions[pin] != "out") return false; + levels[pin] = value; + return true; + } + bool read(int pin, int& value) override { + if (directions[pin] != "in") return false; + value = levels[pin]; + return true; + } + std::vector get_input_pins() override { + std::vector pins; + for (const auto& [pin, dir] : directions) { + if (dir == "in") pins.push_back(pin); + } + return pins; + } + std::map read_all_inputs() override { + if (throw_on_read_all) throw std::runtime_error("fake gpio failure"); + std::map out; + for (int pin : get_input_pins()) out[pin] = levels[pin]; + return out; + } +}; diff --git a/tests/fake_i2c_bus.hpp b/tests/fake_i2c_bus.hpp new file mode 100644 index 0000000..518a5d2 --- /dev/null +++ b/tests/fake_i2c_bus.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "interfaces/i2c_bus.hpp" + +// An I2cBus that is a register file: it records every write, answers reads +// from the registers, and can be told to fail. +class FakeI2cBus : public I2cBus { +public: + struct Write { + uint8_t address; + uint8_t reg; + uint8_t value; + }; + + bool ready = true; + bool attach_ok = true; + // The next `fail_writes` write attempts fail (and are still recorded). + int fail_writes = 0; + bool fail_reads = false; + + std::vector writes; + std::vector attached; + std::map, uint8_t> registers; + + bool attach(uint8_t address) override { + attached.push_back(address); + return ready && attach_ok; + } + + bool write_reg8(uint8_t address, uint8_t reg, uint8_t value) override { + writes.push_back({address, reg, value}); + if (fail_writes > 0) { + --fail_writes; + error_ = "fake write failure"; + return false; + } + registers[{address, reg}] = value; + return true; + } + + bool read_reg8(uint8_t address, uint8_t reg, uint8_t& value) override { + if (fail_reads) { + error_ = "fake read failure"; + return false; + } + value = registers[{address, reg}]; + return true; + } + + bool is_ready() const override { return ready; } + const std::string& last_error() const override { return error_; } + + uint8_t reg(uint8_t address, uint8_t r) { return registers[{address, r}]; } + +private: + std::string error_; +}; diff --git a/tests/meson.build b/tests/meson.build index ef76bcb..c35432b 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -10,14 +10,27 @@ nova_core_tests = executable( 'test_telemetry.cpp', 'test_data_logger.cpp', 'test_data_file.cpp', + 'test_tca9535.cpp', + 'test_relay_controller.cpp', + 'test_pca9685.cpp', + 'test_servo_controller.cpp', + 'test_gpio_controller.cpp', + 'test_sampler.cpp', + 'test_stoppable_loops.cpp', + 'test_waveform.cpp', + 'test_scenario.cpp', + 'test_daqhats_sim.cpp', + 'test_sim_actuators.cpp', + 'test_sim_hardware.cpp', + '../src/sim/hardware_sim.cpp', ), - link_with: nova_core, + link_with: sim_libs, dependencies: [boost_test_dep, boost_dep, threads_dep], - include_directories: include, + include_directories: [sim_include] + include, cpp_args: ['-DBOOST_TEST_DYN_LINK'], ) -test('nova_core', nova_core_tests) +test('nova_core', nova_core_tests, timeout: 120) # Every flag the shell scripts pass to the binary must be one it accepts. test( diff --git a/tests/novamock_smoke.sh b/tests/novamock_smoke.sh new file mode 100755 index 0000000..99b3faf --- /dev/null +++ b/tests/novamock_smoke.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# End to end on the real application with simulated hardware: novaMock against a +# private broker. Checks that telemetry carries the scenario's values and that a +# data-saving command records them to CSV. +# +# Sandbox: the broker listens on loopback on a free port and is never 1883; +# everything novaMock writes goes under a scratch directory; +# the "backend" is a closed port, so nothing is uploaded anywhere. +set -euo pipefail + +NOVAMOCK="${1:?usage: novamock_smoke.sh }" +WORK="$(mktemp -d)" +PIDS=() + +cleanup() { + for p in "${PIDS[@]:-}"; do kill "$p" 2>/dev/null || true; done + wait 2>/dev/null || true + rm -rf "$WORK" +} +trap cleanup EXIT + +fail() { echo "FAIL: $*" >&2; [[ -f "$WORK/novamock.log" ]] && sed 's/^/ novaMock: /' "$WORK/novamock.log" >&2; exit 1; } + +PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')" +case "$PORT" in + 1883|8000|8001|3000|3001|8080|8081) echo "refusing protected port $PORT" >&2; exit 2 ;; +esac + +printf 'listener %s 127.0.0.1\nallow_anonymous true\n' "$PORT" > "$WORK/mosquitto.conf" +mosquitto -c "$WORK/mosquitto.conf" > "$WORK/mosquitto.log" 2>&1 & +PIDS+=($!) +for _ in $(seq 50); do + if (exec 3<>"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then break; fi + sleep 0.1 +done + +# One MCC 128 with a flat 2.5 V on channel 3, and GPIO input 5 held high. +cat > "$WORK/scenario.json" <<'EOF' +{"hats":[{"address":0,"type":"mcc128","channels":{"3":{"wave":"flat","value":2.5}}}], + "gpio_inputs":{"5":1}} +EOF + +mkdir "$WORK/run" +cd "$WORK/run" # novaMock records to ./data +NOVA_ENV=dev \ +NOVA_STATE_DIR="$WORK/state" \ +NOVA_SIM_SCENARIO="$WORK/scenario.json" \ + "$NOVAMOCK" --broker "127.0.0.1:$PORT" --backend "http://127.0.0.1:9" \ + --sample-ms 20 --publish-ms 50 > "$WORK/novamock.log" 2>&1 & +PIDS+=($!) + +# 1. Telemetry with the scenario's sensor value and GPIO input arrives. +cat > "$WORK/check_telemetry.py" <<'EOF' +import json, sys +for line in sys.stdin: + try: + msg = json.loads(line) + except ValueError: + continue + sensors = {(s["hat_id"], s["channel_id"]): s["value"] for s in msg.get("sensors", [])} + gpios = {g["pin_id"]: g["state"] for g in msg.get("gpios", [])} + if sensors.get((0, 3)) == 2.5 and gpios.get(5) == 1: + sys.exit(0) +sys.exit(1) +EOF +python3 "$WORK/check_telemetry.py" < <(mosquitto_sub -h 127.0.0.1 -p "$PORT" -t nova/telemetry/engine -W 20) \ + || fail "no telemetry with hat 0 channel 3 = 2.5 and GPIO 5 = 1" +echo "ok: telemetry carries the scenario values" + +# 2. A data-saving command reaches the logger, which records the sampled rows. +mosquitto_pub -h 127.0.0.1 -p "$PORT" -q 1 -t nova/command \ + -m '{"source":"novaOps","command":{"type":"data_file","action":"start_data_saving","filename":"smoke"}}' +sleep 1 +mosquitto_pub -h 127.0.0.1 -p "$PORT" -q 1 -t nova/command \ + -m '{"source":"novaOps","command":{"type":"data_file","action":"stop_data_saving"}}' + +CSV="$WORK/run/data/novaMock_smoke_sensors.csv" +for _ in $(seq 50); do [[ -f "$WORK/state/last_upload.json" ]] && break; sleep 0.1; done +[[ -f "$CSV" ]] || fail "no sensor CSV at $CSV" +[[ "$(head -n 1 "$CSV")" == "timestamp,hat0_ch0,hat0_ch1,hat0_ch2,hat0_ch3,hat0_ch4,hat0_ch5,hat0_ch6,hat0_ch7" ]] \ + || fail "unexpected CSV header: $(head -n 1 "$CSV")" +[[ "$(wc -l < "$CSV")" -ge 3 ]] || fail "expected at least two data rows in $CSV" +ROW="$(sed -n 2p "$CSV")" +[[ "$(cut -d, -f5 <<<"$ROW")" == "2.5" ]] || fail "hat0_ch3 should be 2.5 in row: $ROW" +echo "ok: the command recorded the sampled rows" + +# 3. The upload to the unreachable backend was attempted and reported as failed. +[[ -f "$WORK/state/last_upload.json" ]] || fail "no last_upload.json was written" +python3 -c 'import json,sys; d = json.load(open(sys.argv[1])); sys.exit(0 if d.get("ok") is False else 1)' \ + "$WORK/state/last_upload.json" || fail "last_upload.json should report a failed upload: $(cat "$WORK/state/last_upload.json")" +echo "ok: the failed upload was reported" diff --git a/tests/test_cli.cpp b/tests/test_cli.cpp index 774da53..b7b4ec0 100644 --- a/tests/test_cli.cpp +++ b/tests/test_cli.cpp @@ -6,6 +6,17 @@ #include "utils/cli.hpp" +// Boost.Test prints both sides of a failed comparison, and an enum class has no +// operator<< of its own. ParseStatus is in the global namespace, so ADL finds this. +inline std::ostream& operator<<(std::ostream& os, ParseStatus s) { + switch (s) { + case ParseStatus::Run: return os << "Run"; + case ParseStatus::ExitOk: return os << "ExitOk"; + case ParseStatus::ExitError: return os << "ExitError"; + } + return os << "ParseStatus(" << static_cast(s) << ")"; +} + namespace { RuntimeConfig defaults() { RuntimeConfig d; diff --git a/tests/test_daqhats_sim.cpp b/tests/test_daqhats_sim.cpp new file mode 100644 index 0000000..68e218a --- /dev/null +++ b/tests/test_daqhats_sim.cpp @@ -0,0 +1,249 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "controllers/sampler.hpp" +#include "daqhats/daqhats.h" +#include "daqhats/mcc128.h" +#include "daqhats/mcc134.h" +#include "drivers/mcc_daqhats.hpp" +#include "sim/daqhats_sim.hpp" +#include "temp_dir.hpp" + +namespace tt = boost::test_tools; + +namespace { +// Hat 0: MCC 128 with a sine on channel 0, a flat 2.5 on channel 3 and a read +// error on channel 5. Hat 1: MCC 134 with a flat 21.5 on channel 0. Hat 2 is +// in the file but missing from the bus. +const char* kScenario = R"({ + "hats": [ + { "address": 0, "type": "mcc128", "channels": { + "0": { "wave": "sine", "min": 1, "max": 5, "period": 20 }, + "3": { "wave": "flat", "value": 2.5 } } }, + { "address": 1, "type": "mcc134", "channels": { "0": { "wave": "flat", "value": 21.5 } } }, + { "address": 2, "type": "mcc128" } + ], + "faults": { "read_errors": [ { "address": 0, "channel": 5 } ], "missing_hats": [ 2 ] }, + "gpio_inputs": { "5": 1 } +})"; + +struct Fixture { + Fixture() { + daqhats_sim::load(parse_scenario(kScenario)); + daqhats_sim::set_clock([] { return 5.0; }); // a quarter of the sine's period + } + ~Fixture() { + daqhats_sim::reset(); + unsetenv("NOVA_SIM_SCENARIO"); + } +}; +} + +BOOST_FIXTURE_TEST_SUITE(daqhats_sim_api, Fixture) + +BOOST_AUTO_TEST_CASE(hat_list_counts_the_hats_that_are_on_the_bus) { + BOOST_TEST(hat_list(HAT_ID_ANY, nullptr) == 2); // the missing hat is not found + HatInfo list[2]; + BOOST_REQUIRE_EQUAL(hat_list(HAT_ID_ANY, list), 2); + BOOST_TEST(list[0].address == 0); + BOOST_TEST(list[0].id == HAT_ID_MCC_128); + BOOST_TEST(list[1].address == 1); + BOOST_TEST(list[1].id == HAT_ID_MCC_134); +} + +BOOST_AUTO_TEST_CASE(hat_list_can_filter_by_product_id) { + BOOST_TEST(hat_list(HAT_ID_MCC_128, nullptr) == 1); + BOOST_TEST(hat_list(HAT_ID_MCC_134, nullptr) == 1); + BOOST_TEST(hat_list(0x7FFF, nullptr) == 0); +} + +BOOST_AUTO_TEST_CASE(open_succeeds_only_for_a_present_hat_of_the_right_type) { + BOOST_TEST(mcc128_open(0) == RESULT_SUCCESS); + BOOST_TEST(mcc134_open(1) == RESULT_SUCCESS); + BOOST_TEST(mcc134_open(0) == RESULT_INVALID_DEVICE); // an MCC 128 + BOOST_TEST(mcc128_open(1) == RESULT_INVALID_DEVICE); // an MCC 134 + BOOST_TEST(mcc128_open(2) == RESULT_INVALID_DEVICE); // missing + BOOST_TEST(mcc128_open(7) == RESULT_INVALID_DEVICE); // not in the file +} + +BOOST_AUTO_TEST_CASE(reading_needs_an_open_hat) { + double v = -1; + BOOST_TEST(mcc128_a_in_read(0, 3, OPTS_DEFAULT, &v) == RESULT_INVALID_DEVICE); + BOOST_REQUIRE_EQUAL(mcc128_open(0), RESULT_SUCCESS); + BOOST_TEST(mcc128_a_in_read(0, 3, OPTS_DEFAULT, &v) == RESULT_SUCCESS); + BOOST_TEST(v == 2.5); +} + +BOOST_AUTO_TEST_CASE(a_read_follows_the_waveform_at_the_simulation_clock) { + BOOST_REQUIRE_EQUAL(mcc128_open(0), RESULT_SUCCESS); + double v = 0; + BOOST_REQUIRE_EQUAL(mcc128_a_in_read(0, 0, OPTS_DEFAULT, &v), RESULT_SUCCESS); + BOOST_TEST(v == 5.0, tt::tolerance(1e-9)); // sine peak at t = period / 4 + daqhats_sim::set_clock([] { return 0.0; }); + BOOST_REQUIRE_EQUAL(mcc128_a_in_read(0, 0, OPTS_DEFAULT, &v), RESULT_SUCCESS); + BOOST_TEST(v == 3.0, tt::tolerance(1e-9)); // midpoint +} + +BOOST_AUTO_TEST_CASE(a_channel_with_no_waveform_reads_zero) { + BOOST_REQUIRE_EQUAL(mcc128_open(0), RESULT_SUCCESS); + double v = -1; + BOOST_TEST(mcc128_a_in_read(0, 7, OPTS_DEFAULT, &v) == RESULT_SUCCESS); + BOOST_TEST(v == 0.0); +} + +BOOST_AUTO_TEST_CASE(bad_arguments_are_bad_parameters) { + BOOST_REQUIRE_EQUAL(mcc128_open(0), RESULT_SUCCESS); + double v = 0; + BOOST_TEST(mcc128_a_in_read(0, 8, OPTS_DEFAULT, &v) == RESULT_BAD_PARAMETER); // only 0-7 + BOOST_TEST(mcc128_a_in_read(0, 0, OPTS_DEFAULT, nullptr) == RESULT_BAD_PARAMETER); + BOOST_REQUIRE_EQUAL(mcc134_open(1), RESULT_SUCCESS); + BOOST_TEST(mcc134_t_in_read(1, 4, &v) == RESULT_BAD_PARAMETER); // only 0-3 +} + +BOOST_AUTO_TEST_CASE(a_configured_read_error_is_a_comms_failure_on_that_channel_only) { + BOOST_REQUIRE_EQUAL(mcc128_open(0), RESULT_SUCCESS); + double v = 0; + BOOST_TEST(mcc128_a_in_read(0, 5, OPTS_DEFAULT, &v) == RESULT_COMMS_FAILURE); + BOOST_TEST(mcc128_a_in_read(0, 4, OPTS_DEFAULT, &v) == RESULT_SUCCESS); +} + +BOOST_AUTO_TEST_CASE(the_mcc134_sets_a_thermocouple_type_only_when_open) { + BOOST_TEST(mcc134_tc_type_write(1, 0, TC_TYPE_K) == RESULT_INVALID_DEVICE); + BOOST_REQUIRE_EQUAL(mcc134_open(1), RESULT_SUCCESS); + BOOST_TEST(mcc134_tc_type_write(1, 0, TC_TYPE_K) == RESULT_SUCCESS); + BOOST_TEST(mcc134_tc_type_write(1, 4, TC_TYPE_K) == RESULT_BAD_PARAMETER); + double v = 0; + BOOST_TEST(mcc134_t_in_read(1, 0, &v) == RESULT_SUCCESS); + BOOST_TEST(v == 21.5); +} + +BOOST_AUTO_TEST_CASE(a_read_delay_slows_reads_down) { + Scenario s = parse_scenario( + R"({"hats":[{"address":0,"type":"mcc128"}],"faults":{"read_delay_us":20000}})"); + daqhats_sim::load(s); + BOOST_REQUIRE_EQUAL(mcc128_open(0), RESULT_SUCCESS); + double v = 0; + auto start = std::chrono::steady_clock::now(); + BOOST_REQUIRE_EQUAL(mcc128_a_in_read(0, 0, OPTS_DEFAULT, &v), RESULT_SUCCESS); + auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start).count(); + BOOST_TEST(ms >= 15); +} + +BOOST_AUTO_TEST_CASE(loading_a_scenario_forgets_which_hats_were_open) { + BOOST_REQUIRE_EQUAL(mcc128_open(0), RESULT_SUCCESS); + daqhats_sim::load(parse_scenario(kScenario)); + double v = 0; + BOOST_TEST(mcc128_a_in_read(0, 3, OPTS_DEFAULT, &v) == RESULT_INVALID_DEVICE); +} + +BOOST_AUTO_TEST_CASE(gpio_inputs_come_from_the_scenario) { + auto inputs = daqhats_sim::gpio_inputs(); + BOOST_REQUIRE_EQUAL(inputs.size(), 1u); + BOOST_TEST(inputs.at(5) == 1); +} + +BOOST_AUTO_TEST_CASE(without_a_scenario_variable_the_default_scenario_is_used) { + daqhats_sim::reset(); + unsetenv("NOVA_SIM_SCENARIO"); + BOOST_TEST(hat_list(HAT_ID_ANY, nullptr) == 2); +} + +BOOST_AUTO_TEST_CASE(the_scenario_variable_names_the_scenario_file) { + TempDir dir; + auto path = (dir.path() / "s.json").string(); + { std::ofstream(path) << R"({"hats":[{"address":4,"type":"mcc134"}]})"; } + daqhats_sim::reset(); + setenv("NOVA_SIM_SCENARIO", path.c_str(), 1); + HatInfo list[1]; + BOOST_REQUIRE_EQUAL(hat_list(HAT_ID_ANY, list), 1); + BOOST_TEST(list[0].address == 4); +} + +BOOST_AUTO_TEST_CASE(an_unreadable_scenario_file_fails_loudly_instead_of_falling_back) { + daqhats_sim::reset(); + setenv("NOVA_SIM_SCENARIO", "/definitely/not/here.json", 1); + BOOST_TEST(hat_list(HAT_ID_ANY, nullptr) < 0); + BOOST_TEST(mcc128_open(0) < 0); + BOOST_CHECK_THROW(MccDaqHats().discover(), std::runtime_error); +} + +BOOST_AUTO_TEST_SUITE_END() + +// The real MccDaqHats driver (compiled against these simulated headers) over the +// simulated library. +BOOST_FIXTURE_TEST_SUITE(mcc_driver_over_the_sim, Fixture) + +BOOST_AUTO_TEST_CASE(discover_lists_the_present_hats_with_their_channels) { + MccDaqHats daq; + auto hats = daq.discover(); + BOOST_REQUIRE_EQUAL(hats.size(), 2u); + BOOST_TEST(hats[0].address == 0); + BOOST_TEST(hats[0].hat_id == HAT_ID_MCC_128); + BOOST_TEST((hats[0].channels == std::vector{0, 1, 2, 3, 4, 5, 6, 7})); + BOOST_TEST(hats[1].address == 1); + BOOST_TEST(hats[1].hat_id == HAT_ID_MCC_134); + BOOST_TEST((hats[1].channels == std::vector{0, 1, 2, 3})); +} + +BOOST_AUTO_TEST_CASE(open_and_read_go_through_to_the_library) { + MccDaqHats daq; + auto hats = daq.discover(); + for (const auto& hat : hats) BOOST_REQUIRE(daq.open(hat)); + + double v = 0; + BOOST_TEST(daq.read(hats[0], 0, v)); + BOOST_TEST(v == 5.0, tt::tolerance(1e-9)); + BOOST_TEST(daq.read(hats[1], 0, v)); + BOOST_TEST(v == 21.5); + BOOST_TEST(!daq.read(hats[0], 5, v)); // the configured read error +} + +BOOST_AUTO_TEST_CASE(reading_a_hat_that_was_not_opened_fails) { + MccDaqHats daq; + auto hats = daq.discover(); + double v = 0; + BOOST_TEST(!daq.read(hats[0], 3, v)); +} + +BOOST_AUTO_TEST_CASE(an_unknown_hat_type_cannot_be_opened_or_read) { + MccDaqHats daq; + DaqHatDevice odd; + odd.address = 0; + odd.hat_id = 0x0999; + double v = 0; + BOOST_TEST(!daq.open(odd)); + BOOST_TEST(!daq.read(odd, 0, v)); +} + +BOOST_AUTO_TEST_CASE(discover_and_open_opens_every_hat_that_can_be_opened) { + MccDaqHats daq; + auto hats = discover_and_open(daq); + BOOST_TEST(hats.size() == 2u); +} + +BOOST_AUTO_TEST_CASE(a_whole_sampling_pass_over_the_simulated_hats) { + MccDaqHats daq; + auto hats = discover_and_open(daq); + TelemetryStore store; + daq_sample_once(daq, hats, store, nullptr); + + auto samples = store.snapshot_sensors(); + BOOST_REQUIRE_EQUAL(samples.size(), 12u); // 8 + 4 channels + BOOST_TEST(samples[0].hat_id == 0); + BOOST_TEST(samples[0].value == 5.0, tt::tolerance(1e-9)); + BOOST_TEST(samples[3].value == 2.5); + BOOST_TEST(std::isnan(samples[5].value)); // the configured read error + BOOST_TEST(samples[8].hat_id == 1); + BOOST_TEST(samples[8].value == 21.5); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_gpio_controller.cpp b/tests/test_gpio_controller.cpp new file mode 100644 index 0000000..826a8bb --- /dev/null +++ b/tests/test_gpio_controller.cpp @@ -0,0 +1,104 @@ +#include + +#include +#include +#include + +#include "controllers/gpio_controller.hpp" +#include "core/data_logger.hpp" +#include "fake_digital_io.hpp" +#include "temp_dir.hpp" + +namespace json = boost::json; + +namespace { +json::object cmd(const char* text) { + return json::parse(text).as_object(); +} + +std::vector fields(const std::string& line) { + std::vector out; + std::istringstream in(line); + std::string f; + while (std::getline(in, f, ',')) out.push_back(f); + return out; +} + +struct Fixture { + FakeDigitalIo io; + GpioController controller{&io, nullptr}; +}; +} + +BOOST_FIXTURE_TEST_SUITE(gpio_controller, Fixture) + +BOOST_AUTO_TEST_CASE(mode_sets_the_direction) { + controller.handle_command(cmd(R"({"id":17,"mode":"output"})")); + controller.handle_command(cmd(R"({"id":5,"mode":"input"})")); + BOOST_TEST((io.calls == std::vector{"dir:17:out", "dir:5:in"})); +} + +BOOST_AUTO_TEST_CASE(mode_is_case_insensitive) { + controller.handle_command(cmd(R"({"id":17,"mode":"OUTPUT"})")); + BOOST_TEST((io.calls == std::vector{"dir:17:out"})); +} + +BOOST_AUTO_TEST_CASE(an_unknown_mode_changes_nothing) { + controller.handle_command(cmd(R"({"id":17,"mode":"tristate"})")); + BOOST_TEST(io.calls.empty()); +} + +BOOST_AUTO_TEST_CASE(state_writes_one_or_zero) { + controller.handle_command(cmd(R"({"id":17,"mode":"output"})")); + controller.handle_command(cmd(R"({"id":17,"state":true})")); + controller.handle_command(cmd(R"({"id":17,"state":false})")); + controller.handle_command(cmd(R"({"id":17,"state":1})")); + BOOST_TEST((io.calls == std::vector{"dir:17:out", "write:17:1", "write:17:0", "write:17:1"})); + BOOST_TEST(io.levels[17] == 1); +} + +BOOST_AUTO_TEST_CASE(mode_and_state_in_one_command_set_the_direction_first) { + controller.handle_command(cmd(R"({"id":22,"mode":"output","state":true})")); + BOOST_TEST((io.calls == std::vector{"dir:22:out", "write:22:1"})); + BOOST_TEST(io.levels[22] == 1); +} + +BOOST_AUTO_TEST_CASE(a_command_without_an_id_does_nothing) { + controller.handle_command(cmd(R"({"mode":"output","state":true})")); + controller.handle_command(cmd(R"({"id":"17","state":true})")); + BOOST_TEST(io.calls.empty()); +} + +BOOST_AUTO_TEST_CASE(a_state_that_is_not_a_bool_or_number_is_ignored) { + controller.handle_command(cmd(R"({"id":17,"mode":"output","state":"on"})")); + BOOST_TEST((io.calls == std::vector{"dir:17:out"})); +} + +BOOST_AUTO_TEST_CASE(no_hardware_means_no_crash) { + GpioController none(nullptr, nullptr); + none.handle_command(cmd(R"({"id":17,"mode":"output","state":true})")); + BOOST_TEST(io.calls.empty()); +} + +BOOST_AUTO_TEST_CASE(the_actuator_log_records_the_pin_level) { + TempDir dir; + std::vector headers{"timestamp", "gpio_17"}; + for (int i = 0; i < 16; ++i) headers.push_back("relay_" + std::to_string(i)); + for (int i = 0; i < 16; ++i) headers.push_back("servo_" + std::to_string(i)); + headers.push_back("type_id"); + DataLogger logger(dir.path().string(), {"timestamp"}, headers, {17}, "n"); + BOOST_REQUIRE(logger.start("r")); + GpioController logged(&io, &logger); + + logged.handle_command(cmd(R"({"id":17,"mode":"output","state":true})")); + logger.stop(); + + std::string text = read_file(logger.last_actuator_path()); + auto last = text.substr(text.find('\n') + 1); + auto f = fields(last.substr(0, last.find('\n'))); + BOOST_REQUIRE_EQUAL(f.size(), 35u); + BOOST_TEST(f[1] == "1"); // gpio_17 + BOOST_TEST(f.back() == "gpio"); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_pca9685.cpp b/tests/test_pca9685.cpp new file mode 100644 index 0000000..394d450 --- /dev/null +++ b/tests/test_pca9685.cpp @@ -0,0 +1,142 @@ +#include + +#include +#include + +#include "drivers/servo.hpp" +#include "fake_i2c_bus.hpp" + +namespace { +constexpr uint8_t kAddr = 0x40; +constexpr uint8_t kPrescale = 0xFE; + +struct Fixture { + FakeI2cBus bus; + Adafruit_PWMServoDriver pwm{bus}; + + // Bring the chip up the way main() does, then forget those writes. + void start_at_50hz() { + BOOST_REQUIRE(pwm.begin()); + pwm.setPWMFreq(50); + bus.writes.clear(); + } +}; + +// "reg=value" for each write, in order, e.g. "00=80 FE=05". +std::string sequence(const FakeI2cBus& bus) { + std::string out; + for (const auto& w : bus.writes) { + char item[16]; + std::snprintf(item, sizeof item, "%s%02X=%02X", out.empty() ? "" : " ", w.reg, w.value); + out += item; + } + return out; +} +} + +BOOST_FIXTURE_TEST_SUITE(pca9685, Fixture) + +BOOST_AUTO_TEST_CASE(begin_resets_then_sets_1000_hz_and_wakes_with_auto_increment) { + BOOST_TEST(pwm.begin()); + BOOST_TEST(pwm.is_ready()); + BOOST_TEST(bus.attached.at(0) == kAddr); + // reset(); then setPWMFreq(1000): sleep, prescale 5, restore, restart+AI. + BOOST_TEST(sequence(bus) == "00=80 00=10 FE=05 00=80 00=A0"); + for (const auto& w : bus.writes) BOOST_TEST(w.address == kAddr); +} + +BOOST_AUTO_TEST_CASE(begin_fails_without_touching_the_chip_when_the_bus_is_unusable) { + bus.ready = false; + BOOST_TEST(!pwm.begin()); + BOOST_TEST(!pwm.is_ready()); + BOOST_TEST(bus.writes.empty()); + + FakeI2cBus no_addr; + no_addr.attach_ok = false; + Adafruit_PWMServoDriver other(no_addr); + BOOST_TEST(!other.begin()); + BOOST_TEST(no_addr.writes.empty()); +} + +BOOST_AUTO_TEST_CASE(a_driver_that_was_never_started_ignores_everything) { + BOOST_TEST(!pwm.is_ready()); + pwm.setPWM(0, 0, 300); + pwm.writeMicroseconds(0, 1500); + pwm.setPWMFreq(50); + BOOST_TEST(!pwm.disablePWM(0)); + BOOST_TEST(bus.writes.empty()); +} + +BOOST_AUTO_TEST_CASE(not_ready_when_the_bus_goes_away) { + BOOST_REQUIRE(pwm.begin()); + bus.ready = false; + BOOST_TEST(!pwm.is_ready()); +} + +BOOST_AUTO_TEST_CASE(set_pwm_freq_50hz_uses_prescale_121_in_the_sleep_restart_sequence) { + BOOST_REQUIRE(pwm.begin()); // leaves MODE1 at 0xA0 + bus.writes.clear(); + pwm.setPWMFreq(50); + BOOST_TEST(sequence(bus) == "00=30 FE=79 00=A0 00=A0"); +} + +BOOST_AUTO_TEST_CASE(frequency_is_clamped_to_the_chip_range) { + BOOST_REQUIRE(pwm.begin()); + pwm.setPWMFreq(0.1f); // clamps to 1 Hz: prescale clamps to 255 + BOOST_TEST(bus.reg(kAddr, kPrescale) == 255); + pwm.setPWMFreq(100000.0f); // clamps to 3500 Hz + BOOST_TEST(bus.reg(kAddr, kPrescale) == 3); // clamped up to the minimum +} + +BOOST_AUTO_TEST_CASE(write_microseconds_1500_at_50hz_is_307_ticks) { + start_at_50hz(); + pwm.writeMicroseconds(0, 1500); + BOOST_TEST(bus.reg(kAddr, 0x06) == 0x00); // LED0_ON_L + BOOST_TEST(bus.reg(kAddr, 0x07) == 0x00); // LED0_ON_H + BOOST_TEST(bus.reg(kAddr, 0x08) == 0x33); // LED0_OFF_L: 307 = 0x133 + BOOST_TEST(bus.reg(kAddr, 0x09) == 0x01); // LED0_OFF_H +} + +BOOST_AUTO_TEST_CASE(each_channel_has_its_own_register_block) { + start_at_50hz(); + pwm.writeMicroseconds(5, 1500); + pwm.writeMicroseconds(15, 1500); + BOOST_TEST(bus.reg(kAddr, 0x1A + 2) == 0x33); // channel 5: base 0x1A + BOOST_TEST(bus.reg(kAddr, 0x1A + 3) == 0x01); + BOOST_TEST(bus.reg(kAddr, 0x42 + 2) == 0x33); // channel 15: base 0x42 + BOOST_TEST(bus.reg(kAddr, 0x42 + 3) == 0x01); + BOOST_TEST(bus.reg(kAddr, 0x06 + 2) == 0x00); // channel 0 untouched +} + +BOOST_AUTO_TEST_CASE(disable_pwm_sets_the_full_off_bit) { + start_at_50hz(); + pwm.writeMicroseconds(2, 1500); + BOOST_TEST(pwm.disablePWM(2)); + BOOST_TEST(bus.reg(kAddr, 0x0E + 2) == 0x00); // OFF_L + BOOST_TEST(bus.reg(kAddr, 0x0E + 3) == 0x10); // OFF_H bit 4 = full off +} + +BOOST_AUTO_TEST_CASE(set_pwm_and_get_pwm_round_trip_including_wraparound) { + start_at_50hz(); + pwm.setPWM(3, 100, 400); + BOOST_TEST(pwm.getPWM(3) == 300); + pwm.setPWM(3, 3000, 500); + BOOST_TEST(pwm.getPWM(3) == 1596); // off < on wraps by 4096 +} + +BOOST_AUTO_TEST_CASE(set_pin_maps_the_extremes_to_full_on_and_full_off) { + start_at_50hz(); + pwm.setPin(1, 4095); // full on: ON bit 4 + BOOST_TEST(bus.reg(kAddr, 0x0A + 1) == 0x10); + BOOST_TEST(bus.reg(kAddr, 0x0A + 3) == 0x00); + pwm.setPin(1, 0); // full off: OFF bit 4 + BOOST_TEST(bus.reg(kAddr, 0x0A + 3) == 0x10); +} + +BOOST_AUTO_TEST_CASE(a_failed_read_reads_as_zero_like_before) { + start_at_50hz(); + bus.fail_reads = true; + BOOST_TEST(pwm.readPrescale() == 0); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_relay_controller.cpp b/tests/test_relay_controller.cpp new file mode 100644 index 0000000..f274e22 --- /dev/null +++ b/tests/test_relay_controller.cpp @@ -0,0 +1,137 @@ +#include + +#include + +#include "controllers/relay_controller.hpp" +#include "core/data_logger.hpp" +#include "core/telemetry.hpp" +#include "fake_i2c_bus.hpp" +#include "drivers/io_expander.hpp" +#include "temp_dir.hpp" + +namespace json = boost::json; + +namespace { +class FakeRelayBank : public RelayBank { +public: + bool ready = true; + bool write_ok = true; + std::vector> writes; + + bool is_ready() const override { return ready; } + bool write_output(const std::bitset<16>& state) override { + writes.push_back(state); + return write_ok; + } +}; + +json::object cmd(const char* text) { + return json::parse(text).as_object(); +} + +struct Fixture { + FakeRelayBank bank; + TelemetryStore telemetry; + RelayController controller{&bank, &telemetry, nullptr}; +}; +} + +BOOST_FIXTURE_TEST_SUITE(relay_controller, Fixture) + +BOOST_AUTO_TEST_CASE(turning_a_relay_on_writes_and_publishes_the_state) { + controller.handle_command(cmd(R"({"type":"relay","id":3,"state":true})")); + BOOST_REQUIRE_EQUAL(bank.writes.size(), 1u); + BOOST_TEST(bank.writes[0].to_ulong() == 1ul << 3); + BOOST_TEST(telemetry.snapshot_relay_state().to_ulong() == 1ul << 3); +} + +BOOST_AUTO_TEST_CASE(states_accumulate_and_clear_per_relay) { + controller.handle_command(cmd(R"({"id":1,"state":true})")); + controller.handle_command(cmd(R"({"id":9,"state":true})")); + controller.handle_command(cmd(R"({"id":1,"state":false})")); + BOOST_REQUIRE_EQUAL(bank.writes.size(), 3u); + BOOST_TEST(bank.writes[1].to_ulong() == ((1ul << 1) | (1ul << 9))); + BOOST_TEST(bank.writes[2].to_ulong() == 1ul << 9); +} + +BOOST_AUTO_TEST_CASE(state_accepts_numbers_like_the_rest_of_the_protocol) { + controller.handle_command(cmd(R"({"id":0,"state":1})")); + BOOST_REQUIRE_EQUAL(bank.writes.size(), 1u); + BOOST_TEST(bank.writes[0].test(0)); +} + +BOOST_AUTO_TEST_CASE(out_of_range_ids_write_nothing) { + controller.handle_command(cmd(R"({"id":-1,"state":true})")); + controller.handle_command(cmd(R"({"id":16,"state":true})")); + controller.handle_command(cmd(R"({"id":1000,"state":true})")); + BOOST_TEST(bank.writes.empty()); + BOOST_TEST(telemetry.snapshot_relay_state().none()); +} + +BOOST_AUTO_TEST_CASE(boundary_ids_0_and_15_are_valid) { + controller.handle_command(cmd(R"({"id":0,"state":true})")); + controller.handle_command(cmd(R"({"id":15,"state":true})")); + BOOST_TEST(bank.writes.size() == 2u); + BOOST_TEST(bank.writes[1].to_ulong() == ((1ul << 0) | (1ul << 15))); +} + +BOOST_AUTO_TEST_CASE(missing_or_wrong_typed_fields_write_nothing) { + controller.handle_command(cmd(R"({"state":true})")); + controller.handle_command(cmd(R"({"id":2})")); + controller.handle_command(cmd(R"({"id":"2","state":true})")); + controller.handle_command(cmd(R"({"id":2,"state":"on"})")); + BOOST_TEST(bank.writes.empty()); +} + +BOOST_AUTO_TEST_CASE(a_failed_write_does_not_publish_the_state) { + bank.write_ok = false; + controller.handle_command(cmd(R"({"id":4,"state":true})")); + BOOST_TEST(bank.writes.size() == 1u); + BOOST_TEST(telemetry.snapshot_relay_state().none()); +} + +BOOST_AUTO_TEST_CASE(unavailable_hardware_writes_nothing) { + bank.ready = false; + controller.handle_command(cmd(R"({"id":4,"state":true})")); + BOOST_TEST(bank.writes.empty()); + + RelayController none(nullptr, &telemetry, nullptr); + none.handle_command(cmd(R"({"id":4,"state":true})")); + BOOST_TEST(telemetry.snapshot_relay_state().none()); +} + +BOOST_AUTO_TEST_CASE(set_initial_state_seeds_the_next_write_and_telemetry) { + std::bitset<16> initial(0xFFFF); + controller.set_initial_state(initial); + BOOST_TEST(telemetry.snapshot_relay_state() == initial); + controller.handle_command(cmd(R"({"id":0,"state":false})")); + BOOST_REQUIRE_EQUAL(bank.writes.size(), 1u); + BOOST_TEST(bank.writes[0].to_ulong() == 0xFFFEul); +} + +BOOST_AUTO_TEST_CASE(the_actuator_log_gets_a_relay_row) { + TempDir dir; + std::vector headers{"timestamp"}; + for (int i = 0; i < 16; ++i) headers.push_back("relay_" + std::to_string(i)); + headers.push_back("type_id"); + DataLogger logger(dir.path().string(), {"timestamp"}, headers, {}, "n"); + BOOST_REQUIRE(logger.start("r")); + RelayController logged(&bank, &telemetry, &logger); + logged.handle_command(cmd(R"({"id":2,"state":true})")); + logger.stop(); + std::string text = read_file(logger.last_actuator_path()); + BOOST_TEST(text.find(",relay\n") != std::string::npos); +} + +// The real chip driver behind the controller, on a fake bus: a command ends up +// as the right register bytes. +BOOST_AUTO_TEST_CASE(controller_to_register_bytes_through_the_real_driver) { + FakeI2cBus bus; + TCA9535 chip(bus, 0x20, std::chrono::milliseconds(0)); + RelayController real(&chip, &telemetry, nullptr); + real.handle_command(cmd(R"({"id":10,"state":true})")); + BOOST_TEST(bus.reg(0x20, 0x02) == 0x00); + BOOST_TEST(bus.reg(0x20, 0x03) == 0x04); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_sampler.cpp b/tests/test_sampler.cpp new file mode 100644 index 0000000..9dc94ed --- /dev/null +++ b/tests/test_sampler.cpp @@ -0,0 +1,209 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "controllers/sampler.hpp" +#include "fake_digital_io.hpp" +#include "temp_dir.hpp" + +namespace { +class FakeAnalogIo : public AnalogIo { +public: + std::map, double> values; // (address, channel) -> value + std::set> failing; + std::vector> reads; + + std::vector hats_found; + std::set unopenable; // addresses whose open fails + bool throw_on_discover = false; + + std::vector discover() override { + if (throw_on_discover) throw std::runtime_error("Error listing DAQ hats"); + return hats_found; + } + bool open(const DaqHatDevice& hat) override { return !unopenable.count(hat.address); } + bool read(const DaqHatDevice& hat, int channel, double& value) override { + reads.push_back({hat.address, channel}); + if (failing.count({hat.address, channel})) return false; + value = values[{hat.address, channel}]; + return true; + } +}; + +DaqHatDevice hat(int address, std::vector channels) { + DaqHatDevice h; + h.address = address; + h.hat_id = 1; + h.channels = std::move(channels); + return h; +} + +std::vector lines(const std::string& text) { + std::vector out; + std::istringstream in(text); + std::string l; + while (std::getline(in, l)) out.push_back(l); + return out; +} + +struct Fixture { + FakeAnalogIo daq; + TelemetryStore store; +}; +} + +BOOST_FIXTURE_TEST_SUITE(sampler, Fixture) + +BOOST_AUTO_TEST_CASE(headers_are_timestamp_then_hat_and_channel) { + auto h = build_sensor_headers({hat(0, {0, 1}), hat(3, {2})}); + BOOST_TEST((h == std::vector{"timestamp", "hat0_ch0", "hat0_ch1", "hat3_ch2"})); +} + +BOOST_AUTO_TEST_CASE(a_hat_without_channels_adds_no_headers) { + auto h = build_sensor_headers({hat(0, {}), hat(1, {0})}); + BOOST_TEST((h == std::vector{"timestamp", "hat1_ch0"})); + BOOST_TEST((build_sensor_headers({}) == std::vector{"timestamp"})); +} + +BOOST_AUTO_TEST_CASE(every_channel_is_read_once_in_hat_then_channel_order) { + daq.values = {{{0, 0}, 1.5}, {{0, 1}, 2.5}, {{1, 0}, 3.5}}; + daq_sample_once(daq, {hat(0, {0, 1}), hat(1, {0})}, store, nullptr); + + using Reads = std::vector>; + BOOST_TEST((daq.reads == Reads{{0, 0}, {0, 1}, {1, 0}})); + + auto s = store.snapshot_sensors(); + BOOST_REQUIRE_EQUAL(s.size(), 3u); + BOOST_TEST(s[0].hat_id == 0); + BOOST_TEST(s[0].channel_id == 0); + BOOST_TEST(s[0].value == 1.5); + BOOST_TEST(s[1].channel_id == 1); + BOOST_TEST(s[1].value == 2.5); + BOOST_TEST(s[2].hat_id == 1); + BOOST_TEST(s[2].value == 3.5); +} + +BOOST_AUTO_TEST_CASE(hat_id_in_a_sample_is_the_hat_address) { + daq_sample_once(daq, {hat(5, {0})}, store, nullptr); + BOOST_TEST(store.snapshot_sensors().at(0).hat_id == 5); +} + +BOOST_AUTO_TEST_CASE(all_samples_of_one_pass_share_a_current_timestamp) { + using namespace std::chrono; + auto ms = [] { return double(duration_cast(system_clock::now().time_since_epoch()).count()); }; + double before = ms(); + daq_sample_once(daq, {hat(0, {0, 1, 2})}, store, nullptr); + double after = ms(); + auto s = store.snapshot_sensors(); + BOOST_REQUIRE_EQUAL(s.size(), 3u); + BOOST_TEST(s[0].timestamp_ms >= before); + BOOST_TEST(s[0].timestamp_ms <= after); + BOOST_TEST(s[1].timestamp_ms == s[0].timestamp_ms); + BOOST_TEST(s[2].timestamp_ms == s[0].timestamp_ms); +} + +BOOST_AUTO_TEST_CASE(a_failed_read_is_nan_and_the_rest_still_sample) { + daq.values = {{{0, 0}, 1.0}, {{0, 2}, 3.0}}; + daq.failing.insert({0, 1}); + daq_sample_once(daq, {hat(0, {0, 1, 2})}, store, nullptr); + auto s = store.snapshot_sensors(); + BOOST_REQUIRE_EQUAL(s.size(), 3u); + BOOST_TEST(s[0].value == 1.0); + BOOST_TEST(std::isnan(s[1].value)); + BOOST_TEST(s[2].value == 3.0); +} + +BOOST_AUTO_TEST_CASE(no_hats_publish_an_empty_sample_set) { + store.set_sensors({{9, 9, 9.0, 9.0}}); + daq_sample_once(daq, {}, store, nullptr); + BOOST_TEST(store.snapshot_sensors().empty()); +} + +BOOST_AUTO_TEST_CASE(a_new_pass_replaces_the_previous_one) { + daq.values = {{{0, 0}, 1.0}}; + daq_sample_once(daq, {hat(0, {0})}, store, nullptr); + daq.values = {{{0, 0}, 2.0}}; + daq_sample_once(daq, {hat(0, {0})}, store, nullptr); + auto s = store.snapshot_sensors(); + BOOST_REQUIRE_EQUAL(s.size(), 1u); + BOOST_TEST(s[0].value == 2.0); +} + +BOOST_AUTO_TEST_CASE(rows_are_logged_only_while_recording_and_in_header_order) { + TempDir dir; + std::vector hats{hat(0, {0, 1}), hat(1, {0})}; + DataLogger logger(dir.path().string(), build_sensor_headers(hats), {"timestamp"}, {}, "n"); + daq.values = {{{0, 0}, 1.0}, {{0, 1}, 2.0}, {{1, 0}, 3.0}}; + + daq_sample_once(daq, hats, store, &logger); // not recording: no row + BOOST_REQUIRE(logger.start("r")); + daq_sample_once(daq, hats, store, &logger); + daq.failing.insert({0, 1}); + daq_sample_once(daq, hats, store, &logger); + logger.stop(); + + auto l = lines(read_file(logger.last_sensor_path())); + BOOST_REQUIRE_EQUAL(l.size(), 3u); // header + two rows + BOOST_TEST(l[0] == "timestamp,hat0_ch0,hat0_ch1,hat1_ch0"); + BOOST_TEST(l[1].substr(l[1].find(',')) == ",1,2,3"); + BOOST_TEST(l[2].substr(l[2].find(',')) == ",1,NaN,3"); +} + +BOOST_AUTO_TEST_CASE(discover_and_open_keeps_only_the_hats_that_opened) { + daq.hats_found = {hat(0, {0}), hat(1, {0}), hat(2, {0})}; + daq.unopenable = {1}; + auto opened = discover_and_open(daq); + BOOST_REQUIRE_EQUAL(opened.size(), 2u); + BOOST_TEST(opened[0].address == 0); + BOOST_TEST(opened[1].address == 2); +} + +BOOST_AUTO_TEST_CASE(discover_and_open_with_no_hats_or_a_failing_bus_gives_an_empty_list) { + BOOST_TEST(discover_and_open(daq).empty()); + daq.hats_found = {hat(0, {0})}; + daq.throw_on_discover = true; + BOOST_TEST(discover_and_open(daq).empty()); +} + +BOOST_AUTO_TEST_CASE(gpio_sampling_stores_the_input_pins_only) { + FakeDigitalIo io; + io.set_direction(5, "in"); + io.set_direction(6, "in"); + io.set_direction(17, "out"); + io.levels = {{5, 1}, {6, 0}, {17, 1}}; + gpio_sample_once(io, store); + auto states = store.snapshot_gpio_states(); + BOOST_TEST(states.size() == 2u); + BOOST_TEST(states.at(5) == 1); + BOOST_TEST(states.at(6) == 0); +} + +BOOST_AUTO_TEST_CASE(gpio_sampling_replaces_the_previous_states) { + FakeDigitalIo io; + io.set_direction(5, "in"); + io.levels = {{5, 1}}; + gpio_sample_once(io, store); + io.levels = {{5, 0}}; + gpio_sample_once(io, store); + BOOST_TEST(store.snapshot_gpio_states().at(5) == 0); +} + +BOOST_AUTO_TEST_CASE(a_throwing_gpio_read_is_swallowed_and_keeps_the_old_states) { + FakeDigitalIo io; + io.set_direction(5, "in"); + io.levels = {{5, 1}}; + gpio_sample_once(io, store); + io.throw_on_read_all = true; + BOOST_CHECK_NO_THROW(gpio_sample_once(io, store)); + BOOST_TEST(store.snapshot_gpio_states().at(5) == 1); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_scenario.cpp b/tests/test_scenario.cpp new file mode 100644 index 0000000..1f28db5 --- /dev/null +++ b/tests/test_scenario.cpp @@ -0,0 +1,112 @@ +#include + +#include + +#include "sim/scenario.hpp" +#include "temp_dir.hpp" + +namespace { +const char* kFull = R"({ + "seed": 5, + "hats": [ + { "address": 0, "type": "mcc128", + "channels": { + "0": { "wave": "sine", "min": 1, "max": 5, "period": 20, "noise": 0.02 }, + "3": { "wave": "flat", "value": 2.5 } + } }, + { "address": 1, "type": "mcc134", "channels": { "0": { "wave": "square", "min": 10, "max": 20 } } } + ], + "faults": { + "read_errors": [ { "address": 0, "channel": 3 } ], + "missing_hats": [ 2 ], + "read_delay_us": 250 + }, + "gpio_inputs": { "5": 1, "6": 0 } +})"; +} + +BOOST_AUTO_TEST_SUITE(scenario) + +BOOST_AUTO_TEST_CASE(a_full_scenario_parses) { + Scenario s = parse_scenario(kFull); + BOOST_TEST(s.seed == 5u); + BOOST_REQUIRE_EQUAL(s.hats.size(), 2u); + BOOST_TEST(s.hats[0].address == 0); + BOOST_TEST(s.hats[0].type == "mcc128"); + BOOST_TEST(s.hats[0].channels.at(0).wave == "sine"); + BOOST_TEST(s.hats[0].channels.at(0).min == 1.0); + BOOST_TEST(s.hats[0].channels.at(0).max == 5.0); + BOOST_TEST(s.hats[0].channels.at(0).period == 20.0); + BOOST_TEST(s.hats[0].channels.at(0).noise == 0.02); + BOOST_TEST(s.hats[0].channels.at(3).value == 2.5); + BOOST_TEST(s.hats[1].type == "mcc134"); + BOOST_TEST((s.read_errors.count({0, 3}) == 1u)); + BOOST_TEST((s.missing_hats.count(2) == 1u)); + BOOST_TEST(s.read_delay_us == 250); + BOOST_TEST(s.gpio_inputs.at(5) == 1); + BOOST_TEST(s.gpio_inputs.at(6) == 0); +} + +BOOST_AUTO_TEST_CASE(everything_but_the_hats_is_optional) { + Scenario s = parse_scenario(R"({"hats":[{"address":3,"type":"mcc128"}]})"); + BOOST_TEST(s.seed == 1u); + BOOST_REQUIRE_EQUAL(s.hats.size(), 1u); + BOOST_TEST(s.hats[0].channels.empty()); + BOOST_TEST(s.read_errors.empty()); + BOOST_TEST(s.missing_hats.empty()); + BOOST_TEST(s.read_delay_us == 0); + BOOST_TEST(s.gpio_inputs.empty()); + BOOST_TEST(parse_scenario("{}").hats.empty()); +} + +BOOST_AUTO_TEST_CASE(a_flat_channel_without_a_value_sits_at_the_midpoint) { + Scenario s = parse_scenario(R"({"hats":[{"address":0,"type":"mcc128", + "channels":{"0":{"wave":"flat","min":2,"max":4}}}]})"); + BOOST_TEST(s.hats[0].channels.at(0).value == 3.0); +} + +BOOST_AUTO_TEST_CASE(bad_scenarios_are_rejected_with_a_reason) { + BOOST_CHECK_THROW(parse_scenario("not json"), ScenarioError); + BOOST_CHECK_THROW(parse_scenario("[]"), ScenarioError); + BOOST_CHECK_THROW(parse_scenario(R"({"hats":[{"type":"mcc128"}]})"), ScenarioError); + BOOST_CHECK_THROW(parse_scenario(R"({"hats":[{"address":0}]})"), ScenarioError); + BOOST_CHECK_THROW(parse_scenario(R"({"hats":[{"address":0,"type":"mcc999"}]})"), ScenarioError); + BOOST_CHECK_THROW(parse_scenario(R"({"hats":[{"address":"0","type":"mcc128"}]})"), ScenarioError); + BOOST_CHECK_THROW(parse_scenario(R"({"hats":[{"address":0,"type":"mcc128","channels":{"x":{}}}]})"), + ScenarioError); + BOOST_CHECK_THROW(parse_scenario(R"({"hats":[{"address":0,"type":"mcc128","channels":{"0":{"min":"low"}}}]})"), + ScenarioError); + BOOST_CHECK_THROW(parse_scenario(R"({"faults":{"read_errors":[{"address":0}]}})"), ScenarioError); + BOOST_CHECK_THROW(parse_scenario(R"({"gpio_inputs":{"five":1}})"), ScenarioError); +} + +BOOST_AUTO_TEST_CASE(the_error_message_names_the_problem) { + try { + parse_scenario(R"({"hats":[{"address":0,"type":"mcc999"}]})"); + BOOST_FAIL("expected a ScenarioError"); + } catch (const ScenarioError& e) { + BOOST_TEST(std::string(e.what()).find("mcc999") != std::string::npos); + } +} + +BOOST_AUTO_TEST_CASE(a_scenario_file_loads_and_a_missing_one_is_an_error) { + TempDir dir; + auto path = (dir.path() / "scenario.json").string(); + { std::ofstream(path) << kFull; } + BOOST_TEST(load_scenario_file(path).hats.size() == 2u); + BOOST_CHECK_THROW(load_scenario_file((dir.path() / "nope.json").string()), ScenarioError); +} + +BOOST_AUTO_TEST_CASE(the_default_scenario_has_the_python_simulators_gcs_channels) { + Scenario s = default_scenario(); + BOOST_REQUIRE_EQUAL(s.hats.size(), 2u); + BOOST_TEST(s.hats[0].address == 0); + for (int channel : {0, 1, 4, 6, 7}) { + BOOST_TEST(s.hats[0].channels.count(channel) == 1u, "channel " << channel); + } + BOOST_TEST(s.hats[1].address == 1); + BOOST_TEST(s.hats[1].channels.count(1) == 1u); + BOOST_TEST(s.hats[0].channels.at(0).wave == "sine"); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_servo_controller.cpp b/tests/test_servo_controller.cpp new file mode 100644 index 0000000..b197f75 --- /dev/null +++ b/tests/test_servo_controller.cpp @@ -0,0 +1,143 @@ +#include + +#include +#include + +#include "controllers/servo_controller.hpp" +#include "core/telemetry.hpp" +#include "drivers/servo.hpp" +#include "fake_i2c_bus.hpp" + +namespace json = boost::json; + +namespace { +class FakePwm : public PwmDriver { +public: + bool ready = true; + std::vector calls; // "us::" and "off:" + + bool is_ready() const override { return ready; } + void writeMicroseconds(uint8_t ch, uint16_t us) override { + calls.push_back("us:" + std::to_string(ch) + ":" + std::to_string(us)); + } + bool disablePWM(uint8_t ch) override { + calls.push_back("off:" + std::to_string(ch)); + return true; + } +}; + +json::object cmd(const char* text) { + return json::parse(text).as_object(); +} + +struct Fixture { + FakePwm pwm; + TelemetryStore telemetry; + ServoController controller{&pwm, &telemetry, nullptr}; + + ServoTelemetry servo(int id) { + for (const auto& s : telemetry.snapshot_servo_states()) { + if (s.id == id) return s; + } + return ServoTelemetry{-1, 0, false}; + } +}; +} + +BOOST_FIXTURE_TEST_SUITE(servo_controller, Fixture) + +BOOST_AUTO_TEST_CASE(a_numeric_angle_is_a_pulse_width_in_microseconds) { + controller.handle_command(cmd(R"({"id":3,"angle":1200})")); + BOOST_REQUIRE_EQUAL(pwm.calls.size(), 1u); + BOOST_TEST(pwm.calls[0] == "us:3:1200"); + BOOST_TEST(servo(3).angle == 1200); + BOOST_TEST(servo(3).enabled); +} + +BOOST_AUTO_TEST_CASE(off_disables_the_channel_and_keeps_the_last_angle) { + controller.handle_command(cmd(R"({"id":3,"angle":1200})")); + controller.handle_command(cmd(R"({"id":3,"angle":"off"})")); + BOOST_REQUIRE_EQUAL(pwm.calls.size(), 2u); + BOOST_TEST(pwm.calls[1] == "off:3"); + BOOST_TEST(servo(3).angle == 1200); + BOOST_TEST(!servo(3).enabled); +} + +BOOST_AUTO_TEST_CASE(on_restores_the_last_angle) { + controller.handle_command(cmd(R"({"id":3,"angle":1200})")); + controller.handle_command(cmd(R"({"id":3,"angle":"off"})")); + controller.handle_command(cmd(R"({"id":3,"angle":"on"})")); + BOOST_REQUIRE_EQUAL(pwm.calls.size(), 3u); + BOOST_TEST(pwm.calls[2] == "us:3:1200"); + BOOST_TEST(servo(3).enabled); +} + +BOOST_AUTO_TEST_CASE(on_with_no_previous_angle_uses_1500_microseconds) { + controller.handle_command(cmd(R"({"id":7,"angle":"on"})")); + BOOST_REQUIRE_EQUAL(pwm.calls.size(), 1u); + BOOST_TEST(pwm.calls[0] == "us:7:1500"); +} + +BOOST_AUTO_TEST_CASE(on_and_off_are_case_insensitive) { + controller.handle_command(cmd(R"({"id":1,"angle":"ON"})")); + controller.handle_command(cmd(R"({"id":1,"angle":"Off"})")); + BOOST_TEST((pwm.calls == std::vector{"us:1:1500", "off:1"})); +} + +BOOST_AUTO_TEST_CASE(servos_keep_independent_state) { + controller.handle_command(cmd(R"({"id":1,"angle":1100})")); + controller.handle_command(cmd(R"({"id":2,"angle":1900})")); + controller.handle_command(cmd(R"({"id":1,"angle":"off"})")); + BOOST_TEST(!servo(1).enabled); + BOOST_TEST(servo(2).enabled); + BOOST_TEST(servo(2).angle == 1900); +} + +BOOST_AUTO_TEST_CASE(ids_0_and_15_are_valid_and_others_are_not) { + controller.handle_command(cmd(R"({"id":0,"angle":1500})")); + controller.handle_command(cmd(R"({"id":15,"angle":1500})")); + BOOST_TEST(pwm.calls.size() == 2u); + controller.handle_command(cmd(R"({"id":-1,"angle":1500})")); + controller.handle_command(cmd(R"({"id":16,"angle":1500})")); + BOOST_TEST(pwm.calls.size() == 2u); +} + +BOOST_AUTO_TEST_CASE(bad_commands_drive_nothing) { + controller.handle_command(cmd(R"({"angle":1500})")); + controller.handle_command(cmd(R"({"id":1})")); + controller.handle_command(cmd(R"({"id":"1","angle":1500})")); + controller.handle_command(cmd(R"({"id":1,"angle":"sideways"})")); + controller.handle_command(cmd(R"({"id":1,"angle":null})")); + controller.handle_command(cmd(R"({"id":1,"angle":[1500]})")); + BOOST_TEST(pwm.calls.empty()); + BOOST_TEST(telemetry.snapshot_servo_states().empty()); +} + +BOOST_AUTO_TEST_CASE(unavailable_hardware_drives_nothing) { + pwm.ready = false; + controller.handle_command(cmd(R"({"id":1,"angle":1500})")); + BOOST_TEST(pwm.calls.empty()); + + ServoController none(nullptr, &telemetry, nullptr); + none.handle_command(cmd(R"({"id":1,"angle":1500})")); + BOOST_TEST(telemetry.snapshot_servo_states().empty()); +} + +// The real PCA9685 driver behind the controller, on a fake bus: a command ends +// up as the right register bytes. +BOOST_AUTO_TEST_CASE(controller_to_register_bytes_through_the_real_driver) { + FakeI2cBus bus; + Adafruit_PWMServoDriver chip(bus); + BOOST_REQUIRE(chip.begin()); + chip.setPWMFreq(50); + ServoController real(&chip, &telemetry, nullptr); + + real.handle_command(cmd(R"({"id":1,"angle":1500})")); + BOOST_TEST(bus.reg(0x40, 0x0A + 2) == 0x33); + BOOST_TEST(bus.reg(0x40, 0x0A + 3) == 0x01); + + real.handle_command(cmd(R"({"id":1,"angle":"off"})")); + BOOST_TEST(bus.reg(0x40, 0x0A + 3) == 0x10); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_sim_actuators.cpp b/tests/test_sim_actuators.cpp new file mode 100644 index 0000000..30170c3 --- /dev/null +++ b/tests/test_sim_actuators.cpp @@ -0,0 +1,64 @@ +#include + +#include +#include +#include + +#include "sim/sim_actuators.hpp" + +BOOST_AUTO_TEST_SUITE(sim_actuators) + +BOOST_AUTO_TEST_CASE(the_relay_bank_remembers_what_was_written) { + SimRelayBank bank; + BOOST_TEST(bank.is_ready()); + BOOST_TEST(bank.state().none()); + std::bitset<16> s(0x8001); + BOOST_TEST(bank.write_output(s)); + BOOST_TEST((bank.state() == s)); + BOOST_TEST(bank.writes() == 1); +} + +BOOST_AUTO_TEST_CASE(the_pwm_driver_keeps_pulse_widths_and_off_is_zero) { + SimPwmDriver pwm; + BOOST_TEST(pwm.is_ready()); + BOOST_TEST(pwm.microseconds(3) == 0); + pwm.writeMicroseconds(3, 1200); + BOOST_TEST(pwm.microseconds(3) == 1200); + BOOST_TEST(pwm.disablePWM(3)); + BOOST_TEST(pwm.microseconds(3) == 0); +} + +BOOST_AUTO_TEST_CASE(gpio_follows_the_real_drivers_contract) { + SimDigitalIo io(std::map{{5, 1}}); + int v = -1; + int w = -1; + BOOST_TEST(!io.write(17, 1)); // not configured + BOOST_TEST(!io.read(5, v)); // not configured + BOOST_TEST(!io.set_direction(17, "sideways")); + + BOOST_TEST(io.set_direction(17, "out")); + BOOST_TEST(io.set_direction(5, "in")); + BOOST_TEST(io.set_direction(6, "in")); + BOOST_TEST(io.write(17, 1)); + BOOST_TEST(io.output_level(17) == 1); + BOOST_TEST(!io.write(5, 1)); // an input cannot be written + BOOST_TEST(!io.read(17, w)); // an output cannot be read + BOOST_TEST(io.read(5, v)); + BOOST_TEST(v == 1); + BOOST_TEST(io.read(6, w)); + BOOST_TEST(w == 0); // no level given: low +} + +BOOST_AUTO_TEST_CASE(gpio_input_listing_only_has_inputs) { + SimDigitalIo io(std::map{{5, 1}}); + io.set_direction(5, "in"); + io.set_direction(6, "in"); + io.set_direction(17, "out"); + BOOST_TEST((io.get_input_pins() == std::vector{5, 6})); + auto all = io.read_all_inputs(); + BOOST_TEST(all.size() == 2u); + BOOST_TEST(all.at(5) == 1); + BOOST_TEST(all.at(6) == 0); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_sim_hardware.cpp b/tests/test_sim_hardware.cpp new file mode 100644 index 0000000..25cbb2f --- /dev/null +++ b/tests/test_sim_hardware.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include +#include + +#include "controllers/relay_controller.hpp" +#include "controllers/sampler.hpp" +#include "controllers/servo_controller.hpp" +#include "core/data_logger.hpp" +#include "core/telemetry.hpp" +#include "interfaces/hardware.hpp" +#include "sim/daqhats_sim.hpp" +#include "temp_dir.hpp" + +namespace json = boost::json; + +namespace { +const char* kScenario = R"({ + "hats": [ { "address": 0, "type": "mcc128", "channels": { "3": { "wave": "flat", "value": 2.5 } } } ], + "gpio_inputs": { "5": 1 } +})"; + +struct Fixture { + Fixture() { daqhats_sim::load(parse_scenario(kScenario)); } + ~Fixture() { daqhats_sim::reset(); } +}; +} + +// build_hardware from sim/hardware_sim.cpp: the simulated machine. +BOOST_FIXTURE_TEST_SUITE(sim_hardware, Fixture) + +BOOST_AUTO_TEST_CASE(everything_is_present_and_in_its_startup_state) { + Hardware hw = build_hardware({17}, {5}); + + BOOST_REQUIRE(hw.daq); + BOOST_REQUIRE(hw.servo); + BOOST_REQUIRE(hw.relays); + BOOST_REQUIRE(hw.gpio); + BOOST_TEST(hw.servo->is_ready()); + BOOST_TEST(hw.relays->is_ready()); + BOOST_TEST(hw.relay_state.all()); // all relays on at startup +} + +BOOST_AUTO_TEST_CASE(the_daq_hats_are_the_scenarios_and_opened) { + Hardware hw = build_hardware({17}, {5}); + BOOST_REQUIRE_EQUAL(hw.daq_hats.size(), 1u); + BOOST_TEST(hw.daq_hats[0].address == 0); + + TelemetryStore store; + daq_sample_once(*hw.daq, hw.daq_hats, store, nullptr); + auto samples = store.snapshot_sensors(); + BOOST_REQUIRE_EQUAL(samples.size(), 8u); + BOOST_TEST(samples[3].value == 2.5); +} + +BOOST_AUTO_TEST_CASE(a_scenario_without_hats_gives_no_daq_hats_but_the_rest_still_works) { + daqhats_sim::load(parse_scenario(R"({"gpio_inputs":{"5":1}})")); + Hardware hw = build_hardware({17}, {5}); + BOOST_TEST(hw.daq_hats.empty()); + BOOST_TEST(static_cast(hw.relays)); + BOOST_TEST(static_cast(hw.gpio)); +} + +BOOST_AUTO_TEST_CASE(gpio_has_the_given_pins_as_outputs_and_inputs_with_scenario_levels) { + Hardware hw = build_hardware({17, 27}, {5, 6}); + int level = -1; + BOOST_TEST(hw.gpio->write(17, 1)); + BOOST_TEST(hw.gpio->write(27, 1)); + BOOST_TEST(!hw.gpio->write(5, 1)); // an input + BOOST_TEST(hw.gpio->read(5, level)); + BOOST_TEST(level == 1); // from the scenario + BOOST_TEST(hw.gpio->read(6, level)); + BOOST_TEST(level == 0); // not listed: low +} + +BOOST_AUTO_TEST_CASE(the_real_controllers_drive_the_simulated_outputs) { + Hardware hw = build_hardware({17}, {5}); + TelemetryStore telemetry; + + RelayController relays(hw.relays.get(), &telemetry, nullptr); + relays.set_initial_state(hw.relay_state); + relays.handle_command(json::parse(R"({"id":3,"state":false})").as_object()); + BOOST_TEST(telemetry.snapshot_relay_state().test(3) == false); + BOOST_TEST(telemetry.snapshot_relay_state().test(4) == true); + + ServoController servos(hw.servo.get(), &telemetry, nullptr); + servos.handle_command(json::parse(R"({"id":2,"angle":1700})").as_object()); + auto states = telemetry.snapshot_servo_states(); + BOOST_REQUIRE_EQUAL(states.size(), 1u); + BOOST_TEST(states[0].angle == 1700); + BOOST_TEST(states[0].enabled); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_stoppable_loops.cpp b/tests/test_stoppable_loops.cpp new file mode 100644 index 0000000..1363f40 --- /dev/null +++ b/tests/test_stoppable_loops.cpp @@ -0,0 +1,142 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "controllers/sampler.hpp" +#include "fake_digital_io.hpp" +#include "utils/interruptible_sleep.hpp" + +using namespace std::chrono; + +namespace { +class FakeAnalogIo : public AnalogIo { +public: + std::vector discover() override { return {}; } + bool open(const DaqHatDevice&) override { return true; } + bool read(const DaqHatDevice&, int, double& value) override { + value = 1.0; + return true; + } +}; + +// True once `ready` holds, false if it does not within the deadline. +bool wait_until(const std::function& ready, milliseconds deadline = seconds(5)) { + auto end = steady_clock::now() + deadline; + while (steady_clock::now() < end) { + if (ready()) return true; + std::this_thread::sleep_for(milliseconds(1)); + } + return ready(); +} + +DaqHatDevice one_channel_hat() { + DaqHatDevice h; + h.address = 0; + h.hat_id = 1; + h.channels = {0}; + return h; +} + +// A long interval must not delay the stop: that is what the interruptible +// sleep is for. +constexpr int kOneHourMs = 3600 * 1000; +constexpr long long kPromptlyMs = 2000; + +long long ms_since(steady_clock::time_point start) { + return duration_cast(steady_clock::now() - start).count(); +} +} + +BOOST_AUTO_TEST_SUITE(stoppable_loops) + +BOOST_AUTO_TEST_CASE(sleep_runs_its_course_when_no_stop_is_requested) { + InterruptibleSleep sleeper; + std::stop_source source; + auto start = steady_clock::now(); + BOOST_TEST(!sleeper.sleep_for(source.get_token(), milliseconds(30))); + BOOST_TEST(ms_since(start) >= 25); +} + +BOOST_AUTO_TEST_CASE(sleep_returns_at_once_if_a_stop_was_already_requested) { + InterruptibleSleep sleeper; + std::stop_source source; + source.request_stop(); + auto start = steady_clock::now(); + BOOST_TEST(sleeper.sleep_for(source.get_token(), hours(1))); + BOOST_TEST(ms_since(start) < kPromptlyMs); +} + +BOOST_AUTO_TEST_CASE(a_stop_request_cuts_a_long_sleep_short) { + InterruptibleSleep sleeper; + std::stop_source source; + std::thread stopper([&source] { + std::this_thread::sleep_for(milliseconds(50)); + source.request_stop(); + }); + auto start = steady_clock::now(); + BOOST_TEST(sleeper.sleep_for(source.get_token(), hours(1))); + BOOST_TEST(ms_since(start) < kPromptlyMs); + stopper.join(); +} + +BOOST_AUTO_TEST_CASE(the_daq_loop_samples_then_stops_promptly) { + FakeAnalogIo daq; + TelemetryStore store; + std::vector hats{one_channel_hat()}; + + std::jthread loop(daq_sample_loop, std::ref(static_cast(daq)), hats, + std::ref(store), static_cast(nullptr), kOneHourMs); + BOOST_REQUIRE(wait_until([&] { return !store.snapshot_sensors().empty(); })); + + auto start = steady_clock::now(); + loop.request_stop(); + loop.join(); + BOOST_TEST(ms_since(start) < kPromptlyMs); +} + +BOOST_AUTO_TEST_CASE(the_daq_loop_keeps_sampling_until_stopped) { + FakeAnalogIo daq; + TelemetryStore store; + std::vector hats{one_channel_hat()}; + double first_pass = 0; + + std::jthread loop(daq_sample_loop, std::ref(static_cast(daq)), hats, + std::ref(store), static_cast(nullptr), 1); + BOOST_REQUIRE(wait_until([&] { return !store.snapshot_sensors().empty(); })); + first_pass = store.snapshot_sensors().at(0).timestamp_ms; + // A later pass has a later timestamp. + BOOST_TEST(wait_until([&] { return store.snapshot_sensors().at(0).timestamp_ms > first_pass; })); +} + +BOOST_AUTO_TEST_CASE(the_gpio_loop_samples_then_stops_promptly) { + FakeDigitalIo io; + io.set_direction(5, "in"); + io.levels[5] = 1; + TelemetryStore store; + + std::jthread loop(gpio_sample_loop, std::ref(static_cast(io)), std::ref(store), + kOneHourMs); + BOOST_REQUIRE(wait_until([&] { return !store.snapshot_gpio_states().empty(); })); + + auto start = steady_clock::now(); + loop.request_stop(); + loop.join(); + BOOST_TEST(ms_since(start) < kPromptlyMs); +} + +BOOST_AUTO_TEST_CASE(a_loop_given_an_already_stopped_token_does_no_work) { + FakeDigitalIo io; + io.set_direction(5, "in"); + TelemetryStore store; + std::stop_source source; + source.request_stop(); + gpio_sample_loop(source.get_token(), io, store, 1); // returns at once + BOOST_TEST(store.snapshot_gpio_states().empty()); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_tca9535.cpp b/tests/test_tca9535.cpp new file mode 100644 index 0000000..6aae982 --- /dev/null +++ b/tests/test_tca9535.cpp @@ -0,0 +1,116 @@ +#include + +#include + +#include "fake_i2c_bus.hpp" +#include "drivers/io_expander.hpp" + +namespace { +constexpr uint8_t kAddr = 0x20; +const auto kNoDelay = std::chrono::milliseconds(0); +} + +BOOST_AUTO_TEST_SUITE(tca9535) + +BOOST_AUTO_TEST_CASE(ready_when_the_bus_can_address_the_chip) { + FakeI2cBus bus; + TCA9535 chip(bus, kAddr, kNoDelay); + BOOST_TEST(chip.is_ready()); + BOOST_REQUIRE_EQUAL(bus.attached.size(), 1u); + BOOST_TEST(bus.attached[0] == kAddr); +} + +BOOST_AUTO_TEST_CASE(not_ready_when_the_bus_is_not_open_or_cannot_address) { + FakeI2cBus closed; + closed.ready = false; + TCA9535 a(closed, kAddr, kNoDelay); + BOOST_TEST(!a.is_ready()); + BOOST_TEST(a.last_error() == "Failed to open I2C bus"); + + FakeI2cBus no_addr; + no_addr.attach_ok = false; + TCA9535 b(no_addr, kAddr, kNoDelay); + BOOST_TEST(!b.is_ready()); + BOOST_TEST(b.last_error() == "Failed to set I2C device address"); +} + +BOOST_AUTO_TEST_CASE(a_chip_that_is_not_ready_never_touches_the_bus) { + FakeI2cBus bus; + bus.attach_ok = false; + TCA9535 chip(bus, kAddr, kNoDelay); + BOOST_TEST(!chip.configure_port(0, 0x00)); + BOOST_TEST(!chip.write_output(std::bitset<16>(0xFFFF))); + uint8_t v = 0; + BOOST_TEST(!chip.read_input(0, v)); + BOOST_TEST(bus.writes.empty()); +} + +BOOST_AUTO_TEST_CASE(configure_port_writes_the_config_registers) { + FakeI2cBus bus; + TCA9535 chip(bus, kAddr, kNoDelay); + BOOST_TEST(chip.configure_port(0, 0x00)); + BOOST_TEST(chip.configure_port(1, 0xF0)); + BOOST_REQUIRE_EQUAL(bus.writes.size(), 2u); + BOOST_TEST(bus.writes[0].reg == 0x06); + BOOST_TEST(bus.writes[0].value == 0x00); + BOOST_TEST(bus.writes[1].reg == 0x07); + BOOST_TEST(bus.writes[1].value == 0xF0); + BOOST_TEST(bus.writes[0].address == kAddr); +} + +BOOST_AUTO_TEST_CASE(write_output_splits_the_16_bits_over_the_two_output_ports) { + FakeI2cBus bus; + TCA9535 chip(bus, kAddr, kNoDelay); + BOOST_TEST(chip.write_output(std::bitset<16>(0xA50F))); + BOOST_REQUIRE_EQUAL(bus.writes.size(), 2u); + BOOST_TEST(bus.writes[0].reg == 0x02); // OUTPUT_PORT0: relays 0-7 + BOOST_TEST(bus.writes[0].value == 0x0F); + BOOST_TEST(bus.writes[1].reg == 0x03); // OUTPUT_PORT1: relays 8-15 + BOOST_TEST(bus.writes[1].value == 0xA5); +} + +BOOST_AUTO_TEST_CASE(single_relay_bits_land_in_the_right_port_and_bit) { + for (int i = 0; i < 16; ++i) { + FakeI2cBus bus; + TCA9535 chip(bus, kAddr, kNoDelay); + std::bitset<16> s; + s.set(i); + BOOST_TEST(chip.write_output(s)); + BOOST_TEST(bus.reg(kAddr, 0x02) == (i < 8 ? 1 << i : 0), "relay " << i); + BOOST_TEST(bus.reg(kAddr, 0x03) == (i >= 8 ? 1 << (i - 8) : 0), "relay " << i); + } +} + +BOOST_AUTO_TEST_CASE(a_failed_write_is_retried_and_then_succeeds) { + FakeI2cBus bus; + TCA9535 chip(bus, kAddr, kNoDelay); + bus.fail_writes = 1; // the first port write fails + BOOST_TEST(chip.write_output(std::bitset<16>(0x0102))); + BOOST_TEST(bus.reg(kAddr, 0x02) == 0x02); + BOOST_TEST(bus.reg(kAddr, 0x03) == 0x01); + BOOST_TEST(bus.writes.size() == 4u); // 2 first try + 2 retry +} + +BOOST_AUTO_TEST_CASE(write_output_gives_up_after_ten_retries) { + FakeI2cBus bus; + TCA9535 chip(bus, kAddr, kNoDelay); + bus.fail_writes = 1000; + BOOST_TEST(!chip.write_output(std::bitset<16>(0xFFFF))); + BOOST_TEST(bus.writes.size() == 22u); // 1 first try + 10 retries, 2 writes each +} + +BOOST_AUTO_TEST_CASE(read_input_reads_the_input_ports) { + FakeI2cBus bus; + TCA9535 chip(bus, kAddr, kNoDelay); + bus.registers[{kAddr, 0x00}] = 0x12; + bus.registers[{kAddr, 0x01}] = 0x34; + uint8_t v = 0; + BOOST_TEST(chip.read_input(0, v)); + BOOST_TEST(v == 0x12); + BOOST_TEST(chip.read_input(1, v)); + BOOST_TEST(v == 0x34); + bus.fail_reads = true; + BOOST_TEST(!chip.read_input(0, v)); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/test_waveform.cpp b/tests/test_waveform.cpp new file mode 100644 index 0000000..44209cb --- /dev/null +++ b/tests/test_waveform.cpp @@ -0,0 +1,121 @@ +#include +#include + +#include "sim/waveform.hpp" + +namespace tt = boost::test_tools; + +namespace { +Waveform make(const char* name, double lo, double hi, double period) { + Waveform w; + w.wave = name; + w.min = lo; + w.max = hi; + w.period = period; + w.value = (lo + hi) / 2.0; + return w; +} + +struct Fixture { + WaveformRng rng{1}; +}; +} + +BOOST_FIXTURE_TEST_SUITE(waveform, Fixture) + +BOOST_AUTO_TEST_CASE(flat_is_its_value) { + Waveform w = Waveform::flat(3.5); + BOOST_TEST(wave_value(w, 0.0, rng) == 3.5, tt::tolerance(1e-12)); + BOOST_TEST(wave_value(w, 123.4, rng) == 3.5, tt::tolerance(1e-12)); +} + +BOOST_AUTO_TEST_CASE(sine_starts_at_the_midpoint_and_peaks_a_quarter_period_in) { + Waveform w = make("sine", 1.0, 5.0, 20.0); + BOOST_TEST(wave_value(w, 0.0, rng) == 3.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 5.0, rng) == 5.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 15.0, rng) == 1.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 20.0, rng) == 3.0, tt::tolerance(1e-9)); +} + +BOOST_AUTO_TEST_CASE(square_is_high_for_the_first_half_period) { + Waveform w = make("square", 1.0, 5.0, 10.0); + BOOST_TEST(wave_value(w, 1.0, rng) == 5.0); + BOOST_TEST(wave_value(w, 6.0, rng) == 1.0); + BOOST_TEST(wave_value(w, 11.0, rng) == 5.0); +} + +BOOST_AUTO_TEST_CASE(triangle_rises_then_falls) { + Waveform w = make("triangle", 0.0, 10.0, 8.0); + BOOST_TEST(wave_value(w, 0.0, rng) == 0.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 2.0, rng) == 5.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 4.0, rng) == 10.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 6.0, rng) == 5.0, tt::tolerance(1e-9)); +} + +BOOST_AUTO_TEST_CASE(sawtooth_rises_and_resets) { + Waveform w = make("sawtooth", 0.0, 10.0, 10.0); + BOOST_TEST(wave_value(w, 5.0, rng) == 5.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 10.0, rng) == 0.0, tt::tolerance(1e-9)); +} + +BOOST_AUTO_TEST_CASE(s_curve_is_a_smoothstep) { + Waveform w = make("s_curve", 0.0, 10.0, 10.0); + BOOST_TEST(wave_value(w, 0.0, rng) == 0.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 5.0, rng) == 5.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 2.5, rng) == 10.0 * (0.25 * 0.25 * (3.0 - 0.5)), tt::tolerance(1e-9)); +} + +BOOST_AUTO_TEST_CASE(ramp_is_one_shot_and_holds_at_the_top) { + Waveform w = make("ramp", 2.0, 4.0, 10.0); + BOOST_TEST(wave_value(w, 0.0, rng) == 2.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 5.0, rng) == 3.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 10.0, rng) == 4.0, tt::tolerance(1e-9)); + BOOST_TEST(wave_value(w, 25.0, rng) == 4.0, tt::tolerance(1e-9)); +} + +BOOST_AUTO_TEST_CASE(noise_wave_stays_within_min_and_max) { + Waveform w = make("noise", -1.0, 3.0, 10.0); + for (int i = 0; i < 500; ++i) { + double v = wave_value(w, i * 0.1, rng); + BOOST_REQUIRE(v >= -1.0); + BOOST_REQUIRE(v <= 3.0); + } +} + +BOOST_AUTO_TEST_CASE(an_unknown_wave_gives_the_midpoint) { + Waveform w = make("zigzag", 2.0, 6.0, 10.0); + BOOST_TEST(wave_value(w, 3.0, rng) == 4.0, tt::tolerance(1e-12)); +} + +BOOST_AUTO_TEST_CASE(added_noise_stays_within_its_amplitude) { + Waveform w = make("flat", 0.0, 0.0, 10.0); + w.value = 10.0; + w.noise = 0.5; + bool moved = false; + for (int i = 0; i < 500; ++i) { + double v = wave_value(w, 0.0, rng); + BOOST_REQUIRE(v >= 9.5); + BOOST_REQUIRE(v <= 10.5); + if (v != 10.0) moved = true; + } + BOOST_TEST(moved); +} + +BOOST_AUTO_TEST_CASE(the_same_seed_gives_the_same_noise) { + Waveform w = make("noise", 0.0, 1.0, 10.0); + WaveformRng a(7), b(7), c(8); + double va = wave_value(w, 0.0, a); + BOOST_TEST(wave_value(w, 0.0, b) == va); + BOOST_TEST(wave_value(w, 0.0, c) != va); +} + +BOOST_AUTO_TEST_CASE(a_tiny_or_zero_period_and_negative_time_stay_finite) { + Waveform w = make("sine", 0.0, 1.0, 0.0); + double v = wave_value(w, 0.3, rng); + BOOST_TEST(v >= 0.0); + BOOST_TEST(v <= 1.0); + Waveform s = make("sawtooth", 0.0, 10.0, 10.0); + BOOST_TEST(wave_value(s, -2.0, rng) == 8.0, tt::tolerance(1e-9)); +} + +BOOST_AUTO_TEST_SUITE_END()