Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,20 @@ 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

- name: Build
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
2 changes: 1 addition & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions docs/simulation.md
Original file line number Diff line number Diff line change
@@ -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.
127 changes: 96 additions & 31 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -12,42 +12,125 @@ boost_dep = dependency('boost', modules : ['thread','json'])
threads_dep = dependency('threads')

#### Grab source files
src = []
core_src = []

include = []
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,
dependencies: [boost_dep, threads_dep],
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)
Expand Down Expand Up @@ -87,29 +170,23 @@ 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
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',
Expand All @@ -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',
],
)
12 changes: 6 additions & 6 deletions src/controllers/gpio_controller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
6 changes: 3 additions & 3 deletions src/controllers/gpio_controller.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
#include <boost/json.hpp>

#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;
};
Loading
Loading