added temperature and humidity sensor (SHT41)
This commit is contained in:
parent
c41cb6fc6a
commit
271b076394
|
|
@ -81,6 +81,8 @@ set(FGC_SOURCES
|
|||
src/camera/ImagePipeline.cpp
|
||||
src/serial/SerialMotorController.cpp
|
||||
src/serial/MtiImuSource.cpp
|
||||
src/sensors/I2cBus.cpp
|
||||
src/sensors/Sht41EnvSensor.cpp
|
||||
)
|
||||
if(WITH_MQTT)
|
||||
list(APPEND FGC_SOURCES src/mqtt/MqttControlChannel.cpp)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ baud = 115200
|
|||
device =
|
||||
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]
|
||||
; Degrees<->encoder-counts calibration for each axis. The firmware speaks only
|
||||
; in absolute encoder counts; these map them to the heading/elevation degrees
|
||||
|
|
@ -137,6 +146,8 @@ enable_imu = false
|
|||
mock_camera = false
|
||||
mock_serial = false
|
||||
mock_imu = false
|
||||
enable_env = false
|
||||
mock_env = false
|
||||
|
||||
[Test]
|
||||
; Hardware self-test command (`test`). `profile` is the default profile used when
|
||||
|
|
|
|||
|
|
@ -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_serial` | bool | `false` | Use the simulated motor controller |
|
||||
| `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` | `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` | `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`) |
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
`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.
|
||||
|
||||
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
|
||||
`enable_imu`), **Camera** (count, capture state, rate, last capture), **Connectivity** (MQTT state,
|
||||
**Sensors** (the **Xsens MTi** shows live roll/pitch/yaw + temp once `enable_imu`; the ambient
|
||||
**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
|
||||
one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
back to the configured degree clamps (which, if `*_min_deg`/`*_max_deg` were unset, produced absurd
|
||||
±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.
|
||||
|
|
|
|||
|
|
@ -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>/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
|
||||
|
||||
|
|
@ -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
|
||||
> 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
|
||||
|
||||
```
|
||||
|
|
@ -80,6 +97,7 @@ subscribe: GGS/FWT/<tower>/target_HDG (int heading)
|
|||
|
||||
publish: GGS/FWT/<tower>/StatusCode (echoed control code)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -27,9 +27,6 @@ Keep entries dated; move shipped items into the reference docs.
|
|||
a new `IControlChannel` implementation (richer routing/durable queues, but must
|
||||
re-engineer retained "last value" semantics). The abstraction seam already
|
||||
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 →
|
||||
CamEvent); enables the `camera/rgb` self-test. _(TODO: model/SDK, mounting.)_
|
||||
- **Thermal camera.** Add the thermal sensor with radiometric handling (NUC,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
humidity in plausible range; values update (not stuck). _Checks:_ issue the
|
||||
firmware read N times over a window; verify each returns a valid frame (DHT11
|
||||
checksum ok). _Metrics:_ temperature °C, relative humidity %, read-failure count,
|
||||
**dewpoint margin** (flag condensation risk when humidity is high and temperature
|
||||
is near the computed dewpoint — relevant for an outdoor tower enclosure).
|
||||
_Firmware:_ add a `READ DHT` command → `EN T <decideg> RH <deci%> OK|ERR` line
|
||||
(DHT11 is bit-banged; the firmware already owns timing-critical I/O). _Host:_ an
|
||||
`EnvReport` parser mirroring `DiagParser`; add `IEnvSource` if the IMU-style
|
||||
abstraction is wanted, else read over the motor serial link.
|
||||
- **`env / sht41`** — _Healthy:_ sensor responds every poll (`period_ms`); temperature and humidity
|
||||
in plausible range; values update (not stuck); CRC-8 failures stay rare. _Checks:_ read the sensor
|
||||
N times over a window via `Sht41EnvSensor`; verify each returns a CRC-valid frame; count CRC
|
||||
failures / stale (`connected() == false`) periods. _Metrics:_ temperature °C, relative humidity %,
|
||||
read-failure count, **dewpoint margin** (flag condensation risk when humidity is high and
|
||||
temperature is near the computed dewpoint — relevant for an outdoor tower enclosure). _Host:_ a
|
||||
`TestRunner` leaf reading `IEnvSensor::sample()` directly (no new wire protocol needed — the
|
||||
abstraction the IMU-style tests already use).
|
||||
|
||||
## 2. `comms / *` — message bus — *blocked on: RabbitMQ integration*
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ struct FeaturesConfig {
|
|||
bool mock_camera = false; // use a simulated camera instead of Vimba X
|
||||
bool mock_serial = false; // use a simulated motor controller
|
||||
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
|
||||
|
|
@ -79,6 +81,15 @@ struct ImuConfig {
|
|||
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 {
|
||||
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
|
||||
|
|
@ -142,6 +153,7 @@ struct AppConfig {
|
|||
long enc_error_warn_counts = 400; // [Motor] live encoder-error WARN (0=off)
|
||||
ScanConfig scan; // [Scan] grid source
|
||||
ImuConfig imu; // [IMU] Xsens MTi serial device
|
||||
EnvConfig env; // [Env] SHT41 ambient sensor, LattePanda I2C
|
||||
TestConfig test; // [Test] hardware self-test profiles
|
||||
|
||||
// Capture rate in images/second (derived from general.image_interval).
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ struct CamEvent {
|
|||
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
|
||||
// MqttControlChannel (Eclipse Paho) and NullControlChannel (no broker; used
|
||||
// for development - publishes are dropped and poll() yields a default).
|
||||
|
|
@ -36,6 +44,7 @@ public:
|
|||
|
||||
virtual void publishStatus(int code) = 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
|
||||
// acted on once.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -32,6 +32,7 @@ public:
|
|||
|
||||
void publishStatus(int code) override;
|
||||
void publishCamEvent(const CamEvent& event) override;
|
||||
void publishEnv(const EnvEvent& event) override;
|
||||
ControlCommand poll() override;
|
||||
|
||||
private:
|
||||
|
|
@ -50,6 +51,7 @@ private:
|
|||
std::string topic_control_code_;
|
||||
std::string topic_status_;
|
||||
std::string topic_cam_event_;
|
||||
std::string topic_env_;
|
||||
|
||||
mqtt::async_client client_;
|
||||
mqtt::connect_options conn_opts_;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -22,6 +22,11 @@ public:
|
|||
<< " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms
|
||||
<< " (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 {}; }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -68,7 +68,7 @@ struct SensorField {
|
|||
// A labelled group of readings from one physical sensor, rendered as its own
|
||||
// subsection in the Sensors panel.
|
||||
struct SensorGroup {
|
||||
std::string title; // "MTi (orientation)" / "DHT11 (ambient)"
|
||||
std::string title; // "MTi (orientation)" / "SHT41 (ambient)"
|
||||
std::string status = "pending"; // "live" / "pending" / "no fix"
|
||||
bool present = false; // true once the driver feeds real data
|
||||
std::vector<SensorField> fields;
|
||||
|
|
@ -76,11 +76,13 @@ struct SensorGroup {
|
|||
|
||||
// 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
|
||||
// lands. NOTE: the MTi's Temp is the device's *internal* temperature; the DHT11
|
||||
// Temp (separate group) is *ambient* — deliberately kept apart.
|
||||
// lands. NOTE: the MTi's Temp is the device's *internal* temperature; the env
|
||||
// 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 {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include "fgc/Paths.h"
|
||||
#include "fgc/ICameraSource.h"
|
||||
#include "fgc/IControlChannel.h"
|
||||
#include "fgc/IEnvSensor.h"
|
||||
#include "fgc/IImuSource.h"
|
||||
#include "fgc/IMotorController.h"
|
||||
#include "fgc/ImagePipeline.h"
|
||||
|
|
@ -19,9 +20,11 @@
|
|||
#include "fgc/TestReport.h"
|
||||
#include "fgc/TestRunner.h"
|
||||
#include "fgc/mock/MockCameraSource.h"
|
||||
#include "fgc/mock/MockEnvSensor.h"
|
||||
#include "fgc/mock/MockImuSource.h"
|
||||
#include "fgc/mock/MockMotorController.h"
|
||||
#include "fgc/mock/NullControlChannel.h"
|
||||
#include "fgc/sensors/Sht41EnvSensor.h"
|
||||
#include "fgc/ui/HeadlessUi.h"
|
||||
#include "fgc/ui/IUserInterface.h"
|
||||
#include "fgc/ui/UiSnapshot.h"
|
||||
|
|
@ -179,6 +182,7 @@ struct Application::Impl {
|
|||
std::unique_ptr<IMotorController> motor;
|
||||
std::unique_ptr<ICameraSource> camera;
|
||||
std::unique_ptr<IImuSource> imu;
|
||||
std::unique_ptr<IEnvSensor> env;
|
||||
std::unique_ptr<ImagePipeline> pipeline;
|
||||
std::unique_ptr<CaptureScheduler> scheduler;
|
||||
std::unique_ptr<IUserInterface> ui;
|
||||
|
|
@ -219,6 +223,7 @@ struct Application::Impl {
|
|||
bool test_drift_gate_ = false;
|
||||
mutable ImuConfigView imu_config_view_; // formatted MTi config (read once)
|
||||
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::mutex cmd_mutex;
|
||||
|
|
@ -261,6 +266,18 @@ struct Application::Impl {
|
|||
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() {
|
||||
bool mock = opts.mock_camera.value_or(cfg.features.mock_camera);
|
||||
#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();
|
||||
if (imu) {
|
||||
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 ---
|
||||
s.capture.present = true;
|
||||
s.capture.active = scheduler && scheduler->captureActive();
|
||||
|
|
@ -1331,6 +1381,7 @@ struct Application::Impl {
|
|||
motor = makeMotor();
|
||||
camera = makeCamera();
|
||||
imu = makeImu();
|
||||
env = makeEnv();
|
||||
|
||||
if (!channel->connect())
|
||||
LOG_WARN << "Control channel not connected; continuing in degraded mode";
|
||||
|
|
@ -1382,6 +1433,7 @@ struct Application::Impl {
|
|||
|
||||
motor->start();
|
||||
if (imu) imu->start();
|
||||
if (env) env->start();
|
||||
camera->open();
|
||||
pipeline->start();
|
||||
channel->publishStatus(0);
|
||||
|
|
@ -1438,6 +1490,7 @@ struct Application::Impl {
|
|||
camera->stop();
|
||||
camera->close();
|
||||
if (imu) imu->stop();
|
||||
if (env) env->stop();
|
||||
motor->stop();
|
||||
channel->disconnect();
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -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_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.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.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
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ MqttControlChannel::MqttControlChannel(const std::string& broker_ip, const std::
|
|||
topic_control_code_("GGS/FWT/" + tower + "/ControlCode"),
|
||||
topic_status_("GGS/FWT/" + tower + "/StatusCode"),
|
||||
topic_cam_event_("GGS/FWT/" + tower + "/CamEvent"),
|
||||
topic_env_("GGS/FWT/" + tower + "/Env"),
|
||||
client_(broker_ip, tower) {
|
||||
conn_opts_.set_keep_alive_interval(20);
|
||||
conn_opts_.set_clean_session(true);
|
||||
|
|
@ -123,4 +124,12 @@ void MqttControlChannel::publishCamEvent(const CamEvent& e) {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -82,8 +82,8 @@ Element gimbalPanel(const GimbalView& g) {
|
|||
|
||||
Element sensorsPanel(const SensorsView& s) {
|
||||
// One labelled subsection per physical sensor: a title + status header, then
|
||||
// its readings. Keeps MTi (orientation/device temp) and DHT11 (ambient)
|
||||
// clearly separated.
|
||||
// its readings. Keeps MTi (orientation/device temp) and the ambient env
|
||||
// sensor (SHT41) clearly separated.
|
||||
auto group = [](const SensorGroup& g, const std::string& hint) {
|
||||
std::vector<Element> rows;
|
||||
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,
|
||||
vbox({group(s.imu, "i to expand"),
|
||||
separator(),
|
||||
group(s.dht, "")}));
|
||||
group(s.env, "")}));
|
||||
}
|
||||
|
||||
Element cameraPanel(const CaptureView& c) {
|
||||
|
|
|
|||
|
|
@ -56,11 +56,12 @@ SensorsView pendingSensorsView() {
|
|||
v.imu.fields.push_back({"Pitch", "--.-", "\xC2\xB0", false});
|
||||
v.imu.fields.push_back({"Yaw", "--.-", "\xC2\xB0", false});
|
||||
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).
|
||||
v.dht.title = "DHT11 (ambient)";
|
||||
v.dht.status = "pending";
|
||||
v.dht.fields.push_back({"Temp", "--.-", "\xC2\xB0""C", false});
|
||||
v.dht.fields.push_back({"Humid", "--", "%RH", false});
|
||||
// SHT41 temperature & humidity (Adafruit, I2C on the LattePanda's own bus):
|
||||
// ±0.2 °C, ±1.8 %RH (ambient).
|
||||
v.env.title = "SHT41 (ambient)";
|
||||
v.env.status = "pending";
|
||||
v.env.fields.push_back({"Temp", "--.-", "\xC2\xB0""C", false});
|
||||
v.env.fields.push_back({"Humid", "--", "%RH", false});
|
||||
return v;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,29 @@ TEST_CASE("ConfigLoader maps and defaults typed values") {
|
|||
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") {
|
||||
setenv("FGC_MQTT_USER", "envuser", 1);
|
||||
AppConfig c = ConfigLoader::fromMap({{"Network.mqtt_user", "fileuser"}});
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ struct FakeChannel : IControlChannel {
|
|||
bool connected() const override { return true; }
|
||||
void publishStatus(int c) override { last_status = c; }
|
||||
void publishCamEvent(const CamEvent&) override {}
|
||||
void publishEnv(const EnvEvent&) override {}
|
||||
ControlCommand poll() override {
|
||||
ControlCommand c = next;
|
||||
next = {};
|
||||
|
|
|
|||
|
|
@ -61,12 +61,12 @@ TEST_CASE("MockCameraSource::deviceInfo reports identity, dims, streaming, frame
|
|||
TEST_CASE("pendingSensorsView: grouped by sensor, all absent until drivers land") {
|
||||
SensorsView v = pendingSensorsView();
|
||||
CHECK_FALSE(v.imu.present);
|
||||
CHECK_FALSE(v.dht.present);
|
||||
CHECK_FALSE(v.env.present);
|
||||
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.dht.fields.size() >= 2); // ambient temp + humidity
|
||||
for (const auto* g : {&v.imu, &v.dht}) {
|
||||
CHECK(v.env.fields.size() >= 2); // ambient temp + humidity
|
||||
for (const auto* g : {&v.imu, &v.env}) {
|
||||
for (const auto& f : g->fields) {
|
||||
CHECK_FALSE(f.present);
|
||||
CHECK_FALSE(f.label.empty());
|
||||
|
|
|
|||
Loading…
Reference in New Issue