Support the legacy towers' DHT11 and fan, and fix the installer's reflash and test steps

This commit is contained in:
pgdalmeida 2026-09-28 14:43:05 +02:00
parent 8cf3b3b9e9
commit 8d81e03a3f
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
24 changed files with 649 additions and 43 deletions

View File

@ -95,6 +95,7 @@ add_library(fgc_core STATIC
src/core/ChannelTopics.cpp src/core/ChannelTopics.cpp
src/sensors/I2cBus.cpp src/sensors/I2cBus.cpp
src/sensors/Sht41Protocol.cpp src/sensors/Sht41Protocol.cpp
src/sensors/McuEnvSensor.cpp
src/ui/UiSnapshot.cpp src/ui/UiSnapshot.cpp
src/ui/Scroll.cpp src/ui/Scroll.cpp
src/ui/HeadlessUi.cpp src/ui/HeadlessUi.cpp

View File

@ -46,7 +46,13 @@ axes = yaw, pitch
; is warned about and ignored. ; is warned about and ignored.
imu = none imu = none
; Ambient temperature/humidity sensor: a model name, or 'none'. Same rule. ; Ambient temperature/humidity sensor: a model name, or 'none'. Same rule.
; sht41 on the host's own I2C bus ([Env] below)
; dht11 on the motor MCU, which reads it (the legacy towers; needs [Pins] dht)
env = sht41 env = sht41
; An enclosure fan the motor MCU runs from its DHT11: 'fan', or 'none' (default).
; The legacy towers: off to 25 C, +20/255 per degree, full from 37.75 C, and full
; whenever the sensor cannot be read. Needs env = dht11 and [Pins] fan_pwm.
;cooling = fan
; The [Camera.<name>] blocks expected to be present and found. ; The [Camera.<name>] blocks expected to be present and found.
cameras = rgb cameras = rgb
; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts ; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts
@ -91,6 +97,13 @@ yaw_cs = 8
yaw_en = 7 yaw_en = 7
pitch_cs = 1 pitch_cs = 1
pitch_en = 0 pitch_en = 0
; Parts on the MCU itself - the legacy towers (hardware/lattepanda-3-delta/
; pins-legacy.ini). Each only with its declaration above; the firmware is built
; with them only where they are declared, and is unchanged elsewhere.
;dht = 9 ; env = dht11
;fan_pwm = 10 ; cooling = fan; a PWM pin (Leonardo: 3 5 6 9 10 11 13)
;fan_tach = 11 ; optional; lets the host see a fan that has stopped
;spi_idle_cs = 2 ; another device on the drivers' SPI bus, held deselected
[IMU] [IMU]
; Only read when [Hardware] imu is not 'none'. On the LattePanda 3 Delta the MTi ; Only read when [Hardware] imu is not 'none'. On the LattePanda 3 Delta the MTi

View File

@ -430,6 +430,42 @@ measured), both edges included - so `mode pan 90 30` takes six pictures, 75°
to 105°. It is a property of the camera and lens, so it lives in to 105°. It is a property of the camera and lens, so it lives in
`hardware.ini`. Must be above 0. See [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual). `hardware.ini`. Must be above 0. See [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual).
### The legacy towers: a DHT11 and a fan on the motor MCU
Four towers carry an ambient DHT11 and an enclosure fan wired to the Leonardo
itself, not to the host. They are declared, not branched:
```ini
[Hardware]
axes = yaw
env = dht11 ; read by the motor MCU
cooling = fan ; the MCU runs the fan from it
[Pins] ; hardware/lattepanda-3-delta/pins-legacy.ini
yaw_cs = 8
yaw_en = 7
dht = 9
fan_pwm = 10
fan_tach = 11
spi_idle_cs = 2
```
- **The firmware is built with them only where they are declared**
(`firmware/tools/gen_wiring.py`); a tower without them gets byte-for-byte the
firmware it had.
- **The fan is the MCU's job**, and keeps running with the host down. The law
is the legacy firmware's: off up to 25 °C, +20/255 per degree, full from
37.75 °C. The fan runs **at full speed until the first good reading, and
after three failed reads in a row**.
- **What the host does:** the MCU reports every 10 s as
`ENV t=<C> h=<%> ok=<0|1> fan=<duty> rpm=<n>`. The host shows it as the
ambient sensor ("DHT11 (ambient, via MCU)"), with the fan's duty and rpm.
- **A fan driven at half speed or more that reads 0 rpm on two readings** is
logged as a warning, marked `FAN` on the Sensors panel, and fails
`test hardware env`. This needs `fan_tach`.
- **`spi_idle_cs`** holds a second SPI device's chip select high from power-up,
so it never answers on the drivers' bus.
### One key, not two: declaring a sensor is what enables it ### One key, not two: declaring a sensor is what enables it
`[Hardware] imu` and `[Hardware] env` name what is fitted. `[Features] enable_imu` `[Hardware] imu` and `[Hardware] env` name what is fitted. `[Features] enable_imu`

View File

@ -833,6 +833,18 @@ Each tower runs its own RabbitMQ, in Docker, with the `rabbitmq_mqtt` and
`rabbitmq_shovel` plugins - the shovel is what forwards this tower's messages to `rabbitmq_shovel` plugins - the shovel is what forwards this tower's messages to
the ground station. That lives in `~/zkms-client/`, outside this repository. the ground station. That lives in `~/zkms-client/`, outside this repository.
### A legacy tower (DHT11 and fan on the MCU)
Answer the installer's ambient sensor question with `dht11` and the fan
question with yes, and choose `loom = legacy`. The firmware is then built with
the DHT11 and fan (see configuration.md, "The legacy towers").
Check it **on site** after the first flash:
1. The fan runs at full speed from power-up.
2. It drops once readings come in, if the box is below 25 °C.
3. Warming the DHT11 by hand, or with a hair-dryer, spins it up.
4. `test hardware env` passes, fan leaf included.
### Phase 4 - IMU and ambient sensor ### Phase 4 - IMU and ambient sensor
Only if fitted, and declared in `hardware.ini` (`imu`, `env`) - the declaration Only if fitted, and declared in `hardware.ini` (`imu`, `env`) - the declaration

View File

@ -303,11 +303,36 @@ kv_or_todo() {
printf '%s\n' "$why" | fold -s -w 72 | sed 's/^/; /' | sed 's/[[:space:]]*$//' printf '%s\n' "$why" | fold -s -w 72 | sed 's/^/; /' | sed 's/[[:space:]]*$//'
} }
# `--pin k=v` values as a [Pins] block, or the kit's reference loom, or a TODO. # A loom file's lines, keeping only the pins of what this tower declares: a
# yaw-only tower drops the pitch lines, one without the DHT11 or fan drops
# theirs. The firmware build refuses a pin for an undeclared part, so copying a
# loom whole stopped every yaw-only install on the standard Delta loom.
kit_loom_file() {
case "$1" in
standard) echo "$KIT_DIR/pins.ini" ;;
legacy) echo "$KIT_DIR/pins-legacy.ini" ;;
*) echo "" ;;
esac
}
emit_loom() {
local file="$1" axes="$2" env="$3" cooling="$4"
awk -v axes=",${axes// /}," -v env="$env" -v cooling="$cooling" '
/^[[:space:]]*[;#]/ || /^[[:space:]]*\[/ || !index($0, "=") { print; next }
{
k = substr($0, 1, index($0, "=") - 1); gsub(/[[:space:]]/, "", k)
if (k ~ /^pitch_/ && index(axes, ",pitch,") == 0) next
if (k == "dht" && env != "dht11") next
if (k ~ /^fan_/ && cooling != "fan") next
print
}' "$file"
}
# `--pin k=v` values as a [Pins] block, or one of the kit's looms, or a TODO.
emit_pins() { emit_pins() {
if [[ $LOOM == standard ]]; then if [[ -n $LOOM ]]; then
[[ -r $KIT_DIR/pins.ini ]] || die "--loom standard: this kit has no pins.ini" local file; file="$(kit_loom_file "$LOOM")"
cat "$KIT_DIR/pins.ini" [[ -r $file ]] || die "--loom $LOOM: this kit has no $(basename "${file:-pins-$LOOM.ini}")"
emit_loom "$file" "$AXES" "$ENV_SENSOR" "$COOLING"
return return
fi fi
if (( ${#PINS[@]} > 0 )); then if (( ${#PINS[@]} > 0 )); then
@ -349,6 +374,7 @@ firmware_env = $KIT_FW_ENV
axes = $AXES axes = $AXES
imu = $IMU imu = $IMU
env = $ENV_SENSOR env = $ENV_SENSOR
cooling = $COOLING
cameras = $CAMERAS cameras = $CAMERAS
[Serial] [Serial]
@ -363,7 +389,8 @@ EOF
if [[ $IMU != none ]]; then if [[ $IMU != none ]]; then
printf '\n[IMU]\n; %s\ndevice = %s\n' "$KIT_IMU_NOTE" "${IMU_DEVICE:-$KIT_IMU_DEVICE}" printf '\n[IMU]\n; %s\ndevice = %s\n' "$KIT_IMU_NOTE" "${IMU_DEVICE:-$KIT_IMU_DEVICE}"
fi fi
if [[ $ENV_SENSOR != none ]]; then # A DHT11 is read by the motor MCU: there is no bus of the host's to name.
if [[ $ENV_SENSOR != none && $ENV_SENSOR != dht11 ]]; then
printf '\n[Env]\n' printf '\n[Env]\n'
printf '%s\n' "$KIT_ENV_NOTE" | sed 's/^/; /' printf '%s\n' "$KIT_ENV_NOTE" | sed 's/^/; /'
printf 'i2c_device = %s\ni2c_addr = 0x44\n' "${I2C_DEVICE:-${KIT_ENV_DEVICE:-SET_THIS_BY_HAND}}" printf 'i2c_device = %s\ni2c_addr = 0x44\n' "${I2C_DEVICE:-${KIT_ENV_DEVICE:-SET_THIS_BY_HAND}}"
@ -444,13 +471,15 @@ hardware/$KIT_BOARD/install.sh - set up a $KIT_BOARD tower host. Run ON the devi
What was ASSEMBLED (a probe cannot tell missing from broken): What was ASSEMBLED (a probe cannot tell missing from broken):
--axes yaw|yaw,pitch (default: $KIT_DEFAULT_AXES) --axes yaw|yaw,pitch (default: $KIT_DEFAULT_AXES)
--imu none|mti --env none|sht41 --cameras rgb|none --imu none|mti --env none|sht41|dht11 --cameras rgb|none
--cooling none|fan a fan the motor MCU runs from its DHT11 (legacy towers)
Overrides for what is otherwise probed: Overrides for what is otherwise probed:
--serial PATH the MCU's /dev/serial/by-id path --serial PATH the MCU's /dev/serial/by-id path
--camera-id DEV_... the camera's Vimba id --camera-id DEV_... the camera's Vimba id
--imu-device PATH --i2c-device PATH --imu-device PATH --i2c-device PATH
Wiring ([Pins], which the firmware is built from): Wiring ([Pins], which the firmware is built from):
--loom standard this kit's reference loom (pins.ini) --loom standard|legacy one of this kit's looms (pins.ini, pins-legacy.ini), keeping
only the lines for what is declared
--pin KEY=N one pin at a time instead (yaw_cs=1, spi_sck=2, ...) --pin KEY=N one pin at a time instead (yaw_cs=1, spi_sck=2, ...)
The drivetrain, per axis - anything not given is written as a ; TODO: The drivetrain, per axis - anything not given is written as a ; TODO:
--motor AXIS.KEY=VALUE KEY one of: ${KIT_AXIS_KEYS[*]} --motor AXIS.KEY=VALUE KEY one of: ${KIT_AXIS_KEYS[*]}
@ -463,7 +492,7 @@ EOF
} }
kit_main() { kit_main() {
AXES="$KIT_DEFAULT_AXES" IMU="none" ENV_SENSOR="none" CAMERAS="rgb" AXES="$KIT_DEFAULT_AXES" IMU="none" ENV_SENSOR="none" CAMERAS="rgb" COOLING="none"
SERIAL_DEVICE="" CAMERA_ID="" IMU_DEVICE="" I2C_DEVICE="" LOOM="" SERIAL_DEVICE="" CAMERA_ID="" IMU_DEVICE="" I2C_DEVICE="" LOOM=""
DO_DEPS=0 DO_PRE=0 DO_HW=0 DO_VIMBA=0 PROBE_ONLY=0 TO_STDOUT=0 FORCE=0 APT_UPDATE=1 DO_DEPS=0 DO_PRE=0 DO_HW=0 DO_VIMBA=0 PROBE_ONLY=0 TO_STDOUT=0 FORCE=0 APT_UPDATE=1
VIMBA_SRC="" VIMBA_SRC=""
@ -483,6 +512,7 @@ kit_main() {
--axes) AXES="$2"; shift ;; --axes) AXES="$2"; shift ;;
--imu) IMU="$2"; shift ;; --imu) IMU="$2"; shift ;;
--env) ENV_SENSOR="$2"; shift ;; --env) ENV_SENSOR="$2"; shift ;;
--cooling) COOLING="$2"; shift ;;
--cameras) CAMERAS="$2"; shift ;; --cameras) CAMERAS="$2"; shift ;;
--serial) SERIAL_DEVICE="$2"; shift ;; --serial) SERIAL_DEVICE="$2"; shift ;;
--camera-id) CAMERA_ID="$2"; shift ;; --camera-id) CAMERA_ID="$2"; shift ;;
@ -510,7 +540,11 @@ kit_main() {
shift shift
done done
[[ $explicit -eq 0 ]] && { DO_DEPS=1; DO_PRE=1; DO_HW=1; } [[ $explicit -eq 0 ]] && { DO_DEPS=1; DO_PRE=1; DO_HW=1; }
[[ -z $LOOM || $LOOM == standard ]] || die "--loom takes 'standard' (or use --pin)" [[ -z $LOOM || $LOOM == standard || $LOOM == legacy ]] ||
die "--loom takes 'standard' or 'legacy' (or use --pin)"
[[ $COOLING == none || $COOLING == fan ]] || die "--cooling takes 'none' or 'fan'"
[[ $COOLING == none || $ENV_SENSOR == dht11 ]] ||
die "--cooling fan needs --env dht11: the fan runs from the DHT11 on the MCU"
[[ -n $LOOM && ${#PINS[@]} -gt 0 ]] && die "--loom and --pin are alternatives; give one" [[ -n $LOOM && ${#PINS[@]} -gt 0 ]] && die "--loom and --pin are alternatives; give one"
local first key local first key

View File

@ -53,6 +53,16 @@ In particular:
not copy them to another tower without reading that tower's boards and not copy them to another tower without reading that tower's boards and
firmware revision the same way. firmware revision the same way.
## The legacy loom (`pins-legacy.ini`)
For the four legacy towers: yaw only, with a DHT11 (pin 9) and the enclosure fan
it runs (PWM 10, tach 11) on the Leonardo, and a second SPI device's chip select
(pin 2) held high. The pins are transcribed from the legacy firmware (a TMC-API
Arduino sketch); the yaw pins match the standard loom. Declare it with
`env = dht11` and `cooling = fan`. The installer offers it as `loom = legacy`,
and keeps only the lines for what is declared. Not yet run on a real legacy
tower.
## Migrating an existing Delta tower ## Migrating an existing Delta tower
Host first, firmware second (`docs/hardware-config-plan.md` section 13). The Host first, firmware second (`docs/hardware-config-plan.md` section 13). The

View File

@ -0,0 +1,18 @@
; The legacy loom for a LattePanda 3 Delta tower: the ATmega32U4 (Leonardo) ->
; one TMC5160 driver board (yaw only), plus parts wired to the MCU itself - an
; ambient DHT11 and the enclosure fan it runs, and a second SPI device's chip
; select that must be held high.
;
; Transcribed from the legacy firmware these towers ran (a TMC-API Arduino
; sketch): chipCS 8, enable 7, DHTPIN 9, FAN_PWM 10, FAN_SENS 11, chipCS_enc 2.
; The yaw pins are the same as the standard loom's.
;
; Declares with it, in [Hardware]: axes = yaw, env = dht11, cooling = fan.
; The installer keeps only the lines for what is declared.
[Pins]
yaw_cs = 8
yaw_en = 7
dht = 9
fan_pwm = 10
fan_tach = 11
spi_idle_cs = 2

View File

@ -39,7 +39,10 @@ struct HardwareConfig {
// This is the state every existing tower is in until it gets a hardware.ini. // This is the state every existing tower is in until it gets a hardware.ini.
std::vector<std::string> axes; // e.g. {"yaw", "pitch"} std::vector<std::string> axes; // e.g. {"yaw", "pitch"}
std::string imu; // sensor model, or "none" std::string imu; // sensor model, or "none"
std::string env; // sensor model, or "none" std::string env; // sensor model, or "none"; dht11 = on the motor MCU
// "fan": the motor MCU runs an enclosure fan from its DHT11 (legacy towers).
// Needs env = dht11. Empty or "none": no fan.
std::string cooling;
std::vector<std::string> cameras; // the [Camera.<name>] blocks expected to exist std::vector<std::string> cameras; // the [Camera.<name>] blocks expected to exist
// How much bearing one picture covers, in degrees. Pan mode steps by it: // How much bearing one picture covers, in degrees. Pan mode steps by it:
// a span is cut into as many pictures as it takes (OperatingMode.h). // a span is cut into as many pictures as it takes (OperatingMode.h).
@ -59,6 +62,8 @@ struct HardwareConfig {
struct Pins { struct Pins {
long yaw_cs = -1, yaw_en = -1, pitch_cs = -1, pitch_en = -1; long yaw_cs = -1, yaw_en = -1, pitch_cs = -1, pitch_en = -1;
long spi_sck = -1, spi_mosi = -1, spi_miso = -1; // RP2040 only long spi_sck = -1, spi_mosi = -1, spi_miso = -1; // RP2040 only
// Parts on the MCU itself (legacy towers). -1 = not wired.
long dht = -1, fan_pwm = -1, fan_tach = -1, spi_idle_cs = -1;
bool declared() const { return yaw_cs >= 0 || yaw_en >= 0 || pitch_cs >= 0 || bool declared() const { return yaw_cs >= 0 || yaw_en >= 0 || pitch_cs >= 0 ||
pitch_en >= 0 || spi_sck >= 0; } pitch_en >= 0 || spi_sck >= 0; }
} pins; } pins;

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@ -42,6 +43,17 @@ struct AxisTelemetry {
}; };
// Telemetry snapshot from the motor controller: one segment per axis. // Telemetry snapshot from the motor controller: one segment per axis.
// One "ENV t=<C> h=<%> ok=<0|1> fan=<duty> rpm=<n>" line from the firmware: the
// DHT11 on the MCU and the enclosure fan it runs (see firmware src/cooling.h).
struct McuEnvReading {
int temp_c = 0;
int humidity = 0;
bool sensor_ok = false; // the last read had a good checksum
int fan_duty = 0; // 0-255 as driven; 255 is also the failsafe
int fan_rpm = 0; // 0 with no tach wired, or a stopped fan
long long epoch_ms = 0; // when the host received it
};
struct MotorTelemetry { struct MotorTelemetry {
AxisTelemetry yaw; AxisTelemetry yaw;
AxisTelemetry pitch; AxisTelemetry pitch;
@ -85,6 +97,11 @@ public:
// firmware to ask, and a null implementation would have nothing to say. // firmware to ask, and a null implementation would have nothing to say.
virtual std::vector<std::string> takeNeedLines() { return {}; } virtual std::vector<std::string> takeNeedLines() { return {}; }
// The latest reading of an ambient DHT11 on the motor MCU and the fan it
// drives (firmware "ENV" lines, every 10 s), or nothing on a tower without
// one. Thread-safe in implementations. Default: none.
virtual std::optional<McuEnvReading> latestEnv() const { return std::nullopt; }
// Whether the underlying link is usable. // Whether the underlying link is usable.
virtual bool connected() const = 0; virtual bool connected() const = 0;
}; };

View File

@ -25,6 +25,7 @@ public:
std::string lastDiag() override; std::string lastDiag() override;
unsigned diagSeq() const override; unsigned diagSeq() const override;
std::vector<std::string> takeNeedLines() override; std::vector<std::string> takeNeedLines() override;
std::optional<McuEnvReading> latestEnv() const override;
bool connected() const override; bool connected() const override;
private: private:

View File

@ -22,4 +22,8 @@ std::optional<MotorTelemetry> parseTelemetryLine(const std::string& line);
// Parse a single "Y:..." / "P:..." axis segment. Exposed for unit testing. // Parse a single "Y:..." / "P:..." axis segment. Exposed for unit testing.
std::optional<AxisTelemetry> parseAxisSegment(const std::string& seg); std::optional<AxisTelemetry> parseAxisSegment(const std::string& seg);
// "ENV t=23 h=45 ok=1 fan=0 rpm=0" -> a reading (epoch_ms left 0). Keys in any
// order; t, h and ok required, fan and rpm optional. Nothing for anything else.
std::optional<McuEnvReading> parseEnvLine(const std::string& line);
} // namespace fgc } // namespace fgc

View File

@ -0,0 +1,43 @@
#pragma once
#include "fgc/IEnvSensor.h"
#include "fgc/IMotorController.h"
#include <functional>
namespace fgc {
// A fan driven at half speed or more whose tach reads nothing: dead, jammed or
// unplugged. Only meaningful where a tach is wired - without one rpm is always 0.
inline bool fanLooksStalled(const McuEnvReading& r) {
return r.fan_duty >= 128 && r.fan_rpm == 0;
}
// An ambient DHT11 wired to the motor MCU ([Hardware] env = dht11) - the legacy
// towers' sensor. The firmware reads it every 10 s and prints an ENV line; this
// serves the latest one through the ordinary IEnvSensor interface, so the
// sensors panel, the log and `test hardware env` treat it like any other.
//
// No reading when the firmware's last read failed its checksum, or when no ENV
// line has arrived for 2.5 read periods - a firmware built without the sensor,
// or a link that has gone quiet.
class McuEnvSensor : public IEnvSensor {
public:
static constexpr long long kStaleMs = 25000;
explicit McuEnvSensor(IMotorController& motor, std::function<long long()> now_ms = {});
void start() override {}
void stop() override {}
bool connected() const override;
std::optional<EnvSample> sample() override;
std::string name() const override { return "DHT11"; }
private:
std::optional<EnvSample> current() const;
IMotorController& motor_;
std::function<long long()> now_ms_;
};
} // namespace fgc

View File

@ -95,6 +95,9 @@ struct SensorsView {
// ImuConfigView::problems). Flagged on the compact panel too, because the // ImuConfigView::problems). Flagged on the compact panel too, because the
// readings above are wrong in a way that reading them cannot reveal. // readings above are wrong in a way that reading them cannot reveal.
bool imu_config_alert = false; bool imu_config_alert = false;
// An enclosure fan driven hard but not turning (legacy towers). Flagged here
// too: it is the fault that stays invisible until the box overheats.
bool fan_alert = false;
}; };
// One auto-sweep scan-grid waypoint, for the expanded camera view. // One auto-sweep scan-grid waypoint, for the expanded camera view.
@ -264,6 +267,14 @@ struct EnvView {
std::string i2c_device; // from EnvConfig, for the bus row std::string i2c_device; // from EnvConfig, for the bus row
unsigned i2c_addr = 0; unsigned i2c_addr = 0;
int period_ms = 0; int period_ms = 0;
// A DHT11 on the motor MCU (legacy towers) - no bus of our own to show - and
// the enclosure fan the MCU runs from it.
bool on_mcu = false;
bool fan = false; // [Hardware] cooling = fan
bool fan_tach = false; // a tach is wired, so rpm means something
int fan_duty = 0; // 0-255
int fan_rpm = 0;
bool fan_stalled = false; // driven, but the tach reads nothing
}; };
// Status of the currently-running special operation (calibration, diagnostics, // Status of the currently-running special operation (calibration, diagnostics,

View File

@ -202,7 +202,7 @@ firmware_report() {
[[ -n $l ]] && grep -q '^DUMP BEGIN' "$l" && break [[ -n $l ]] && grep -q '^DUMP BEGIN' "$l" && break
sleep 1; l="$(latest_log)" sleep 1; l="$(latest_log)"
done done
[[ -n $l ]] && text="$(grep -E '^DUMP (BEGIN|[YP] state)' "$l" | tail -3)" [[ -n $l ]] && text="$(grep -E '^DUMP (BEGIN|[YP] state|ENV )' "$l" | tail -4)"
else else
local py port; py="$(py_serial)" || return 0 local py port; py="$(py_serial)" || return 0
port="$(ini_get "$HW" Serial device 2>/dev/null || true)" port="$(ini_get "$HW" Serial device 2>/dev/null || true)"
@ -215,7 +215,7 @@ time.sleep(1.5); s.reset_input_buffer(); s.write(b"DUMP\r\n")
end = time.time() + 3 end = time.time() + 3
while time.time() < end: while time.time() < end:
l = s.readline().decode(errors="replace").strip() l = s.readline().decode(errors="replace").strip()
if l.startswith("DUMP BEGIN") or (l.startswith("DUMP ") and " state=" in l): print(l) if l.startswith("DUMP BEGIN") or l.startswith("DUMP ENV ") or (l.startswith("DUMP ") and " state=" in l): print(l)
if l.startswith("DUMP END"): break if l.startswith("DUMP END"): break
PYEOF PYEOF
)" )"
@ -226,17 +226,27 @@ PYEOF
spi="$(sed -n 's/.*DUMP BEGIN.* spi=\([0-9,]*\).*/\1/p' <<< "$text" | tail -1)" spi="$(sed -n 's/.*DUMP BEGIN.* spi=\([0-9,]*\).*/\1/p' <<< "$text" | tail -1)"
y="$(sed -n 's/^DUMP Y .* cs=\([0-9]*\) en=\([0-9]*\).*/\1,\2/p' <<< "$text" | tail -1)" y="$(sed -n 's/^DUMP Y .* cs=\([0-9]*\) en=\([0-9]*\).*/\1,\2/p' <<< "$text" | tail -1)"
p="$(sed -n 's/^DUMP P .* cs=\([0-9]*\) en=\([0-9]*\).*/\1,\2/p' <<< "$text" | tail -1)" p="$(sed -n 's/^DUMP P .* cs=\([0-9]*\) en=\([0-9]*\).*/\1,\2/p' <<< "$text" | tail -1)"
echo "build=${build:-?} spi=${spi:--} Y=${y:--} P=${p:--}" # The parts on the MCU itself (legacy towers): dht,fan_pwm,fan_tach, - when absent.
local e="" ln; ln="$(grep '^DUMP ENV ' <<< "$text" | tail -1)"
if [[ -n $ln ]]; then
local d f t
d="$(sed -n 's/.* dht=\([0-9]*\).*/\1/p' <<< "$ln")"
f="$(sed -n 's/.* fan_pwm=\([0-9]*\).*/\1/p' <<< "$ln")"
t="$(sed -n 's/.* fan_tach=\([0-9]*\).*/\1/p' <<< "$ln")"
e="${d:--},${f:--},${t:--}"
fi
echo "build=${build:-?} spi=${spi:--} Y=${y:--} P=${p:--} E=${e:--}"
} }
# Does what the firmware reports match what hardware.ini declares? Only the # Does what the firmware reports match what hardware.ini declares? Every field
# declared fields count, as in `test hardware motor`: a Leonardo declares no SPI # counts - an axis or a DHT11 the firmware has and the file does not, or the
# pins (they are fixed), so whatever an older build reports for them is moot. # other way round, needs a rebuild - except undeclared SPI pins: a Leonardo
# declares none (they are fixed), so whatever a build reports for them is moot.
pins_match() { pins_match() {
local now="$1" want="$2" f v local now="$1" want="$2" f
[[ -n $now ]] || return 1 [[ -n $now ]] || return 1
for f in $want; do for f in $want; do
v="${f#*=}"; [[ $v == - ]] && continue [[ $f == spi=- ]] && continue
[[ " $now " == *" $f "* ]] || return 1 [[ " $now " == *" $f "* ]] || return 1
done done
} }
@ -252,7 +262,13 @@ declared_pins() {
[[ -n $a ]] && y="$a,$b" [[ -n $a ]] && y="$a,$b"
a="$(ini_get "$HW" Pins pitch_cs 2>/dev/null || true)"; b="$(ini_get "$HW" Pins pitch_en 2>/dev/null || true)" a="$(ini_get "$HW" Pins pitch_cs 2>/dev/null || true)"; b="$(ini_get "$HW" Pins pitch_en 2>/dev/null || true)"
[[ -n $a ]] && p="$a,$b" [[ -n $a ]] && p="$a,$b"
echo "spi=$spi Y=$y P=$p" local e="-"
a="$(ini_get "$HW" Pins dht 2>/dev/null || true)"
if [[ -n $a ]]; then
b="$(ini_get "$HW" Pins fan_pwm 2>/dev/null || true)"; c="$(ini_get "$HW" Pins fan_tach 2>/dev/null || true)"
e="$a,${b:--},${c:--}"
fi
echo "spi=$spi Y=$y P=$p E=$e"
} }
# Each gap in hardware.ini, named with its section: "[Motor.pitch] sw_mode". # Each gap in hardware.ini, named with its section: "[Motor.pitch] sw_mode".
@ -321,13 +337,26 @@ run_test() {
# The app holds the serial port, so a test runs INSIDE it when it is up, and # The app holds the serial port, so a test runs INSIDE it when it is up, and
# as a one-shot run of the binary when it is not. # as a one-shot run of the binary when it is not.
if app_running; then if app_running; then
local pane before after local pane before after restore=""
pane="$(tmux list-panes -t "=${FGC_SESSION:-fgc}" -F '#{pane_id}' | head -1)" pane="$(tmux list-panes -t "=${FGC_SESSION:-fgc}" -F '#{pane_id}' | head -1)"
before="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)" before="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)"
app_cmd() { # one command line into the running program, dashboard or console
if tmux capture-pane -p -t "$pane" | grep -q '╭'; then if tmux capture-pane -p -t "$pane" | grep -q '╭'; then
tmux send-keys -t "$pane" ':'; sleep 0.5 tmux send-keys -t "$pane" ':'; sleep 0.5
fi fi
tmux send-keys -t "$pane" "test $sel" Enter tmux send-keys -t "$pane" -l "$1"; tmux send-keys -t "$pane" Enter
}
# Tests run in manual mode only - auto and pan refuse them. Note the mode,
# switch for the test, and put it back afterwards, pan's arguments and all.
app_cmd "mode"; sleep 1
local m; m="$(grep -oE '\] mode (auto|manual|pan: heading [^,]+, span [^,]+, pitch [^ ]+)' \
"$(latest_log)" 2>/dev/null | tail -1 | sed 's/^\] //')"
case "$m" in
"mode auto") restore="mode auto" ;;
"mode pan: "*) restore="$(sed -E 's/mode pan: heading ([^,]+), span ([^,]+), pitch (.+)/mode pan \1 \2 \3/' <<< "$m")" ;;
esac
if [[ -n $restore ]]; then say "switching to manual for the test (was: $restore)"; app_cmd "mode manual"; sleep 2; fi
app_cmd "test $sel"
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
after="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)" after="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)"
[[ -n $after && $after != "$before" ]] && break [[ -n $after && $after != "$before" ]] && break
@ -340,10 +369,13 @@ run_test() {
else else
warn "no test report appeared" warn "no test report appeared"
fi fi
TEST_RC=5; return TEST_RC=5
[[ -n $restore ]] && app_cmd "$restore"
return
fi fi
grep -E '^\[| ; ' "$after" | sed 's/^/ /' grep -E '^\[| ; ' "$after" | sed 's/^/ /'
grep -q '^result = PASS' "$after" && TEST_RC=0 || TEST_RC=4 grep -q '^result = PASS' "$after" && TEST_RC=0 || TEST_RC=4
if [[ -n $restore ]]; then sleep 2; app_cmd "$restore"; say "back to: $restore"; fi
else else
local out="$WORK/test.out" local out="$WORK/test.out"
TEST_RC=0 TEST_RC=0
@ -452,13 +484,31 @@ declare -a HW_SETS=() # key edits, when there is one
declare -a HW_FILLS=() # "axis key value": a ; TODO answered now, filled in place declare -a HW_FILLS=() # "axis key value": a ; TODO answered now, filled in place
TODO_LEFT=0 TODO_LEFT=0
HW_APPEND_PINS=0 HW_APPEND_PINS=0
# Sets LOOM_A to the loom chosen (standard / legacy), or "" to give each pin.
# Returns 0 when a loom was chosen. Only the lines for what the tower declares
# are kept, so a yaw-only tower takes the standard loom without its pitch pins.
LOOM_A=""
choose_pins() { choose_pins() {
local names="standard" n
[[ -r $KIT/pins-legacy.ini ]] && names+=" legacy"
echo echo
echo "Wiring ([Pins]) - the firmware is built from it, and a chip-select on the" echo "Wiring ([Pins]) - the firmware is built from it, and a chip-select on the"
echo "wrong pin writes into a driver that was never meant to hear it. The standard" echo "wrong pin writes into a driver that was never meant to hear it. This board's looms:"
echo "loom for this board is hardware/$S_BOARD/pins.ini:" for n in $names; do
sed -n '/^\[Pins\]/,$p' "$KIT/pins.ini" | sed 's/^/ /' echo " $n (hardware/$S_BOARD/$(basename "$(kit_loom_file "$n")")):"
ask_yn loom "Is this tower wired exactly to that loom?" n emit_loom "$(kit_loom_file "$n")" "${AXES_A:-$declared_axes_now}" "${ENV_A:-none}" \
"${COOLING_A:-none}" | sed -n '/^\[Pins\]/,$p' | sed 's/^/ /'
done
while :; do
ask loom "Is this tower wired exactly to one of them? (${names// /, }, or n to give each pin)" n
case "${REPLY_VAL,,}" in
y|yes|standard) LOOM_A=standard; return 0 ;;
legacy) [[ $names == *legacy* ]] && { LOOM_A=legacy; return 0; } ;;
n|no) LOOM_A=""; return 1 ;;
esac
warn "'$REPLY_VAL' is not one of: ${names// /, }, n"; unset "ANS[loom]"
[[ $NONINTERACTIVE -eq 1 ]] && die "bad value for 'loom'"
done
} }
if [[ ! -r $HW ]]; then if [[ ! -r $HW ]]; then
echo echo
@ -467,9 +517,16 @@ if [[ ! -r $HW ]]; then
ask axes "Axes fitted (yaw, or yaw,pitch)" "$KIT_DEFAULT_AXES"; AXES_A="${REPLY_VAL// /}" ask axes "Axes fitted (yaw, or yaw,pitch)" "$KIT_DEFAULT_AXES"; AXES_A="${REPLY_VAL// /}"
[[ $AXES_A == yaw || $AXES_A == yaw,pitch ]] || die "axes must be 'yaw' or 'yaw,pitch' (the firmware's axis 0 is yaw)" [[ $AXES_A == yaw || $AXES_A == yaw,pitch ]] || die "axes must be 'yaw' or 'yaw,pitch' (the firmware's axis 0 is yaw)"
ask imu "IMU model, or none" none; IMU_A="$REPLY_VAL" ask imu "IMU model, or none" none; IMU_A="$REPLY_VAL"
ask env "Ambient sensor model, or none" none; ENV_A="$REPLY_VAL" ask env "Ambient sensor model (sht41 on the host's I2C, dht11 on the motor MCU), or none" none
ENV_A="${REPLY_VAL,,}"
COOLING_A=none
if [[ $ENV_A == dht11 ]]; then
explain "the legacy towers: the motor MCU reads the DHT11 and runs the enclosure fan from it - off to 25 C, full from 37.75 C, full whenever the sensor cannot be read"
ask_yn fan "Does the motor MCU run an enclosure fan from it?" y && COOLING_A=fan
fi
ask cameras "Cameras (rgb, or none)" "$([[ $HAVE_VIMBA -eq 1 ]] && echo rgb || echo none)"; CAMS_A="$REPLY_VAL" ask cameras "Cameras (rgb, or none)" "$([[ $HAVE_VIMBA -eq 1 ]] && echo rgb || echo none)"; CAMS_A="$REPLY_VAL"
KIT_ARGS=(--stdout --axes "$AXES_A" --imu "$IMU_A" --env "$ENV_A" --cameras "$CAMS_A") KIT_ARGS=(--stdout --axes "$AXES_A" --imu "$IMU_A" --env "$ENV_A" --cooling "$COOLING_A"
--cameras "$CAMS_A")
if (( S_MCU_N > 1 )); then if (( S_MCU_N > 1 )); then
echo "Several MCU candidates:"; nl -w2 -s') ' <<< "$S_MCU" echo "Several MCU candidates:"; nl -w2 -s') ' <<< "$S_MCU"
ask serial "Which is the motor MCU (full path)" ask serial "Which is the motor MCU (full path)"
@ -483,15 +540,17 @@ if [[ ! -r $HW ]]; then
KIT_ARGS+=(--camera-id "$REPLY_VAL") KIT_ARGS+=(--camera-id "$REPLY_VAL")
fi fi
[[ $IMU_A != none ]] && { ask imu_device "IMU serial device" "$KIT_IMU_DEVICE"; KIT_ARGS+=(--imu-device "$REPLY_VAL"); } [[ $IMU_A != none ]] && { ask imu_device "IMU serial device" "$KIT_IMU_DEVICE"; KIT_ARGS+=(--imu-device "$REPLY_VAL"); }
if [[ $ENV_A != none ]]; then if [[ $ENV_A != none && $ENV_A != dht11 ]]; then
ask i2c_device "Ambient sensor I2C bus (check it with i2cdetect -F)" "$KIT_ENV_DEVICE" ask i2c_device "Ambient sensor I2C bus (check it with i2cdetect -F)" "$KIT_ENV_DEVICE"
KIT_ARGS+=(--i2c-device "$REPLY_VAL") KIT_ARGS+=(--i2c-device "$REPLY_VAL")
fi fi
if choose_pins; then KIT_ARGS+=(--loom standard) if choose_pins; then KIT_ARGS+=(--loom "$LOOM_A")
else else
echo "Then give each pin (GPn number):" echo "Then give each pin (GPn number):"
keys=(); [[ $KIT_FW_ENV == *rp2040* ]] && keys+=(spi_sck spi_mosi spi_miso) keys=(); [[ $KIT_FW_ENV == *rp2040* ]] && keys+=(spi_sck spi_mosi spi_miso)
for ax in ${AXES_A//,/ }; do keys+=("${ax}_cs" "${ax}_en"); done for ax in ${AXES_A//,/ }; do keys+=("${ax}_cs" "${ax}_en"); done
[[ $ENV_A == dht11 ]] && keys+=(dht)
[[ $COOLING_A == fan ]] && keys+=(fan_pwm fan_tach)
for k in "${keys[@]}"; do ask "pin.$k" " $k"; KIT_ARGS+=(--pin "$k=$REPLY_VAL"); done for k in "${keys[@]}"; do ask "pin.$k" " $k"; KIT_ARGS+=(--pin "$k=$REPLY_VAL"); done
fi fi
echo echo
@ -521,6 +580,7 @@ else
# An existing file is the tower's record: never regenerated, only topped up. # An existing file is the tower's record: never regenerated, only topped up.
if ! grep -q '^\[Pins\]' "$HW"; then if ! grep -q '^\[Pins\]' "$HW"; then
echo; echo "hardware.ini has no [Pins], and the firmware cannot be built without it." echo; echo "hardware.ini has no [Pins], and the firmware cannot be built without it."
declared_axes_now="$(ini_get "$HW" Hardware axes 2>/dev/null | tr -d ' ' || echo yaw)"
choose_pins && HW_APPEND_PINS=1 || choose_pins && HW_APPEND_PINS=1 ||
warn "add [Pins] to $HW by hand (the format is in config/hardware.example.ini)" warn "add [Pins] to $HW by hand (the format is in config/hardware.example.ini)"
fi fi
@ -664,7 +724,7 @@ else
grep -q '^\[Pins\]' "$HW" || [[ $HW_APPEND_PINS -eq 1 ]] || todo_n=$((todo_n + 1)) grep -q '^\[Pins\]' "$HW" || [[ $HW_APPEND_PINS -eq 1 ]] || todo_n=$((todo_n + 1))
fi fi
(( todo_n > 0 )) && FW_BLOCK="hardware.ini still has $todo_n gap(s): the firmware would sit in NEEDCFG, or not build" (( todo_n > 0 )) && FW_BLOCK="hardware.ini still has $todo_n gap(s): the firmware would sit in NEEDCFG, or not build"
want_pins="$( (HW="$HW_VIEW"; [[ $HW_APPEND_PINS -eq 1 ]] && { cat "$HW" "$KIT/pins.ini" > "$WORK/hw_pins.ini"; HW="$WORK/hw_pins.ini"; }; declared_pins) )" want_pins="$( (HW="$HW_VIEW"; [[ $HW_APPEND_PINS -eq 1 ]] && { cat "$HW" "$(kit_loom_file "$LOOM_A")" > "$WORK/hw_pins.ini"; HW="$WORK/hw_pins.ini"; }; declared_pins) )"
if [[ -n $FW_BLOCK ]]; then plan firmware blocked "$FW_BLOCK" if [[ -n $FW_BLOCK ]]; then plan firmware blocked "$FW_BLOCK"
elif [[ -n $S_FW_NOW && ${S_FW_NOW%% *} == "build=$S_FW_REV" ]] && pins_match "$S_FW_NOW" "$want_pins"; then elif [[ -n $S_FW_NOW && ${S_FW_NOW%% *} == "build=$S_FW_REV" ]] && pins_match "$S_FW_NOW" "$want_pins"; then
plan firmware skip "running $S_FW_REV, built with the declared pins" plan firmware skip "running $S_FW_REV, built with the declared pins"
@ -765,7 +825,10 @@ elif [[ ${DO[hardware.ini]} == change ]]; then
hr hardware.ini hr hardware.ini
sudo cp -p "$HW" "$HW.bak-$(date +%Y%m%d-%H%M%S)" sudo cp -p "$HW" "$HW.bak-$(date +%Y%m%d-%H%M%S)"
if [[ $HW_APPEND_PINS -eq 1 ]]; then if [[ $HW_APPEND_PINS -eq 1 ]]; then
{ printf '\n'; cat "$KIT/pins.ini"; } | sudo tee -a "$HW" > /dev/null; say "added [Pins]" { printf '\n'; emit_loom "$(kit_loom_file "$LOOM_A")" "$declared_axes_now" \
"$(ini_get "$HW" Hardware env 2>/dev/null || echo none)" \
"$(ini_get "$HW" Hardware cooling 2>/dev/null || echo none)"; } |
sudo tee -a "$HW" > /dev/null; say "added [Pins]"
fi fi
if (( ${#HW_FILLS[@]} )); then if (( ${#HW_FILLS[@]} )); then
# In place of the ; TODO and its explanation, so the value sits where the # In place of the ; TODO and its explanation, so the value sits where the

View File

@ -34,6 +34,7 @@
#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/McuEnvSensor.h"
#include "fgc/sensors/Sht41EnvSensor.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"
@ -282,6 +283,9 @@ struct Application::Impl {
std::atomic<bool> imu_pitch_warned_{false}; std::atomic<bool> imu_pitch_warned_{false};
// The TUI heading wizard is aiming by hand; blocks a scan from starting. // The TUI heading wizard is aiming by hand; blocks a scan from starting.
bool heading_setup_active_ = false; bool heading_setup_active_ = false;
long long fan_seen_ms_ = 0; // last ENV reading checkFan() judged
int fan_stall_count_ = 0; // consecutive stalled readings
bool fan_stalled_ = false;
std::vector<HeadingSighting> heading_marks_; // `gimbal heading mark`s this setup std::vector<HeadingSighting> heading_marks_; // `gimbal heading mark`s this setup
std::atomic<bool> running{true}; std::atomic<bool> running{true};
@ -363,6 +367,9 @@ struct Application::Impl {
return nullptr; // sensors panel stays "pending" return nullptr; // sensors panel stays "pending"
if (force_mock || cfg.features.mock_env) if (force_mock || cfg.features.mock_env)
return std::make_unique<MockEnvSensor>(std::chrono::milliseconds(cfg.env.period_ms)); return std::make_unique<MockEnvSensor>(std::chrono::milliseconds(cfg.env.period_ms));
// The legacy towers' DHT11 hangs off the motor MCU, which reads it and
// reports each reading on the serial link - there is no bus here to open.
if (cfg.hardware.env == "dht11") return std::make_unique<McuEnvSensor>(*motor);
if (cfg.env.i2c_device.empty()) { if (cfg.env.i2c_device.empty()) {
LOG_WARN << "Env sensor enabled but [Env] i2c_device is empty; disabling"; LOG_WARN << "Env sensor enabled but [Env] i2c_device is empty; disabling";
return nullptr; return nullptr;
@ -823,6 +830,25 @@ struct Application::Impl {
s.env.i2c_device = cfg.env.i2c_device; s.env.i2c_device = cfg.env.i2c_device;
s.env.i2c_addr = cfg.env.i2c_addr; s.env.i2c_addr = cfg.env.i2c_addr;
s.env.period_ms = cfg.env.period_ms; s.env.period_ms = cfg.env.period_ms;
if (cfg.hardware.env == "dht11") {
s.env.on_mcu = true;
s.env.period_ms = 10000; // the firmware's own read period
}
s.env.fan = cfg.hardware.cooling == "fan";
s.env.fan_tach = cfg.hardware.pins.fan_tach >= 0;
if (auto r = motor ? motor->latestEnv() : std::nullopt) {
s.env.fan_duty = r->fan_duty;
s.env.fan_rpm = r->fan_rpm;
}
s.env.fan_stalled = fan_stalled_;
s.sensors.env.title = env->name() + (s.env.on_mcu ? " (ambient, via MCU)" : " (ambient)");
s.sensors.fan_alert = fan_stalled_;
if (s.env.fan) {
// The duty as a percentage, and the rpm where a tach says what it is.
std::string v = std::to_string(s.env.fan_duty * 100 / 255) + "%";
if (s.env.fan_tach) v += " " + std::to_string(s.env.fan_rpm) + " rpm";
s.sensors.env.fields.push_back({"Fan", v, "", true});
}
if (auto m = env->sample()) { if (auto m = env->sample()) {
s.env.present = true; s.env.present = true;
s.env.temp_c = m->temp_c; s.env.temp_c = m->temp_c;
@ -1057,6 +1083,25 @@ struct Application::Impl {
// Warn (once per excursion) when an axis's live encoder error |xactual-xenc| // Warn (once per excursion) when an axis's live encoder error |xactual-xenc|
// crosses the configured limit, so positional slip is visible in the log // crosses the configured limit, so positional slip is visible in the log
// without watching the gimbal view. Hysteresis re-arms at 75% of the limit. // without watching the gimbal view. Hysteresis re-arms at 75% of the limit.
// A legacy tower's enclosure fan, driven hard but not turning, on two
// readings in a row (20 s): said once, and again when it recovers. Nothing
// on a tower without a fan, or without a tach to tell.
void checkFan() {
if (cfg.hardware.cooling != "fan" || cfg.hardware.pins.fan_tach < 0) return;
const auto r = motor->latestEnv();
if (!r || r->epoch_ms == fan_seen_ms_) return;
fan_seen_ms_ = r->epoch_ms;
fan_stall_count_ = fanLooksStalled(*r) ? fan_stall_count_ + 1 : 0;
const bool stalled = fan_stall_count_ >= 2;
if (stalled && !fan_stalled_)
LOG_WARN << "enclosure fan driven at " << r->fan_duty
<< "/255 but its tach reads 0 rpm - dead, jammed or unplugged; the box "
"is not being cooled (" << r->temp_c << " C inside)";
else if (!stalled && fan_stalled_)
LOG_INFO << "enclosure fan turning again (" << r->fan_rpm << " rpm)";
fan_stalled_ = stalled;
}
void checkTrackingError() { void checkTrackingError() {
const long limit = cfg.enc_error_warn_counts; const long limit = cfg.enc_error_warn_counts;
if (limit <= 0) return; // disabled if (limit <= 0) return; // disabled
@ -2396,6 +2441,11 @@ struct Application::Impl {
resume_pan_after_test_ = scheduler->pan(); resume_pan_after_test_ = scheduler->pan();
if (resume_mode_after_test_ != Mode::Manual) if (resume_mode_after_test_ != Mode::Manual)
setMode(Mode::Manual); // free the serial link; don't fight the routine's moves setMode(Mode::Manual); // free the serial link; don't fight the routine's moves
// Manual mode stops the camera's stream, and `test camera` counts the
// frames it delivers: without this, every test run in manual - which is
// every typed one - failed "the camera delivers whole frames" on a camera
// that was fine. Left running afterwards, as a snapshot leaves it.
if (camera) camera->start();
LOG_INFO << "starting hardware test (profile '" << profile.name << "')"; LOG_INFO << "starting hardware test (profile '" << profile.name << "')";
// What `test hardware` needs beyond the motor and IMU: the declaration to // What `test hardware` needs beyond the motor and IMU: the declaration to
// check against, and the subsystems that answer for themselves. // check against, and the subsystems that answer for themselves.
@ -2990,6 +3040,7 @@ struct Application::Impl {
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
scheduler->tick(); scheduler->tick();
checkTrackingError(); // warn on live encoder-error excursions checkTrackingError(); // warn on live encoder-error excursions
checkFan(); // a legacy tower's fan, driven but not turning
publishSnapshot(); publishSnapshot();
std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::this_thread::sleep_for(std::chrono::milliseconds(10));
} }

View File

@ -246,6 +246,16 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.hardware.cameras = splitList(get(kv, "Hardware.cameras")); cfg.hardware.cameras = splitList(get(kv, "Hardware.cameras"));
cfg.hardware.imu = lowered(get(kv, "Hardware.imu")); cfg.hardware.imu = lowered(get(kv, "Hardware.imu"));
cfg.hardware.env = lowered(get(kv, "Hardware.env")); cfg.hardware.env = lowered(get(kv, "Hardware.env"));
cfg.hardware.cooling = lowered(get(kv, "Hardware.cooling"));
// The same rules the firmware build applies (firmware/tools/gen_wiring.py):
// the fan is run from the DHT11's temperature, on the MCU.
if (!cfg.hardware.cooling.empty() && cfg.hardware.cooling != "none" &&
cfg.hardware.cooling != "fan")
throw std::runtime_error("Hardware.cooling must be 'none' or 'fan' (got '" +
cfg.hardware.cooling + "')");
if (cfg.hardware.cooling == "fan" && cfg.hardware.env != "dht11")
throw std::runtime_error("Hardware.cooling = fan needs Hardware.env = dht11: the fan "
"is driven from the temperature the motor MCU reads");
{ {
auto& p = cfg.hardware.pins; auto& p = cfg.hardware.pins;
p.yaw_cs = getInt(kv, "Pins.yaw_cs", -1); p.yaw_cs = getInt(kv, "Pins.yaw_cs", -1);
@ -255,6 +265,10 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
p.spi_sck = getInt(kv, "Pins.spi_sck", -1); p.spi_sck = getInt(kv, "Pins.spi_sck", -1);
p.spi_mosi = getInt(kv, "Pins.spi_mosi", -1); p.spi_mosi = getInt(kv, "Pins.spi_mosi", -1);
p.spi_miso = getInt(kv, "Pins.spi_miso", -1); p.spi_miso = getInt(kv, "Pins.spi_miso", -1);
p.dht = getInt(kv, "Pins.dht", -1);
p.fan_pwm = getInt(kv, "Pins.fan_pwm", -1);
p.fan_tach = getInt(kv, "Pins.fan_tach", -1);
p.spi_idle_cs = getInt(kv, "Pins.spi_idle_cs", -1);
} }
if (kv.count("Hardware.pan_step_deg")) { if (kv.count("Hardware.pan_step_deg")) {
cfg.hardware.pan_step_deg = getDouble(kv, "Hardware.pan_step_deg", 0.0); cfg.hardware.pan_step_deg = getDouble(kv, "Hardware.pan_step_deg", 0.0);

View File

@ -87,9 +87,12 @@ PresenceReport reconcilePresence(const HardwareConfig& declared, const FoundHard
// ---- env sensor ---- // ---- env sensor ----
if (names(declared.env) && !found.env) if (names(declared.env) && !found.env)
missing(r, "env", missing(r, "env",
"declared as '" + declared.env + "declared as '" + declared.env + "' but no ambient sensor came up -- " +
"' but no ambient sensor came up -- check [Env] i2c_device, and that it is a " (declared.env == "dht11"
"real I2C adapter rather than SMBus-only"); ? std::string("the motor MCU reads it: was the firmware built with "
"[Pins] dht?")
: std::string("check [Env] i2c_device, and that it is a real I2C "
"adapter rather than SMBus-only")));
if (stated(declared.env) && !names(declared.env) && found.env) if (stated(declared.env) && !names(declared.env) && found.env)
undeclared(r, "env", "running, but [Hardware] env says none -- the declaration is stale"); undeclared(r, "env", "running, but [Hardware] env says none -- the declaration is stale");

View File

@ -95,4 +95,31 @@ std::optional<MotorTelemetry> parseTelemetryLine(const std::string& line) {
return t; return t;
} }
std::optional<McuEnvReading> parseEnvLine(const std::string& line) {
if (line.rfind("ENV ", 0) != 0) return std::nullopt;
McuEnvReading r;
bool t = false, h = false, ok = false;
std::istringstream in(line.substr(4));
for (std::string kv; in >> kv;) {
const auto eq = kv.find('=');
if (eq == std::string::npos) continue;
const std::string k = kv.substr(0, eq);
int v = 0;
try {
size_t used = 0;
v = std::stoi(kv.substr(eq + 1), &used);
if (used != kv.size() - eq - 1) return std::nullopt;
} catch (const std::exception&) {
return std::nullopt;
}
if (k == "t") { r.temp_c = v; t = true; }
else if (k == "h") { r.humidity = v; h = true; }
else if (k == "ok") { r.sensor_ok = v != 0; ok = true; }
else if (k == "fan") r.fan_duty = v;
else if (k == "rpm") r.fan_rpm = v;
}
if (!t || !h || !ok) return std::nullopt;
return r;
}
} // namespace fgc } // namespace fgc

View File

@ -1,5 +1,7 @@
#include "fgc/TestRunner.h" #include "fgc/TestRunner.h"
#include "fgc/sensors/McuEnvSensor.h"
#include "fgc/Calibration.h" // unwrapNear #include "fgc/Calibration.h" // unwrapNear
#include "fgc/DiagParser.h" #include "fgc/DiagParser.h"
@ -1047,7 +1049,7 @@ void TestRunner::testHwImu(TestReport& r) {
} }
void TestRunner::testHwEnv(TestReport& r) { void TestRunner::testHwEnv(TestReport& r) {
TestSection s; s.id = "hw_env"; s.title = "declared ambient sensor, on a real I2C bus"; TestSection s; s.id = "hw_env"; s.title = "declared ambient sensor, and the fan it runs";
setProgress(s.id, 1, 2, "probing bus"); setProgress(s.id, 1, 2, "probing bus");
const bool declared = const bool declared =
@ -1060,9 +1062,14 @@ void TestRunner::testHwEnv(TestReport& r) {
return; return;
} }
// A DHT11 on the motor MCU (legacy towers) has no bus of ours to check: the
// firmware reads it, and its ENV lines are the reading below.
const bool on_mcu = hw_.declared->env == "dht11";
if (on_mcu) {
s.notes.push_back("dht11: read by the motor MCU, not on an I2C bus of this host");
// The adapter class first: an SMBus-only bus makes every later symptom look // The adapter class first: an SMBus-only bus makes every later symptom look
// like a dead sensor, so naming it first is what saves the debugging time. // like a dead sensor, so naming it first is what saves the debugging time.
if (hw_.i2c_device.empty()) { } else if (hw_.i2c_device.empty()) {
CheckResult c; CheckResult c;
c.pass = false; c.pass = false;
c.detail = "declared as '" + hw_.declared->env + "' but [Env] i2c_device is empty"; c.detail = "declared as '" + hw_.declared->env + "' but [Env] i2c_device is empty";
@ -1094,6 +1101,13 @@ void TestRunner::testHwEnv(TestReport& r) {
"what enables it"; "what enables it";
} else if (auto sample = hw_.env->sample()) { } else if (auto sample = hw_.env->sample()) {
m.detail = "CRC-valid reading from " + hw_.env->name(); m.detail = "CRC-valid reading from " + hw_.env->name();
} else if (on_mcu) {
m.pass = false;
m.detail = motor_.latestEnv()
? "the firmware reports the DHT11 but its reads are failing the checksum, "
"or its ENV lines stopped -- check the sensor and its wiring"
: "no ENV line from the firmware -- was it built with [Pins] dht? "
"(re-flash after declaring the sensor)";
} else { } else {
m.pass = false; m.pass = false;
m.detail = "no valid reading within the staleness window -- every transfer is failing, or " m.detail = "no valid reading within the staleness window -- every transfer is failing, or "
@ -1101,6 +1115,29 @@ void TestRunner::testHwEnv(TestReport& r) {
} }
verdict(s, "env_reading", m); verdict(s, "env_reading", m);
// The enclosure fan the MCU runs from that reading. Driven hard but reading
// no rpm is a dead or unplugged fan - the one fault that would otherwise stay
// invisible until the box overheats.
if (hw_.declared->cooling == "fan") {
CheckResult f;
const auto r = motor_.latestEnv();
if (!r) {
f.pass = false;
f.detail = "no ENV line from the firmware, so nothing is known about the fan";
} else if (hw_.declared->pins.fan_tach < 0) {
f.detail = "driven at " + std::to_string(r->fan_duty) +
"/255; no tach wired, so whether it turns is not known";
} else if (fanLooksStalled(*r)) {
f.pass = false;
f.detail = "driven at " + std::to_string(r->fan_duty) +
"/255 but the tach reads 0 rpm -- dead, jammed or unplugged";
} else {
f.detail = "driven at " + std::to_string(r->fan_duty) + "/255, " +
std::to_string(r->fan_rpm) + " rpm";
}
verdict(s, "fan", f);
}
rollup(s); rollup(s);
r.sections.push_back(std::move(s)); r.sections.push_back(std::move(s));
} }

View File

@ -0,0 +1,34 @@
#include "fgc/sensors/McuEnvSensor.h"
#include <chrono>
namespace fgc {
namespace {
long long epochMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
}
} // namespace
McuEnvSensor::McuEnvSensor(IMotorController& motor, std::function<long long()> now_ms)
: motor_(motor), now_ms_(now_ms ? std::move(now_ms) : epochMs) {}
std::optional<EnvSample> McuEnvSensor::current() const {
const auto r = motor_.latestEnv();
if (!r || !r->sensor_ok) return std::nullopt;
if (now_ms_() - r->epoch_ms > kStaleMs) return std::nullopt;
EnvSample s;
s.valid = true;
s.temp_c = static_cast<float>(r->temp_c);
s.humidity_pct = static_cast<float>(r->humidity);
s.timestamp_ms = r->epoch_ms;
return s;
}
bool McuEnvSensor::connected() const { return current().has_value(); }
std::optional<EnvSample> McuEnvSensor::sample() { return current(); }
} // namespace fgc

View File

@ -83,6 +83,9 @@ struct SerialMotorController::Impl {
// NEED capture: single lines, queued for the control thread to answer once. // NEED capture: single lines, queued for the control thread to answer once.
std::vector<std::string> need_lines; std::vector<std::string> need_lines;
// ENV: the latest reading of a DHT11 on the MCU and its fan (under mutex).
std::optional<McuEnvReading> latest_env;
// Lines waiting to go out, oldest first. Touched only on the io_context // Lines waiting to go out, oldest first. Touched only on the io_context
// thread (every access is inside a posted handler or a completion), so it // thread (every access is inside a posted handler or a completion), so it
// needs no lock. // needs no lock.
@ -171,9 +174,21 @@ struct SerialMotorController::Impl {
captureDump(line); captureDump(line);
captureDiag(line); captureDiag(line);
captureNeed(line); captureNeed(line);
captureEnv(line);
} }
} }
// Keep the latest ENV line: one every 10 s from a tower with a DHT11 on the
// MCU, never from one without.
void captureEnv(const std::string& line) {
auto r = parseEnvLine(line);
if (!r) return;
r->epoch_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
std::lock_guard<std::mutex> lock(mutex);
latest_env = *r;
}
// Queue a NEED line for the control thread to answer. Unlike DUMP and DIAG // Queue a NEED line for the control thread to answer. Unlike DUMP and DIAG
// this is not a block to assemble - each line stands alone and is actionable // this is not a block to assemble - each line stands alone and is actionable
// immediately, so it is queued rather than accumulated, and taking it clears // immediately, so it is queued rather than accumulated, and taking it clears
@ -328,6 +343,11 @@ std::string SerialMotorController::lastDiag() {
unsigned SerialMotorController::diagSeq() const { return impl_->diag_seq.load(); } unsigned SerialMotorController::diagSeq() const { return impl_->diag_seq.load(); }
std::optional<McuEnvReading> SerialMotorController::latestEnv() const {
std::lock_guard<std::mutex> lock(impl_->mutex);
return impl_->latest_env;
}
std::vector<std::string> SerialMotorController::takeNeedLines() { std::vector<std::string> SerialMotorController::takeNeedLines() {
std::lock_guard<std::mutex> lock(impl_->mutex); std::lock_guard<std::mutex> lock(impl_->mutex);
std::vector<std::string> out; std::vector<std::string> out;

View File

@ -368,9 +368,10 @@ Element sensorsPanel(const SensorsView& s) {
Element imu_badge = s.imu_config_alert Element imu_badge = s.imu_config_alert
? (text(" IMU CFG ") | color(Color::Red) | bold) ? (text(" IMU CFG ") | color(Color::Red) | bold)
: text(""); : text("");
Element fan_badge = s.fan_alert ? (text(" FAN ") | color(Color::Red) | bold) : text("");
return panel("[s] SENSORS", Color::Magenta, return panel("[s] SENSORS", Color::Magenta,
vbox({group(s.imu, std::move(imu_badge)), separator(), vbox({group(s.imu, std::move(imu_badge)), separator(),
group(s.env, text(""))})); group(s.env, std::move(fan_badge))}));
} }
Element cameraPanel(const CaptureView& c) { Element cameraPanel(const CaptureView& c) {
@ -1057,7 +1058,9 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
}; };
body.push_back(separator()); body.push_back(separator());
body.push_back(text((e.name.empty() ? std::string("SHT41") : e.name) + body.push_back(text((e.name.empty() ? std::string("SHT41") : e.name) +
" \xE2\x80\x94 ambient (I2C)") | bold | color(Color::Magenta)); (e.on_mcu ? " \xE2\x80\x94 ambient (on the motor MCU)"
: " \xE2\x80\x94 ambient (I2C)")) |
bold | color(Color::Magenta));
if (!e.enabled) { if (!e.enabled) {
body.push_back(text("(env sensor disabled)") | dim); body.push_back(text("(env sensor disabled)") | dim);
body.push_back( body.push_back(
@ -1078,7 +1081,19 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
} }
// The configured bus is meaningless for the mock, so only show it for real // The configured bus is meaningless for the mock, so only show it for real
// hardware — where it is exactly what you need to debug a missing reading. // hardware — where it is exactly what you need to debug a missing reading.
if (e.enabled && !e.mock) { if (e.fan) {
std::string v = std::to_string(e.fan_duty) + "/255";
v += e.fan_tach ? ", " + std::to_string(e.fan_rpm) + " rpm" : ", no tach wired";
body.push_back(envRow("FAN", e.fan_stalled
? text(v + " NOT TURNING") | color(Color::Red) | bold
: text(v) | bold));
body.push_back(text("off to 25 \xC2\xB0""C, full from 37.75 \xC2\xB0""C, full "
"while the sensor is unread") | dim);
}
if (e.on_mcu) {
body.push_back(envRow("Bus", text("read by the motor MCU, every " +
std::to_string(e.period_ms) + " ms") | dim));
} else if (e.enabled && !e.mock) {
char addr[16]; char addr[16];
std::snprintf(addr, sizeof(addr), "0x%02X", e.i2c_addr); std::snprintf(addr, sizeof(addr), "0x%02X", e.i2c_addr);
body.push_back(envRow("Bus", text(e.i2c_device + " @ " + addr + ", every " + body.push_back(envRow("Bus", text(e.i2c_device + " @ " + addr + ", every " +

View File

@ -25,6 +25,7 @@ add_executable(fgc_tests
test_iniedit.cpp test_iniedit.cpp
test_camerastall.cpp test_camerastall.cpp
test_remotecommands.cpp test_remotecommands.cpp
test_mcuenv.cpp
test_operatingmode.cpp test_operatingmode.cpp
test_tmccurrent.cpp test_tmccurrent.cpp
test_hardwarepresence.cpp test_hardwarepresence.cpp

136
tests/test_mcuenv.cpp Normal file
View File

@ -0,0 +1,136 @@
#include <doctest/doctest.h>
#include "fgc/Config.h"
#include "fgc/TelemetryParser.h"
#include "fgc/sensors/McuEnvSensor.h"
#include <cstdio>
#include <fstream>
#include <stdexcept>
#include <string>
using namespace fgc;
namespace {
// Only latestEnv() matters here; the rest is the minimum the interface demands.
struct EnvOnlyMotor : IMotorController {
std::optional<McuEnvReading> env;
void start() override {}
void stop() override {}
void sendCommand(const std::string&) override {}
MotorTelemetry telemetry() override { return {}; }
std::string lastDump() override { return ""; }
std::string lastDiag() override { return ""; }
unsigned diagSeq() const override { return 0; }
bool connected() const override { return true; }
std::optional<McuEnvReading> latestEnv() const override { return env; }
};
McuEnvReading reading(int t, int h, bool ok, int fan, int rpm, long long at) {
McuEnvReading r;
r.temp_c = t; r.humidity = h; r.sensor_ok = ok; r.fan_duty = fan; r.fan_rpm = rpm;
r.epoch_ms = at;
return r;
}
} // namespace
TEST_CASE("an ENV line from the firmware parses, keys in any order") {
auto r = parseEnvLine("ENV t=23 h=45 ok=1 fan=0 rpm=0");
REQUIRE(r);
CHECK(r->temp_c == 23);
CHECK(r->humidity == 45);
CHECK(r->sensor_ok);
CHECK(r->fan_duty == 0);
r = parseEnvLine("ENV rpm=1180 fan=255 ok=0 h=51 t=-4");
REQUIRE(r);
CHECK(r->temp_c == -4);
CHECK_FALSE(r->sensor_ok);
CHECK(r->fan_duty == 255);
CHECK(r->fan_rpm == 1180);
}
TEST_CASE("anything that is not a whole ENV line is not a reading") {
CHECK_FALSE(parseEnvLine("ST Y:A,1,2,0,0,0,0,S"));
CHECK_FALSE(parseEnvLine("DUMP ENV t=23 h=45 ok=1")); // the dump's copy, handled there
CHECK_FALSE(parseEnvLine("ENV t=23 h=45")); // no ok: cannot tell good from bad
CHECK_FALSE(parseEnvLine("ENV t=2x h=45 ok=1"));
}
TEST_CASE("the MCU sensor serves a fresh good reading, and nothing else") {
EnvOnlyMotor motor;
long long now = 1'000'000;
McuEnvSensor s(motor, [&] { return now; });
CHECK_FALSE(s.sample()); // no ENV line yet
motor.env = reading(24, 40, true, 0, 0, now);
auto v = s.sample();
REQUIRE(v);
CHECK(v->temp_c == doctest::Approx(24.0));
CHECK(v->humidity_pct == doctest::Approx(40.0));
CHECK(s.connected());
CHECK(s.name() == "DHT11");
SUBCASE("a failed read is not served as a reading") {
motor.env = reading(24, 40, false, 255, 0, now);
CHECK_FALSE(s.sample());
}
SUBCASE("a line older than 2.5 read periods is stale") {
now += McuEnvSensor::kStaleMs + 1;
CHECK_FALSE(s.sample());
CHECK_FALSE(s.connected());
}
}
TEST_CASE("a fan driven hard with no rpm looks stalled; idle or turning does not") {
CHECK(fanLooksStalled(reading(30, 40, true, 200, 0, 0)));
CHECK(fanLooksStalled(reading(0, 0, false, 255, 0, 0))); // the failsafe, not turning
CHECK_FALSE(fanLooksStalled(reading(20, 40, true, 0, 0, 0))); // off, as it should be
CHECK_FALSE(fanLooksStalled(reading(30, 40, true, 100, 0, 0))); // too slow to be sure
CHECK_FALSE(fanLooksStalled(reading(30, 40, true, 200, 900, 0)));
}
TEST_CASE("hardware.ini: cooling needs the DHT11, and only 'fan' or 'none'") {
const std::string cfg_path = "test_mcuenv_config.ini";
const std::string hw_path = "test_mcuenv_hardware.ini";
{
std::ofstream c(cfg_path);
c << "[General]\ntower_name = Legacy\n[Camera.rgb]\nid = X\nprofile_dir = /tmp/p\n";
}
auto write_hw = [&](const std::string& hardware, const std::string& pins) {
std::ofstream h(hw_path);
h << "[Board]\nmodel = lattepanda-3-delta\nfirmware_env = leonardo\n"
"[Hardware]\naxes = yaw\nimu = none\ncameras = rgb\n" << hardware <<
"[Pins]\nyaw_cs = 8\nyaw_en = 7\n" << pins;
};
SUBCASE("a legacy tower loads, with its MCU pins") {
write_hw("env = dht11\ncooling = fan\n",
"dht = 9\nfan_pwm = 10\nfan_tach = 11\nspi_idle_cs = 2\n");
auto c = ConfigLoader::loadFromFile(cfg_path, hw_path);
CHECK(c.hardware.env == "dht11");
CHECK(c.hardware.cooling == "fan");
CHECK(c.hardware.pins.dht == 9);
CHECK(c.hardware.pins.fan_tach == 11);
CHECK(c.hardware.pins.spi_idle_cs == 2);
}
SUBCASE("a fan without the DHT11 is refused") {
write_hw("env = sht41\ncooling = fan\n", "fan_pwm = 10\n");
CHECK_THROWS_WITH_AS(ConfigLoader::loadFromFile(cfg_path, hw_path),
doctest::Contains("needs Hardware.env = dht11"), std::runtime_error);
}
SUBCASE("an unknown cooling is refused") {
write_hw("env = dht11\ncooling = water\n", "dht = 9\n");
CHECK_THROWS_WITH_AS(ConfigLoader::loadFromFile(cfg_path, hw_path),
doctest::Contains("'none' or 'fan'"), std::runtime_error);
}
SUBCASE("towers without it are unchanged") {
write_hw("env = sht41\n", "");
auto c = ConfigLoader::loadFromFile(cfg_path, hw_path);
CHECK(c.hardware.cooling.empty());
CHECK(c.hardware.pins.dht == -1);
}
std::remove(cfg_path.c_str());
std::remove(hw_path.c_str());
}