added temperature and humidity sensor (SHT41)

This commit is contained in:
pgdalmeida 2026-08-05 09:13:16 +02:00
parent c41cb6fc6a
commit 271b076394
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
26 changed files with 535 additions and 35 deletions

View File

@ -81,6 +81,8 @@ set(FGC_SOURCES
src/camera/ImagePipeline.cpp src/camera/ImagePipeline.cpp
src/serial/SerialMotorController.cpp src/serial/SerialMotorController.cpp
src/serial/MtiImuSource.cpp src/serial/MtiImuSource.cpp
src/sensors/I2cBus.cpp
src/sensors/Sht41EnvSensor.cpp
) )
if(WITH_MQTT) if(WITH_MQTT)
list(APPEND FGC_SOURCES src/mqtt/MqttControlChannel.cpp) list(APPEND FGC_SOURCES src/mqtt/MqttControlChannel.cpp)

View File

@ -72,6 +72,15 @@ baud = 115200
device = device =
baud = 115200 baud = 115200
[Env]
; Ambient temperature/humidity sensor (Adafruit SHT41), wired to the
; LattePanda's OWN native I2C bus - NOT the Arduino/motor serial link. Bus
; number varies by board; confirm with `i2cdetect -y N` (SHT41 shows up at
; 0x44). Enable with [Features] enable_env = true.
i2c_device = /dev/i2c-1
i2c_addr = 68
period_ms = 2000
[Motor] [Motor]
; Degrees<->encoder-counts calibration for each axis. The firmware speaks only ; Degrees<->encoder-counts calibration for each axis. The firmware speaks only
; in absolute encoder counts; these map them to the heading/elevation degrees ; in absolute encoder counts; these map them to the heading/elevation degrees
@ -137,6 +146,8 @@ enable_imu = false
mock_camera = false mock_camera = false
mock_serial = false mock_serial = false
mock_imu = false mock_imu = false
enable_env = false
mock_env = false
[Test] [Test]
; Hardware self-test command (`test`). `profile` is the default profile used when ; Hardware self-test command (`test`). `profile` is the default profile used when

View File

@ -55,8 +55,13 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Features` | `mock_camera` | bool | `false` | Use the simulated camera | | `Features` | `mock_camera` | bool | `false` | Use the simulated camera |
| `Features` | `mock_serial` | bool | `false` | Use the simulated motor controller | | `Features` | `mock_serial` | bool | `false` | Use the simulated motor controller |
| `Features` | `mock_imu` | bool | `false` | Use the simulated IMU instead of the MTi | | `Features` | `mock_imu` | bool | `false` | Use the simulated IMU instead of the MTi |
| `Features` | `enable_env` | bool | `false` | Use the ambient temp/humidity sensor (SHT41) |
| `Features` | `mock_env` | bool | `false` | Use the simulated env sensor instead of the SHT41 |
| `IMU` | `device` | string | — | MTi serial device (see `[IMU]` note); required when `enable_imu` | | `IMU` | `device` | string | — | MTi serial device (see `[IMU]` note); required when `enable_imu` |
| `IMU` | `baud` | int | `115200` | MTi serial baud rate | | `IMU` | `baud` | int | `115200` | MTi serial baud rate |
| `Env` | `i2c_device` | string | `/dev/i2c-1` | SHT41 I2C bus device (see `[Env]` note); required when `enable_env` |
| `Env` | `i2c_addr` | int | `68` (`0x44`) | SHT41 I2C slave address |
| `Env` | `period_ms` | int | `2000` | Sample interval |
| `Logging` | `level` | enum | `info` | Linear log level (`--log-level` overrides) | | `Logging` | `level` | enum | `info` | Linear log level (`--log-level` overrides) |
| `Logging` | `trace` | csv | — | Wire-trace categories, off by default (`--trace` overrides) | | `Logging` | `trace` | csv | — | Wire-trace categories, off by default (`--trace` overrides) |
| `UI` | `enable_tui` | bool | `false` | Full-screen terminal dashboard (`--tui`/`--no-tui` override; needs `WITH_TUI=ON`) | | `UI` | `enable_tui` | bool | `false` | Full-screen terminal dashboard (`--tui`/`--no-tui` override; needs `WITH_TUI=ON`) |
@ -140,6 +145,18 @@ The config is read **once at startup** and cached, so if you change the XKF prof
stays stale until you **refresh** it: press `r` (or type `refresh`). That re-queries the device (briefly stays stale until you **refresh** it: press `r` (or type `refresh`). That re-queries the device (briefly
pausing the stream) and also requests a fresh firmware dump for the gimbal `g` view. pausing the stream) and also requests a fresh firmware dump for the gimbal `g` view.
### `[Env]` — SHT41 ambient temperature/humidity sensor
Enable with `[Features] enable_env = true` and point `[Env] i2c_device` at the LattePanda's **own
native I2C bus** — this is a separate physical link from the motor Leonardo's serial connection, not
the Arduino's D2 pin (the original DHT11 plan). Bus number varies by board; confirm with
`i2cdetect -y N` (the SHT41 answers at `0x44`/`68`). The backend (`Sht41EnvSensor`) polls at
`period_ms`, discarding any sample that fails the SHT4x CRC-8 check. Set `[Features] mock_env = true`
to use a synthetic reading on dev machines (no hardware) — this is also the checked-in
`config.example.ini` default. The sensor sits behind the generic `IEnvSensor` interface
([include/fgc/IEnvSensor.h](../include/fgc/IEnvSensor.h)), so swapping in a different ambient sensor
later means adding a new backend, not touching `Application`, the UI, or MQTT.
### Secrets ### Secrets
`mqtt_user` / `mqtt_pw` are read from the environment variables **`FGC_MQTT_USER` / `FGC_MQTT_PW`** first, `mqtt_user` / `mqtt_pw` are read from the environment variables **`FGC_MQTT_USER` / `FGC_MQTT_PW`** first,
@ -220,8 +237,9 @@ operation is unchanged and remains the default — the same binary runs under sy
logs on stdout. logs on stdout.
Dashboard panels: **Gimbal** (per-axis state, heading, encoder counts, flag badges, target), Dashboard panels: **Gimbal** (per-axis state, heading, encoder counts, flag badges, target),
**Sensors** (DHT11 still *pending*; the **Xsens MTi** shows live roll/pitch/yaw + temp once **Sensors** (the **Xsens MTi** shows live roll/pitch/yaw + temp once `enable_imu`; the ambient
`enable_imu`), **Camera** (count, capture state, rate, last capture), **Connectivity** (MQTT state, **SHT41** shows live temp/humidity once `enable_env` — see [known-issues.md](known-issues.md) for
its hardware-bring-up status), **Camera** (count, capture state, rate, last capture), **Connectivity** (MQTT state,
broker, tower, control mode, target heading). Adding a panel later is a struct in `UiSnapshot.h` plus broker, tower, control mode, target heading). Adding a panel later is a struct in `UiSnapshot.h` plus
one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp). one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp).

View File

@ -94,4 +94,8 @@ persistence for LPM + usbfs, and verify a full sweep captures white-balanced fra
yet (not homed / no `gimbal dump`), nudge requests one and does nothing that press — it never falls yet (not homed / no `gimbal dump`), nudge requests one and does nothing that press — it never falls
back to the configured degree clamps (which, if `*_min_deg`/`*_max_deg` were unset, produced absurd back to the configured degree clamps (which, if `*_min_deg`/`*_max_deg` were unset, produced absurd
±100000° steps). ±100000° steps).
- DHT11 temperature/humidity is still a Sensors-panel placeholder (the IMU half is integrated). - Ambient temperature/humidity (SHT41, `IEnvSensor`/`Sht41EnvSensor`/`MockEnvSensor`) is
integrated in software — config-gated (`[Features] enable_env`/`mock_env`, `[Env]`), Sensors
panel + MQTT `Env` topic wired — but defaults to `mock_env = true` in the checked-in example
config. Bus/address bring-up and validation against the physical sensor on the LattePanda's I2C
bus is still pending; flip `mock_env = false` on the deployed `config.ini` once confirmed.

View File

@ -52,6 +52,7 @@ When a ControlCode message arrives, the program echoes the current code back on
|-------|------|---------|--------------| |-------|------|---------|--------------|
| `GGS/FWT/<tower>/StatusCode` | At startup (`"0"`); whenever a ControlCode message is received (echoes the code) | integer as string | 1 / retained | | `GGS/FWT/<tower>/StatusCode` | At startup (`"0"`); whenever a ControlCode message is received (echoes the code) | integer as string | 1 / retained |
| `GGS/FWT/<tower>/CamEvent` | After each image is saved | JSON object (below) | 1 / retained | | `GGS/FWT/<tower>/CamEvent` | After each image is saved | JSON object (below) | 1 / retained |
| `GGS/FWT/<tower>/Env` | Once per fresh ambient sensor sample (see `[Env] period_ms`) | JSON object (below) | 1 / retained |
### CamEvent payload ### CamEvent payload
@ -72,6 +73,22 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
> The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file > The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file
> for a given event: `<RGB|ACR|NIR>/<time>.jxl`. > for a given event: `<RGB|ACR|NIR>/<time>.jxl`.
### Env payload
Built by `MqttControlChannel::publishEnv` from an `EnvEvent`, sourced from whichever `IEnvSensor` backend is
active (SHT41 on the LattePanda's own I2C bus by default; see [configuration.md](configuration.md)):
```json
{ "fwt":"ExampleTower", "temp_c":22.4, "humid_pct":46.1, "time":1719312345678 }
```
| Field | Type | Meaning |
|-------|------|---------|
| `fwt` | string | Tower name (`config.ini` `tower_name`) |
| `temp_c` | float | Ambient temperature, °C |
| `humid_pct` | float | Ambient relative humidity, % |
| `time` | int | Sample timestamp, Unix epoch **milliseconds** |
## Topic summary ## Topic summary
``` ```
@ -80,6 +97,7 @@ subscribe: GGS/FWT/<tower>/target_HDG (int heading)
publish: GGS/FWT/<tower>/StatusCode (echoed control code) publish: GGS/FWT/<tower>/StatusCode (echoed control code)
GGS/FWT/<tower>/CamEvent (JSON: fwt, cam, hdg×10, pit×10, time-ms) GGS/FWT/<tower>/CamEvent (JSON: fwt, cam, hdg×10, pit×10, time-ms)
GGS/FWT/<tower>/Env (JSON: fwt, temp_c, humid_pct, time-ms)
``` ```
## Local testing ## Local testing

View File

@ -27,9 +27,6 @@ Keep entries dated; move shipped items into the reference docs.
a new `IControlChannel` implementation (richer routing/durable queues, but must a new `IControlChannel` implementation (richer routing/durable queues, but must
re-engineer retained "last value" semantics). The abstraction seam already re-engineer retained "last value" semantics). The abstraction seam already
exists (`IControlChannel`). _(TODO: confirm approach, target release.)_ exists (`IControlChannel`). _(TODO: confirm approach, target release.)_
- **DHT11 environmental sensor.** Temperature + humidity on the Arduino side; new
firmware `READ DHT` command, host `env` telemetry, condensation monitoring.
Enables the `env/dht11` self-test. _(TODO: wiring, sampling cadence.)_
- **RGB camera.** Integrate the production RGB sensor end-to-end (acquire → JXL → - **RGB camera.** Integrate the production RGB sensor end-to-end (acquire → JXL →
CamEvent); enables the `camera/rgb` self-test. _(TODO: model/SDK, mounting.)_ CamEvent); enables the `camera/rgb` self-test. _(TODO: model/SDK, mounting.)_
- **Thermal camera.** Add the thermal sensor with radiometric handling (NUC, - **Thermal camera.** Add the thermal sensor with radiometric handling (NUC,

View File

@ -7,20 +7,22 @@ apply unchanged). **Status: planned — blocked on component.** When a component
lands, promote its entry into [test-command.md](test-command.md) and the taxonomy lands, promote its entry into [test-command.md](test-command.md) and the taxonomy
table. table.
## 1. `env / *` — environmental sensing — *blocked on: DHT11* ## 1. `env / *` — environmental sensing — *blocked on: SHT41 hardware bring-up*
The DHT11 (temperature + humidity) attaches to the Arduino/firmware side. The ambient sensor is now an SHT41 on the LattePanda's **own I2C bus** (not the Arduino) — see
[configuration.md](configuration.md) `[Env]`. The software side has landed: `IEnvSensor` /
`Sht41EnvSensor` / `MockEnvSensor`, config-gated, wired into the Sensors panel and the MQTT `Env`
topic. What's still blocking this self-test leaf is validating the real backend against physical
hardware (bus/address confirmation, a run with `mock_env = false`).
- **`env / dht11`** — _Healthy:_ sensor responds every read; temperature and - **`env / sht41`** — _Healthy:_ sensor responds every poll (`period_ms`); temperature and humidity
humidity in plausible range; values update (not stuck). _Checks:_ issue the in plausible range; values update (not stuck); CRC-8 failures stay rare. _Checks:_ read the sensor
firmware read N times over a window; verify each returns a valid frame (DHT11 N times over a window via `Sht41EnvSensor`; verify each returns a CRC-valid frame; count CRC
checksum ok). _Metrics:_ temperature °C, relative humidity %, read-failure count, failures / stale (`connected() == false`) periods. _Metrics:_ temperature °C, relative humidity %,
**dewpoint margin** (flag condensation risk when humidity is high and temperature read-failure count, **dewpoint margin** (flag condensation risk when humidity is high and
is near the computed dewpoint — relevant for an outdoor tower enclosure). temperature is near the computed dewpoint — relevant for an outdoor tower enclosure). _Host:_ a
_Firmware:_ add a `READ DHT` command → `EN T <decideg> RH <deci%> OK|ERR` line `TestRunner` leaf reading `IEnvSensor::sample()` directly (no new wire protocol needed — the
(DHT11 is bit-banged; the firmware already owns timing-critical I/O). _Host:_ an abstraction the IMU-style tests already use).
`EnvReport` parser mirroring `DiagParser`; add `IEnvSource` if the IMU-style
abstraction is wanted, else read over the motor serial link.
## 2. `comms / *` — message bus — *blocked on: RabbitMQ integration* ## 2. `comms / *` — message bus — *blocked on: RabbitMQ integration*

View File

@ -70,6 +70,8 @@ struct FeaturesConfig {
bool mock_camera = false; // use a simulated camera instead of Vimba X bool mock_camera = false; // use a simulated camera instead of Vimba X
bool mock_serial = false; // use a simulated motor controller bool mock_serial = false; // use a simulated motor controller
bool mock_imu = false; // use a simulated IMU instead of the MTi bool mock_imu = false; // use a simulated IMU instead of the MTi
bool enable_env = false; // ambient temp/humidity sensor (off by default)
bool mock_env = false; // use a simulated env sensor instead of the SHT41
}; };
// [IMU]: Xsens MTi connected over the LattePanda's RS-232 UART (a hardware // [IMU]: Xsens MTi connected over the LattePanda's RS-232 UART (a hardware
@ -79,6 +81,15 @@ struct ImuConfig {
unsigned int baud = 115200; unsigned int baud = 115200;
}; };
// [Env]: ambient temperature/humidity sensor (SHT41) on the LattePanda's own
// native I2C bus — NOT the Arduino/motor serial link. Bus number varies by
// board; confirm with `i2cdetect -y N` (expect the SHT41 at 0x44).
struct EnvConfig {
std::string i2c_device = "/dev/i2c-1"; // empty => required when enabled
unsigned int i2c_addr = 0x44;
int period_ms = 2000; // sample interval
};
struct LoggingConfig { struct LoggingConfig {
std::string level; // trace|debug|info|warn|error|off; empty => default (CLI overrides) std::string level; // trace|debug|info|warn|error|off; empty => default (CLI overrides)
std::string trace; // verbatim wire-trace categories: serial,mqtt,camera,control,all,none std::string trace; // verbatim wire-trace categories: serial,mqtt,camera,control,all,none
@ -142,6 +153,7 @@ struct AppConfig {
long enc_error_warn_counts = 400; // [Motor] live encoder-error WARN (0=off) long enc_error_warn_counts = 400; // [Motor] live encoder-error WARN (0=off)
ScanConfig scan; // [Scan] grid source ScanConfig scan; // [Scan] grid source
ImuConfig imu; // [IMU] Xsens MTi serial device ImuConfig imu; // [IMU] Xsens MTi serial device
EnvConfig env; // [Env] SHT41 ambient sensor, LattePanda I2C
TestConfig test; // [Test] hardware self-test profiles TestConfig test; // [Test] hardware self-test profiles
// Capture rate in images/second (derived from general.image_interval). // Capture rate in images/second (derived from general.image_interval).

View File

@ -22,6 +22,14 @@ struct CamEvent {
long long timestamp_ms = 0; // Unix epoch ms; matches the image filename long long timestamp_ms = 0; // Unix epoch ms; matches the image filename
}; };
// Ambient temperature/humidity reading published once per fresh sensor sample.
struct EnvEvent {
std::string tower;
float temp_c = 0.0f;
float humidity_pct = 0.0f;
long long timestamp_ms = 0;
};
// Abstraction over the remote control/telemetry channel. Implemented by // Abstraction over the remote control/telemetry channel. Implemented by
// MqttControlChannel (Eclipse Paho) and NullControlChannel (no broker; used // MqttControlChannel (Eclipse Paho) and NullControlChannel (no broker; used
// for development - publishes are dropped and poll() yields a default). // for development - publishes are dropped and poll() yields a default).
@ -36,6 +44,7 @@ public:
virtual void publishStatus(int code) = 0; virtual void publishStatus(int code) = 0;
virtual void publishCamEvent(const CamEvent& event) = 0; virtual void publishCamEvent(const CamEvent& event) = 0;
virtual void publishEnv(const EnvEvent& event) = 0;
// Latest control input; clears the *_available flags so each update is // Latest control input; clears the *_available flags so each update is
// acted on once. // acted on once.

37
include/fgc/IEnvSensor.h Normal file
View File

@ -0,0 +1,37 @@
#pragma once
#include <optional>
#include <string>
namespace fgc {
struct EnvSample {
bool valid = false;
float temp_c = 0.0f;
float humidity_pct = 0.0f;
long long timestamp_ms = 0;
};
// Abstraction over an ambient temperature/humidity sensor. Implemented by
// Sht41EnvSensor (SHT41 on the LattePanda's native I2C bus) and MockEnvSensor
// (synthetic). The interface intentionally exposes nothing chip-specific, so
// swapping the sensor (a different I2C part, or a serial one) means writing a
// new backend, not touching Application/UiSnapshot/MQTT.
class IEnvSensor {
public:
virtual ~IEnvSensor() = default;
virtual void start() = 0;
virtual void stop() = 0;
// Whether a valid, recent reading is available.
virtual bool connected() const = 0;
// Latest reading, or nullopt if none/stale.
virtual std::optional<EnvSample> sample() = 0;
// Identity string for logs/UI, e.g. "SHT41".
virtual std::string name() const { return "env"; }
};
} // namespace fgc

View File

@ -32,6 +32,7 @@ public:
void publishStatus(int code) override; void publishStatus(int code) override;
void publishCamEvent(const CamEvent& event) override; void publishCamEvent(const CamEvent& event) override;
void publishEnv(const EnvEvent& event) override;
ControlCommand poll() override; ControlCommand poll() override;
private: private:
@ -50,6 +51,7 @@ private:
std::string topic_control_code_; std::string topic_control_code_;
std::string topic_status_; std::string topic_status_;
std::string topic_cam_event_; std::string topic_cam_event_;
std::string topic_env_;
mqtt::async_client client_; mqtt::async_client client_;
mqtt::connect_options conn_opts_; mqtt::connect_options conn_opts_;

View File

@ -0,0 +1,43 @@
#pragma once
#include "fgc/IEnvSensor.h"
#include "fgc/Logger.h"
#include <chrono>
#include <cmath>
namespace fgc {
// Simulated ambient sensor for development without hardware: synthesizes a
// slowly varying, plausible temp/humidity reading so the Sensors panel
// animates without an SHT41 attached.
class MockEnvSensor : public IEnvSensor {
public:
void start() override {
start_ = clock::now();
LOG_INFO << "[mock] env sensor started";
}
void stop() override { LOG_INFO << "[mock] env sensor stopped"; }
bool connected() const override { return true; }
std::optional<EnvSample> sample() override {
const double t = std::chrono::duration<double>(clock::now() - start_).count();
EnvSample s;
s.valid = true;
s.temp_c = static_cast<float>(22.0 + 3.0 * std::sin(t * 0.02));
s.humidity_pct = static_cast<float>(45.0 + 10.0 * std::sin(t * 0.013 + 1.0));
s.timestamp_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
return s;
}
std::string name() const override { return "SHT41 (mock)"; }
private:
using clock = std::chrono::steady_clock;
clock::time_point start_ = clock::now();
};
} // namespace fgc

View File

@ -22,6 +22,11 @@ public:
<< " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms << " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms
<< " (null channel)"; << " (null channel)";
} }
void publishEnv(const EnvEvent& e) override {
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB Env temp_c=" << e.temp_c
<< " humid_pct=" << e.humidity_pct << " t=" << e.timestamp_ms
<< " (null channel)";
}
ControlCommand poll() override { return {}; } ControlCommand poll() override { return {}; }
}; };

View File

@ -0,0 +1,37 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace fgc {
// Thin RAII wrapper around a Linux I2C device node (/dev/i2c-N), talking to
// one fixed slave address via the kernel i2c-dev ioctl interface. Generic
// (not sensor-specific) so any future I2C device reuses it.
class I2cBus {
public:
I2cBus(std::string device, uint8_t addr);
~I2cBus();
I2cBus(const I2cBus&) = delete;
I2cBus& operator=(const I2cBus&) = delete;
// Opens the device node and binds the slave address. Returns false on failure.
bool open();
void close();
bool isOpen() const { return fd_ >= 0; }
// Writes `tx`, then (if `rx` is non-empty) reads into it. Combined so a
// single call can do "write command, read reply". Returns false on any I/O
// error.
bool writeRead(const std::vector<uint8_t>& tx, std::vector<uint8_t>& rx);
bool write(const std::vector<uint8_t>& tx);
private:
std::string device_;
uint8_t addr_;
int fd_ = -1;
};
} // namespace fgc

View File

@ -0,0 +1,34 @@
#pragma once
#include "fgc/IEnvSensor.h"
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
namespace fgc {
// Real backend: Adafruit SHT41 temperature/humidity sensor on the
// LattePanda's own native I2C bus (NOT the Arduino/motor serial link).
// Triggers a high-precision measurement every `period`, sleeps out the ~10ms
// conversion delay, and reads back on its own thread so the ~100Hz control
// loop is never blocked. CRC-8-checked; a failed checksum discards the
// sample rather than reporting a bad reading.
class Sht41EnvSensor : public IEnvSensor {
public:
Sht41EnvSensor(std::string i2c_device, uint8_t addr, std::chrono::milliseconds period);
~Sht41EnvSensor() override;
void start() override;
void stop() override;
bool connected() const override;
std::optional<EnvSample> sample() override;
std::string name() const override { return "SHT41"; }
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace fgc

View File

@ -68,7 +68,7 @@ struct SensorField {
// A labelled group of readings from one physical sensor, rendered as its own // A labelled group of readings from one physical sensor, rendered as its own
// subsection in the Sensors panel. // subsection in the Sensors panel.
struct SensorGroup { struct SensorGroup {
std::string title; // "MTi (orientation)" / "DHT11 (ambient)" std::string title; // "MTi (orientation)" / "SHT41 (ambient)"
std::string status = "pending"; // "live" / "pending" / "no fix" std::string status = "pending"; // "live" / "pending" / "no fix"
bool present = false; // true once the driver feeds real data bool present = false; // true once the driver feeds real data
std::vector<SensorField> fields; std::vector<SensorField> fields;
@ -76,11 +76,13 @@ struct SensorGroup {
// The Sensors panel, split by source so it is clear which parameters come from // The Sensors panel, split by source so it is clear which parameters come from
// which device. Each group is filled with pending placeholders until its driver // which device. Each group is filled with pending placeholders until its driver
// lands. NOTE: the MTi's Temp is the device's *internal* temperature; the DHT11 // lands. NOTE: the MTi's Temp is the device's *internal* temperature; the env
// Temp (separate group) is *ambient* — deliberately kept apart. // sensor's Temp (separate group) is *ambient* — deliberately kept apart. The
// env group's title/backing sensor is swappable (currently SHT41 over I2C; see
// IEnvSensor) without touching this struct.
struct SensorsView { struct SensorsView {
SensorGroup imu; // Xsens MTi: Roll/Pitch/Yaw + internal temperature SensorGroup imu; // Xsens MTi: Roll/Pitch/Yaw + internal temperature
SensorGroup dht; // DHT11: ambient temperature + humidity SensorGroup env; // ambient temperature + humidity (SHT41)
}; };
// One auto-sweep scan-grid waypoint, for the expanded camera view. // One auto-sweep scan-grid waypoint, for the expanded camera view.

View File

@ -9,6 +9,7 @@
#include "fgc/Paths.h" #include "fgc/Paths.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h" #include "fgc/IControlChannel.h"
#include "fgc/IEnvSensor.h"
#include "fgc/IImuSource.h" #include "fgc/IImuSource.h"
#include "fgc/IMotorController.h" #include "fgc/IMotorController.h"
#include "fgc/ImagePipeline.h" #include "fgc/ImagePipeline.h"
@ -19,9 +20,11 @@
#include "fgc/TestReport.h" #include "fgc/TestReport.h"
#include "fgc/TestRunner.h" #include "fgc/TestRunner.h"
#include "fgc/mock/MockCameraSource.h" #include "fgc/mock/MockCameraSource.h"
#include "fgc/mock/MockEnvSensor.h"
#include "fgc/mock/MockImuSource.h" #include "fgc/mock/MockImuSource.h"
#include "fgc/mock/MockMotorController.h" #include "fgc/mock/MockMotorController.h"
#include "fgc/mock/NullControlChannel.h" #include "fgc/mock/NullControlChannel.h"
#include "fgc/sensors/Sht41EnvSensor.h"
#include "fgc/ui/HeadlessUi.h" #include "fgc/ui/HeadlessUi.h"
#include "fgc/ui/IUserInterface.h" #include "fgc/ui/IUserInterface.h"
#include "fgc/ui/UiSnapshot.h" #include "fgc/ui/UiSnapshot.h"
@ -179,6 +182,7 @@ struct Application::Impl {
std::unique_ptr<IMotorController> motor; std::unique_ptr<IMotorController> motor;
std::unique_ptr<ICameraSource> camera; std::unique_ptr<ICameraSource> camera;
std::unique_ptr<IImuSource> imu; std::unique_ptr<IImuSource> imu;
std::unique_ptr<IEnvSensor> env;
std::unique_ptr<ImagePipeline> pipeline; std::unique_ptr<ImagePipeline> pipeline;
std::unique_ptr<CaptureScheduler> scheduler; std::unique_ptr<CaptureScheduler> scheduler;
std::unique_ptr<IUserInterface> ui; std::unique_ptr<IUserInterface> ui;
@ -219,6 +223,7 @@ struct Application::Impl {
bool test_drift_gate_ = false; bool test_drift_gate_ = false;
mutable ImuConfigView imu_config_view_; // formatted MTi config (read once) mutable ImuConfigView imu_config_view_; // formatted MTi config (read once)
mutable bool imu_config_done_ = false; mutable bool imu_config_done_ = false;
mutable long long last_env_published_ts_ = 0; // gate MQTT publish to one per fresh sample
std::atomic<bool> running{true}; std::atomic<bool> running{true};
std::mutex cmd_mutex; std::mutex cmd_mutex;
@ -261,6 +266,18 @@ struct Application::Impl {
return std::make_unique<MtiImuSource>(cfg.imu.device, cfg.imu.baud); return std::make_unique<MtiImuSource>(cfg.imu.device, cfg.imu.baud);
} }
std::unique_ptr<IEnvSensor> makeEnv() {
if (!cfg.features.enable_env) return nullptr; // sensors panel stays "pending"
if (cfg.features.mock_env) return std::make_unique<MockEnvSensor>();
if (cfg.env.i2c_device.empty()) {
LOG_WARN << "Env sensor enabled but [Env] i2c_device is empty; disabling";
return nullptr;
}
return std::make_unique<Sht41EnvSensor>(cfg.env.i2c_device,
static_cast<uint8_t>(cfg.env.i2c_addr),
std::chrono::milliseconds(cfg.env.period_ms));
}
std::unique_ptr<ICameraSource> makeCamera() { std::unique_ptr<ICameraSource> makeCamera() {
bool mock = opts.mock_camera.value_or(cfg.features.mock_camera); bool mock = opts.mock_camera.value_or(cfg.features.mock_camera);
#if !FGC_WITH_VIMBA #if !FGC_WITH_VIMBA
@ -353,7 +370,7 @@ struct Application::Impl {
} }
} }
// --- Sensors: MTi (orientation) live if present; DHT11 (ambient) pending --- // --- Sensors: MTi (orientation) + SHT41 (ambient), each live if present ---
s.sensors = pendingSensorsView(); s.sensors = pendingSensorsView();
if (imu) { if (imu) {
auto fmt1 = [](float v) { auto fmt1 = [](float v) {
@ -401,6 +418,39 @@ struct Application::Impl {
} }
} }
if (env) {
auto fmt1 = [](float v) {
char b[24];
std::snprintf(b, sizeof(b), "%.1f", v);
return std::string(b);
};
auto setField = [&](SensorField& f, const std::string& v) {
f.value = v;
f.present = true;
};
if (auto m = env->sample()) {
auto& g = s.sensors.env;
g.present = true;
g.status = "live";
if (g.fields.size() >= 2) {
setField(g.fields[0], fmt1(m->temp_c));
setField(g.fields[1], fmt1(m->humidity_pct));
}
// Publish once per fresh sample, not every ~10ms control-loop tick.
if (channel && m->timestamp_ms != last_env_published_ts_) {
last_env_published_ts_ = m->timestamp_ms;
EnvEvent ev;
ev.tower = cfg.general.tower_name;
ev.temp_c = m->temp_c;
ev.humidity_pct = m->humidity_pct;
ev.timestamp_ms = m->timestamp_ms;
channel->publishEnv(ev);
}
} else {
s.sensors.env.status = "no fix";
}
}
// --- Camera / capture --- // --- Camera / capture ---
s.capture.present = true; s.capture.present = true;
s.capture.active = scheduler && scheduler->captureActive(); s.capture.active = scheduler && scheduler->captureActive();
@ -1331,6 +1381,7 @@ struct Application::Impl {
motor = makeMotor(); motor = makeMotor();
camera = makeCamera(); camera = makeCamera();
imu = makeImu(); imu = makeImu();
env = makeEnv();
if (!channel->connect()) if (!channel->connect())
LOG_WARN << "Control channel not connected; continuing in degraded mode"; LOG_WARN << "Control channel not connected; continuing in degraded mode";
@ -1382,6 +1433,7 @@ struct Application::Impl {
motor->start(); motor->start();
if (imu) imu->start(); if (imu) imu->start();
if (env) env->start();
camera->open(); camera->open();
pipeline->start(); pipeline->start();
channel->publishStatus(0); channel->publishStatus(0);
@ -1438,6 +1490,7 @@ struct Application::Impl {
camera->stop(); camera->stop();
camera->close(); camera->close();
if (imu) imu->stop(); if (imu) imu->stop();
if (env) env->stop();
motor->stop(); motor->stop();
channel->disconnect(); channel->disconnect();
return 0; return 0;

View File

@ -181,10 +181,16 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.features.mock_camera = getBool(kv, "Features.mock_camera", cfg.features.mock_camera); cfg.features.mock_camera = getBool(kv, "Features.mock_camera", cfg.features.mock_camera);
cfg.features.mock_serial = getBool(kv, "Features.mock_serial", cfg.features.mock_serial); cfg.features.mock_serial = getBool(kv, "Features.mock_serial", cfg.features.mock_serial);
cfg.features.mock_imu = getBool(kv, "Features.mock_imu", cfg.features.mock_imu); cfg.features.mock_imu = getBool(kv, "Features.mock_imu", cfg.features.mock_imu);
cfg.features.enable_env = getBool(kv, "Features.enable_env", cfg.features.enable_env);
cfg.features.mock_env = getBool(kv, "Features.mock_env", cfg.features.mock_env);
cfg.imu.device = get(kv, "IMU.device", cfg.imu.device); cfg.imu.device = get(kv, "IMU.device", cfg.imu.device);
cfg.imu.baud = static_cast<unsigned>(getInt(kv, "IMU.baud", cfg.imu.baud)); cfg.imu.baud = static_cast<unsigned>(getInt(kv, "IMU.baud", cfg.imu.baud));
cfg.env.i2c_device = get(kv, "Env.i2c_device", cfg.env.i2c_device);
cfg.env.i2c_addr = static_cast<unsigned>(getInt(kv, "Env.i2c_addr", cfg.env.i2c_addr));
cfg.env.period_ms = getInt(kv, "Env.period_ms", cfg.env.period_ms);
// [Test] + [TestProfile.<name>]: discover profiles by scanning the flattened // [Test] + [TestProfile.<name>]: discover profiles by scanning the flattened
// keys. inih gives us "TestProfile.<name>.<key>" => value; every numeric value // keys. inih gives us "TestProfile.<name>.<key>" => value; every numeric value
// lands in that profile's open param map for the test modules to read. // lands in that profile's open param map for the test modules to read.

View File

@ -19,6 +19,7 @@ MqttControlChannel::MqttControlChannel(const std::string& broker_ip, const std::
topic_control_code_("GGS/FWT/" + tower + "/ControlCode"), topic_control_code_("GGS/FWT/" + tower + "/ControlCode"),
topic_status_("GGS/FWT/" + tower + "/StatusCode"), topic_status_("GGS/FWT/" + tower + "/StatusCode"),
topic_cam_event_("GGS/FWT/" + tower + "/CamEvent"), topic_cam_event_("GGS/FWT/" + tower + "/CamEvent"),
topic_env_("GGS/FWT/" + tower + "/Env"),
client_(broker_ip, tower) { client_(broker_ip, tower) {
conn_opts_.set_keep_alive_interval(20); conn_opts_.set_keep_alive_interval(20);
conn_opts_.set_clean_session(true); conn_opts_.set_clean_session(true);
@ -123,4 +124,12 @@ void MqttControlChannel::publishCamEvent(const CamEvent& e) {
publish(topic_cam_event_, payload); publish(topic_cam_event_, payload);
} }
void MqttControlChannel::publishEnv(const EnvEvent& e) {
std::string payload = "{ \"fwt\":\"" + e.tower +
"\", \"temp_c\":" + std::to_string(e.temp_c) +
", \"humid_pct\":" + std::to_string(e.humidity_pct) +
", \"time\":" + std::to_string(e.timestamp_ms) + " }";
publish(topic_env_, payload);
}
} // namespace fgc } // namespace fgc

51
src/sensors/I2cBus.cpp Normal file
View File

@ -0,0 +1,51 @@
#include "fgc/sensors/I2cBus.h"
#include "fgc/Logger.h"
#include <fcntl.h>
#include <linux/i2c-dev.h>
#include <sys/ioctl.h>
#include <unistd.h>
namespace fgc {
I2cBus::I2cBus(std::string device, uint8_t addr) : device_(std::move(device)), addr_(addr) {}
I2cBus::~I2cBus() { close(); }
bool I2cBus::open() {
close();
fd_ = ::open(device_.c_str(), O_RDWR);
if (fd_ < 0) {
LOG_ERROR << "I2C: failed to open " << device_;
return false;
}
if (::ioctl(fd_, I2C_SLAVE, addr_) < 0) {
LOG_ERROR << "I2C: failed to select slave 0x" << std::hex << int(addr_) << std::dec
<< " on " << device_;
close();
return false;
}
return true;
}
void I2cBus::close() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
bool I2cBus::write(const std::vector<uint8_t>& tx) {
if (fd_ < 0) return false;
return ::write(fd_, tx.data(), tx.size()) == static_cast<ssize_t>(tx.size());
}
bool I2cBus::writeRead(const std::vector<uint8_t>& tx, std::vector<uint8_t>& rx) {
if (fd_ < 0) return false;
if (!tx.empty() && !write(tx)) return false;
if (rx.empty()) return true;
return ::read(fd_, rx.data(), rx.size()) == static_cast<ssize_t>(rx.size());
}
} // namespace fgc

View File

@ -0,0 +1,123 @@
#include "fgc/sensors/Sht41EnvSensor.h"
#include "fgc/Logger.h"
#include "fgc/sensors/I2cBus.h"
#include <atomic>
#include <chrono>
#include <mutex>
#include <thread>
namespace fgc {
namespace {
constexpr uint8_t kCmdSoftReset = 0x94;
constexpr uint8_t kCmdMeasureHighPrec = 0xFD;
constexpr auto kConversionDelay = std::chrono::milliseconds(10);
constexpr auto kSoftResetSettle = std::chrono::milliseconds(1);
// SHT4x CRC-8: poly 0x31, init 0xFF. Datasheet test vector: 0xBE 0xEF -> 0x92.
uint8_t crc8Sht41(const uint8_t* data, size_t len) {
uint8_t crc = 0xFF;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i];
for (int b = 0; b < 8; ++b)
crc = (crc & 0x80) ? static_cast<uint8_t>((crc << 1) ^ 0x31)
: static_cast<uint8_t>(crc << 1);
}
return crc;
}
} // namespace
struct Sht41EnvSensor::Impl {
Impl(std::string dev, uint8_t a, std::chrono::milliseconds p)
: bus(std::move(dev), a), period(p) {}
using clock = std::chrono::steady_clock;
I2cBus bus;
std::chrono::milliseconds period;
std::thread worker;
std::atomic<bool> running{false};
std::mutex mutex;
EnvSample latest;
clock::time_point last_ok{};
void readOnce() {
if (!bus.write({kCmdMeasureHighPrec})) return;
std::this_thread::sleep_for(kConversionDelay);
std::vector<uint8_t> rx(6);
if (!bus.writeRead({}, rx)) return;
if (crc8Sht41(rx.data(), 2) != rx[2] || crc8Sht41(rx.data() + 3, 2) != rx[5]) {
LOG_WARN << "SHT41: CRC mismatch, discarding sample";
return;
}
const uint16_t traw = static_cast<uint16_t>((rx[0] << 8) | rx[1]);
const uint16_t rhraw = static_cast<uint16_t>((rx[3] << 8) | rx[4]);
float temp_c = -45.0f + 175.0f * (static_cast<float>(traw) / 65535.0f);
float rh_pct = -6.0f + 125.0f * (static_cast<float>(rhraw) / 65535.0f);
if (rh_pct < 0.0f) rh_pct = 0.0f;
if (rh_pct > 100.0f) rh_pct = 100.0f;
std::lock_guard<std::mutex> lock(mutex);
latest.valid = true;
latest.temp_c = temp_c;
latest.humidity_pct = rh_pct;
latest.timestamp_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
last_ok = clock::now();
}
void run() {
while (running.load()) {
readOnce();
std::this_thread::sleep_for(period);
}
}
};
Sht41EnvSensor::Sht41EnvSensor(std::string i2c_device, uint8_t addr,
std::chrono::milliseconds period)
: impl_(std::make_unique<Impl>(std::move(i2c_device), addr, period)) {}
Sht41EnvSensor::~Sht41EnvSensor() { stop(); }
void Sht41EnvSensor::start() {
if (!impl_->bus.open()) {
LOG_ERROR << "SHT41: failed to open I2C bus; sensor disabled";
return;
}
impl_->bus.write({kCmdSoftReset});
std::this_thread::sleep_for(kSoftResetSettle);
impl_->running = true;
impl_->worker = std::thread([this] { impl_->run(); });
LOG_INFO << "SHT41 env sensor started";
}
void Sht41EnvSensor::stop() {
if (!impl_) return;
impl_->running = false;
if (impl_->worker.joinable()) impl_->worker.join();
impl_->bus.close();
}
bool Sht41EnvSensor::connected() const {
std::lock_guard<std::mutex> lock(impl_->mutex);
return impl_->latest.valid &&
(Impl::clock::now() - impl_->last_ok) < 2 * impl_->period;
}
std::optional<EnvSample> Sht41EnvSensor::sample() {
std::lock_guard<std::mutex> lock(impl_->mutex);
if (!impl_->latest.valid || (Impl::clock::now() - impl_->last_ok) >= 2 * impl_->period)
return std::nullopt;
return impl_->latest;
}
} // namespace fgc

View File

@ -82,8 +82,8 @@ Element gimbalPanel(const GimbalView& g) {
Element sensorsPanel(const SensorsView& s) { Element sensorsPanel(const SensorsView& s) {
// One labelled subsection per physical sensor: a title + status header, then // One labelled subsection per physical sensor: a title + status header, then
// its readings. Keeps MTi (orientation/device temp) and DHT11 (ambient) // its readings. Keeps MTi (orientation/device temp) and the ambient env
// clearly separated. // sensor (SHT41) clearly separated.
auto group = [](const SensorGroup& g, const std::string& hint) { auto group = [](const SensorGroup& g, const std::string& hint) {
std::vector<Element> rows; std::vector<Element> rows;
Element status = g.present ? (text(g.status) | color(Color::Green)) Element status = g.present ? (text(g.status) | color(Color::Green))
@ -100,7 +100,7 @@ Element sensorsPanel(const SensorsView& s) {
return panel("[i] SENSORS", Color::Magenta, return panel("[i] SENSORS", Color::Magenta,
vbox({group(s.imu, "i to expand"), vbox({group(s.imu, "i to expand"),
separator(), separator(),
group(s.dht, "")})); group(s.env, "")}));
} }
Element cameraPanel(const CaptureView& c) { Element cameraPanel(const CaptureView& c) {

View File

@ -56,11 +56,12 @@ SensorsView pendingSensorsView() {
v.imu.fields.push_back({"Pitch", "--.-", "\xC2\xB0", false}); v.imu.fields.push_back({"Pitch", "--.-", "\xC2\xB0", false});
v.imu.fields.push_back({"Yaw", "--.-", "\xC2\xB0", false}); v.imu.fields.push_back({"Yaw", "--.-", "\xC2\xB0", false});
v.imu.fields.push_back({"Temp", "--.-", "\xC2\xB0""C", false}); // MTi internal v.imu.fields.push_back({"Temp", "--.-", "\xC2\xB0""C", false}); // MTi internal
// DHT11 temperature & humidity (Aosong): 0-50 °C ±2 °C, 20-90 %RH ±5 % (ambient). // SHT41 temperature & humidity (Adafruit, I2C on the LattePanda's own bus):
v.dht.title = "DHT11 (ambient)"; // ±0.2 °C, ±1.8 %RH (ambient).
v.dht.status = "pending"; v.env.title = "SHT41 (ambient)";
v.dht.fields.push_back({"Temp", "--.-", "\xC2\xB0""C", false}); v.env.status = "pending";
v.dht.fields.push_back({"Humid", "--", "%RH", false}); v.env.fields.push_back({"Temp", "--.-", "\xC2\xB0""C", false});
v.env.fields.push_back({"Humid", "--", "%RH", false});
return v; return v;
} }

View File

@ -37,6 +37,29 @@ TEST_CASE("ConfigLoader maps and defaults typed values") {
CHECK(std::abs(c.image_rate() - 1.0 / 3.0) < 1e-9); CHECK(std::abs(c.image_rate() - 1.0 / 3.0) < 1e-9);
} }
TEST_CASE("ConfigLoader maps [Env]/env feature flags") {
AppConfig d = ConfigLoader::fromMap({});
CHECK(d.features.enable_env == false);
CHECK(d.features.mock_env == false);
CHECK(d.env.i2c_device == "/dev/i2c-1");
CHECK(d.env.i2c_addr == 0x44);
CHECK(d.env.period_ms == 2000);
std::map<std::string, std::string> kv = {
{"Features.enable_env", "true"},
{"Features.mock_env", "true"},
{"Env.i2c_device", "/dev/i2c-3"},
{"Env.i2c_addr", "68"},
{"Env.period_ms", "5000"},
};
AppConfig c = ConfigLoader::fromMap(kv);
CHECK(c.features.enable_env == true);
CHECK(c.features.mock_env == true);
CHECK(c.env.i2c_device == "/dev/i2c-3");
CHECK(c.env.i2c_addr == 68);
CHECK(c.env.period_ms == 5000);
}
TEST_CASE("environment overrides file credentials") { TEST_CASE("environment overrides file credentials") {
setenv("FGC_MQTT_USER", "envuser", 1); setenv("FGC_MQTT_USER", "envuser", 1);
AppConfig c = ConfigLoader::fromMap({{"Network.mqtt_user", "fileuser"}}); AppConfig c = ConfigLoader::fromMap({{"Network.mqtt_user", "fileuser"}});

View File

@ -44,6 +44,7 @@ struct FakeChannel : IControlChannel {
bool connected() const override { return true; } bool connected() const override { return true; }
void publishStatus(int c) override { last_status = c; } void publishStatus(int c) override { last_status = c; }
void publishCamEvent(const CamEvent&) override {} void publishCamEvent(const CamEvent&) override {}
void publishEnv(const EnvEvent&) override {}
ControlCommand poll() override { ControlCommand poll() override {
ControlCommand c = next; ControlCommand c = next;
next = {}; next = {};

View File

@ -61,12 +61,12 @@ TEST_CASE("MockCameraSource::deviceInfo reports identity, dims, streaming, frame
TEST_CASE("pendingSensorsView: grouped by sensor, all absent until drivers land") { TEST_CASE("pendingSensorsView: grouped by sensor, all absent until drivers land") {
SensorsView v = pendingSensorsView(); SensorsView v = pendingSensorsView();
CHECK_FALSE(v.imu.present); CHECK_FALSE(v.imu.present);
CHECK_FALSE(v.dht.present); CHECK_FALSE(v.env.present);
CHECK_FALSE(v.imu.title.empty()); CHECK_FALSE(v.imu.title.empty());
CHECK_FALSE(v.dht.title.empty()); CHECK_FALSE(v.env.title.empty());
CHECK(v.imu.fields.size() >= 3); // roll/pitch/yaw (+ device temp) CHECK(v.imu.fields.size() >= 3); // roll/pitch/yaw (+ device temp)
CHECK(v.dht.fields.size() >= 2); // ambient temp + humidity CHECK(v.env.fields.size() >= 2); // ambient temp + humidity
for (const auto* g : {&v.imu, &v.dht}) { for (const auto* g : {&v.imu, &v.env}) {
for (const auto& f : g->fields) { for (const auto& f : g->fields) {
CHECK_FALSE(f.present); CHECK_FALSE(f.present);
CHECK_FALSE(f.label.empty()); CHECK_FALSE(f.label.empty());