diff --git a/CMakeLists.txt b/CMakeLists.txt index a35aa12..e8f4e91 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -95,6 +95,7 @@ add_library(fgc_core STATIC src/core/ChannelTopics.cpp src/sensors/I2cBus.cpp src/sensors/Sht41Protocol.cpp + src/sensors/McuEnvSensor.cpp src/ui/UiSnapshot.cpp src/ui/Scroll.cpp src/ui/HeadlessUi.cpp diff --git a/config/hardware.example.ini b/config/hardware.example.ini index 51b59b8..23babc4 100644 --- a/config/hardware.example.ini +++ b/config/hardware.example.ini @@ -46,7 +46,13 @@ axes = yaw, pitch ; is warned about and ignored. imu = none ; 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 +; 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.] blocks expected to be present and found. cameras = rgb ; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts @@ -91,6 +97,13 @@ yaw_cs = 8 yaw_en = 7 pitch_cs = 1 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] ; Only read when [Hardware] imu is not 'none'. On the LattePanda 3 Delta the MTi diff --git a/docs/configuration.md b/docs/configuration.md index 6dfe516..e51ebf6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 `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= h=<%> ok=<0|1> fan= rpm=`. 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 `[Hardware] imu` and `[Hardware] env` name what is fitted. `[Features] enable_imu` diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 4d8d730..4040864 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -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 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 Only if fitted, and declared in `hardware.ini` (`imu`, `env`) - the declaration diff --git a/hardware/common/kit.sh b/hardware/common/kit.sh index 9fd33da..8885f7f 100644 --- a/hardware/common/kit.sh +++ b/hardware/common/kit.sh @@ -303,11 +303,36 @@ kv_or_todo() { 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() { - if [[ $LOOM == standard ]]; then - [[ -r $KIT_DIR/pins.ini ]] || die "--loom standard: this kit has no pins.ini" - cat "$KIT_DIR/pins.ini" + if [[ -n $LOOM ]]; then + local file; file="$(kit_loom_file "$LOOM")" + [[ -r $file ]] || die "--loom $LOOM: this kit has no $(basename "${file:-pins-$LOOM.ini}")" + emit_loom "$file" "$AXES" "$ENV_SENSOR" "$COOLING" return fi if (( ${#PINS[@]} > 0 )); then @@ -349,6 +374,7 @@ firmware_env = $KIT_FW_ENV axes = $AXES imu = $IMU env = $ENV_SENSOR +cooling = $COOLING cameras = $CAMERAS [Serial] @@ -363,7 +389,8 @@ EOF if [[ $IMU != none ]]; then printf '\n[IMU]\n; %s\ndevice = %s\n' "$KIT_IMU_NOTE" "${IMU_DEVICE:-$KIT_IMU_DEVICE}" 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 '%s\n' "$KIT_ENV_NOTE" | sed 's/^/; /' 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): --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: --serial PATH the MCU's /dev/serial/by-id path --camera-id DEV_... the camera's Vimba id --imu-device PATH --i2c-device PATH 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, ...) The drivetrain, per axis - anything not given is written as a ; TODO: --motor AXIS.KEY=VALUE KEY one of: ${KIT_AXIS_KEYS[*]} @@ -463,7 +492,7 @@ EOF } 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="" 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="" @@ -483,6 +512,7 @@ kit_main() { --axes) AXES="$2"; shift ;; --imu) IMU="$2"; shift ;; --env) ENV_SENSOR="$2"; shift ;; + --cooling) COOLING="$2"; shift ;; --cameras) CAMERAS="$2"; shift ;; --serial) SERIAL_DEVICE="$2"; shift ;; --camera-id) CAMERA_ID="$2"; shift ;; @@ -510,7 +540,11 @@ kit_main() { shift done [[ $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" local first key diff --git a/hardware/lattepanda-3-delta/NOTES.md b/hardware/lattepanda-3-delta/NOTES.md index 9702cc9..af78250 100644 --- a/hardware/lattepanda-3-delta/NOTES.md +++ b/hardware/lattepanda-3-delta/NOTES.md @@ -53,6 +53,16 @@ In particular: not copy them to another tower without reading that tower's boards and 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 Host first, firmware second (`docs/hardware-config-plan.md` section 13). The diff --git a/hardware/lattepanda-3-delta/pins-legacy.ini b/hardware/lattepanda-3-delta/pins-legacy.ini new file mode 100644 index 0000000..3aceb31 --- /dev/null +++ b/hardware/lattepanda-3-delta/pins-legacy.ini @@ -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 diff --git a/include/fgc/Config.h b/include/fgc/Config.h index 74791e2..1c36737 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -39,7 +39,10 @@ struct HardwareConfig { // This is the state every existing tower is in until it gets a hardware.ini. std::vector axes; // e.g. {"yaw", "pitch"} 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 cameras; // the [Camera.] blocks expected to exist // 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). @@ -59,6 +62,8 @@ struct HardwareConfig { struct Pins { 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 + // 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 || pitch_en >= 0 || spi_sck >= 0; } } pins; diff --git a/include/fgc/IMotorController.h b/include/fgc/IMotorController.h index f39929e..b2b2720 100644 --- a/include/fgc/IMotorController.h +++ b/include/fgc/IMotorController.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -42,6 +43,17 @@ struct AxisTelemetry { }; // Telemetry snapshot from the motor controller: one segment per axis. +// One "ENV t= h=<%> ok=<0|1> fan= rpm=" 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 { AxisTelemetry yaw; AxisTelemetry pitch; @@ -85,6 +97,11 @@ public: // firmware to ask, and a null implementation would have nothing to say. virtual std::vector 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 latestEnv() const { return std::nullopt; } + // Whether the underlying link is usable. virtual bool connected() const = 0; }; diff --git a/include/fgc/SerialMotorController.h b/include/fgc/SerialMotorController.h index 368ee43..d552a5c 100644 --- a/include/fgc/SerialMotorController.h +++ b/include/fgc/SerialMotorController.h @@ -25,6 +25,7 @@ public: std::string lastDiag() override; unsigned diagSeq() const override; std::vector takeNeedLines() override; + std::optional latestEnv() const override; bool connected() const override; private: diff --git a/include/fgc/TelemetryParser.h b/include/fgc/TelemetryParser.h index 071aa7c..33b6591 100644 --- a/include/fgc/TelemetryParser.h +++ b/include/fgc/TelemetryParser.h @@ -22,4 +22,8 @@ std::optional parseTelemetryLine(const std::string& line); // Parse a single "Y:..." / "P:..." axis segment. Exposed for unit testing. std::optional 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 parseEnvLine(const std::string& line); + } // namespace fgc diff --git a/include/fgc/sensors/McuEnvSensor.h b/include/fgc/sensors/McuEnvSensor.h new file mode 100644 index 0000000..609511e --- /dev/null +++ b/include/fgc/sensors/McuEnvSensor.h @@ -0,0 +1,43 @@ +#pragma once + +#include "fgc/IEnvSensor.h" +#include "fgc/IMotorController.h" + +#include + +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 now_ms = {}); + + void start() override {} + void stop() override {} + bool connected() const override; + std::optional sample() override; + std::string name() const override { return "DHT11"; } + +private: + std::optional current() const; + + IMotorController& motor_; + std::function now_ms_; +}; + +} // namespace fgc diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index 702574a..1c306c7 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -95,6 +95,9 @@ struct SensorsView { // ImuConfigView::problems). Flagged on the compact panel too, because the // readings above are wrong in a way that reading them cannot reveal. 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. @@ -264,6 +267,14 @@ struct EnvView { std::string i2c_device; // from EnvConfig, for the bus row unsigned i2c_addr = 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, diff --git a/scripts/install-tower b/scripts/install-tower index 1791686..0363e64 100755 --- a/scripts/install-tower +++ b/scripts/install-tower @@ -202,7 +202,7 @@ firmware_report() { [[ -n $l ]] && grep -q '^DUMP BEGIN' "$l" && break sleep 1; l="$(latest_log)" 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 local py port; py="$(py_serial)" || return 0 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 while time.time() < end: 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 PYEOF )" @@ -226,17 +226,27 @@ PYEOF 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)" 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 -# declared fields count, as in `test hardware motor`: a Leonardo declares no SPI -# pins (they are fixed), so whatever an older build reports for them is moot. +# Does what the firmware reports match what hardware.ini declares? Every field +# counts - an axis or a DHT11 the firmware has and the file does not, or the +# 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() { - local now="$1" want="$2" f v + local now="$1" want="$2" f [[ -n $now ]] || return 1 for f in $want; do - v="${f#*=}"; [[ $v == - ]] && continue + [[ $f == spi=- ]] && continue [[ " $now " == *" $f "* ]] || return 1 done } @@ -252,7 +262,13 @@ declared_pins() { [[ -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)" [[ -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". @@ -321,13 +337,26 @@ run_test() { # 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. 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)" before="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)" - if tmux capture-pane -p -t "$pane" | grep -q '╭'; then - tmux send-keys -t "$pane" ':'; sleep 0.5 - fi - tmux send-keys -t "$pane" "test $sel" Enter + app_cmd() { # one command line into the running program, dashboard or console + if tmux capture-pane -p -t "$pane" | grep -q '╭'; then + tmux send-keys -t "$pane" ':'; sleep 0.5 + fi + 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 after="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)" [[ -n $after && $after != "$before" ]] && break @@ -340,10 +369,13 @@ run_test() { else warn "no test report appeared" fi - TEST_RC=5; return + TEST_RC=5 + [[ -n $restore ]] && app_cmd "$restore" + return fi grep -E '^\[| ; ' "$after" | sed 's/^/ /' 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 local out="$WORK/test.out" 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 TODO_LEFT=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() { + local names="standard" n + [[ -r $KIT/pins-legacy.ini ]] && names+=" legacy" echo 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 "loom for this board is hardware/$S_BOARD/pins.ini:" - sed -n '/^\[Pins\]/,$p' "$KIT/pins.ini" | sed 's/^/ /' - ask_yn loom "Is this tower wired exactly to that loom?" n + echo "wrong pin writes into a driver that was never meant to hear it. This board's looms:" + for n in $names; do + echo " $n (hardware/$S_BOARD/$(basename "$(kit_loom_file "$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 echo @@ -467,9 +517,16 @@ if [[ ! -r $HW ]]; then 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)" 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" - 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 echo "Several MCU candidates:"; nl -w2 -s') ' <<< "$S_MCU" ask serial "Which is the motor MCU (full path)" @@ -483,15 +540,17 @@ if [[ ! -r $HW ]]; then KIT_ARGS+=(--camera-id "$REPLY_VAL") fi [[ $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" KIT_ARGS+=(--i2c-device "$REPLY_VAL") fi - if choose_pins; then KIT_ARGS+=(--loom standard) + if choose_pins; then KIT_ARGS+=(--loom "$LOOM_A") else echo "Then give each pin (GPn number):" keys=(); [[ $KIT_FW_ENV == *rp2040* ]] && keys+=(spi_sck spi_mosi spi_miso) 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 fi echo @@ -521,6 +580,7 @@ else # An existing file is the tower's record: never regenerated, only topped up. if ! grep -q '^\[Pins\]' "$HW"; then 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 || warn "add [Pins] to $HW by hand (the format is in config/hardware.example.ini)" fi @@ -664,7 +724,7 @@ else grep -q '^\[Pins\]' "$HW" || [[ $HW_APPEND_PINS -eq 1 ]] || todo_n=$((todo_n + 1)) fi (( 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" 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" @@ -765,7 +825,10 @@ elif [[ ${DO[hardware.ini]} == change ]]; then hr hardware.ini sudo cp -p "$HW" "$HW.bak-$(date +%Y%m%d-%H%M%S)" 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 if (( ${#HW_FILLS[@]} )); then # In place of the ; TODO and its explanation, so the value sits where the diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 9712ccb..547d0a3 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -34,6 +34,7 @@ #include "fgc/mock/MockImuSource.h" #include "fgc/mock/MockMotorController.h" #include "fgc/mock/NullControlChannel.h" +#include "fgc/sensors/McuEnvSensor.h" #include "fgc/sensors/Sht41EnvSensor.h" #include "fgc/ui/HeadlessUi.h" #include "fgc/ui/IUserInterface.h" @@ -282,6 +283,9 @@ struct Application::Impl { std::atomic imu_pitch_warned_{false}; // The TUI heading wizard is aiming by hand; blocks a scan from starting. 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 heading_marks_; // `gimbal heading mark`s this setup std::atomic running{true}; @@ -363,6 +367,9 @@ struct Application::Impl { return nullptr; // sensors panel stays "pending" if (force_mock || cfg.features.mock_env) return std::make_unique(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(*motor); if (cfg.env.i2c_device.empty()) { LOG_WARN << "Env sensor enabled but [Env] i2c_device is empty; disabling"; return nullptr; @@ -823,6 +830,25 @@ struct Application::Impl { s.env.i2c_device = cfg.env.i2c_device; s.env.i2c_addr = cfg.env.i2c_addr; 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()) { s.env.present = true; 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| // 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. + // 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() { const long limit = cfg.enc_error_warn_counts; if (limit <= 0) return; // disabled @@ -2396,6 +2441,11 @@ struct Application::Impl { resume_pan_after_test_ = scheduler->pan(); if (resume_mode_after_test_ != Mode::Manual) 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 << "')"; // What `test hardware` needs beyond the motor and IMU: the declaration to // check against, and the subsystems that answer for themselves. @@ -2990,6 +3040,7 @@ struct Application::Impl { pollBackgroundResults(); // apply calibration result / emit DIAG summary+log scheduler->tick(); checkTrackingError(); // warn on live encoder-error excursions + checkFan(); // a legacy tower's fan, driven but not turning publishSnapshot(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } diff --git a/src/core/Config.cpp b/src/core/Config.cpp index fb6bf21..6f27eb5 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -246,6 +246,16 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { cfg.hardware.cameras = splitList(get(kv, "Hardware.cameras")); cfg.hardware.imu = lowered(get(kv, "Hardware.imu")); 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; p.yaw_cs = getInt(kv, "Pins.yaw_cs", -1); @@ -255,6 +265,10 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { p.spi_sck = getInt(kv, "Pins.spi_sck", -1); p.spi_mosi = getInt(kv, "Pins.spi_mosi", -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")) { cfg.hardware.pan_step_deg = getDouble(kv, "Hardware.pan_step_deg", 0.0); diff --git a/src/core/HardwarePresence.cpp b/src/core/HardwarePresence.cpp index 824137c..ed0c3ca 100644 --- a/src/core/HardwarePresence.cpp +++ b/src/core/HardwarePresence.cpp @@ -87,9 +87,12 @@ PresenceReport reconcilePresence(const HardwareConfig& declared, const FoundHard // ---- env sensor ---- if (names(declared.env) && !found.env) missing(r, "env", - "declared as '" + declared.env + - "' but no ambient sensor came up -- check [Env] i2c_device, and that it is a " - "real I2C adapter rather than SMBus-only"); + "declared as '" + declared.env + "' but no ambient sensor came up -- " + + (declared.env == "dht11" + ? 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) undeclared(r, "env", "running, but [Hardware] env says none -- the declaration is stale"); diff --git a/src/core/TelemetryParser.cpp b/src/core/TelemetryParser.cpp index 2c2f214..00eb2c7 100644 --- a/src/core/TelemetryParser.cpp +++ b/src/core/TelemetryParser.cpp @@ -95,4 +95,31 @@ std::optional parseTelemetryLine(const std::string& line) { return t; } +std::optional 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 diff --git a/src/core/TestRunner.cpp b/src/core/TestRunner.cpp index 667702a..820159b 100644 --- a/src/core/TestRunner.cpp +++ b/src/core/TestRunner.cpp @@ -1,5 +1,7 @@ #include "fgc/TestRunner.h" +#include "fgc/sensors/McuEnvSensor.h" + #include "fgc/Calibration.h" // unwrapNear #include "fgc/DiagParser.h" @@ -1047,7 +1049,7 @@ void TestRunner::testHwImu(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"); const bool declared = @@ -1060,9 +1062,14 @@ void TestRunner::testHwEnv(TestReport& r) { 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 // 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; c.pass = false; 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"; } else if (auto sample = hw_.env->sample()) { 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 { m.pass = false; 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); + // 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); r.sections.push_back(std::move(s)); } diff --git a/src/sensors/McuEnvSensor.cpp b/src/sensors/McuEnvSensor.cpp new file mode 100644 index 0000000..175b370 --- /dev/null +++ b/src/sensors/McuEnvSensor.cpp @@ -0,0 +1,34 @@ +#include "fgc/sensors/McuEnvSensor.h" + +#include + +namespace fgc { + +namespace { +long long epochMs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} +} // namespace + +McuEnvSensor::McuEnvSensor(IMotorController& motor, std::function now_ms) + : motor_(motor), now_ms_(now_ms ? std::move(now_ms) : epochMs) {} + +std::optional 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(r->temp_c); + s.humidity_pct = static_cast(r->humidity); + s.timestamp_ms = r->epoch_ms; + return s; +} + +bool McuEnvSensor::connected() const { return current().has_value(); } + +std::optional McuEnvSensor::sample() { return current(); } + +} // namespace fgc diff --git a/src/serial/SerialMotorController.cpp b/src/serial/SerialMotorController.cpp index c15e709..81fa2d5 100644 --- a/src/serial/SerialMotorController.cpp +++ b/src/serial/SerialMotorController.cpp @@ -83,6 +83,9 @@ struct SerialMotorController::Impl { // NEED capture: single lines, queued for the control thread to answer once. std::vector need_lines; + // ENV: the latest reading of a DHT11 on the MCU and its fan (under mutex). + std::optional latest_env; + // 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 // needs no lock. @@ -171,9 +174,21 @@ struct SerialMotorController::Impl { captureDump(line); captureDiag(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::system_clock::now().time_since_epoch()).count(); + std::lock_guard lock(mutex); + latest_env = *r; + } + // 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 // 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(); } +std::optional SerialMotorController::latestEnv() const { + std::lock_guard lock(impl_->mutex); + return impl_->latest_env; +} + std::vector SerialMotorController::takeNeedLines() { std::lock_guard lock(impl_->mutex); std::vector out; diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 70a1f3c..04dd5a5 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -368,9 +368,10 @@ Element sensorsPanel(const SensorsView& s) { Element imu_badge = s.imu_config_alert ? (text(" IMU CFG ") | color(Color::Red) | bold) : text(""); + Element fan_badge = s.fan_alert ? (text(" FAN ") | color(Color::Red) | bold) : text(""); return panel("[s] SENSORS", Color::Magenta, 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) { @@ -1057,7 +1058,9 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st) }; body.push_back(separator()); 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) { body.push_back(text("(env sensor disabled)") | dim); 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 // 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]; std::snprintf(addr, sizeof(addr), "0x%02X", e.i2c_addr); body.push_back(envRow("Bus", text(e.i2c_device + " @ " + addr + ", every " + diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 80a9ba4..80ffc04 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,7 @@ add_executable(fgc_tests test_iniedit.cpp test_camerastall.cpp test_remotecommands.cpp + test_mcuenv.cpp test_operatingmode.cpp test_tmccurrent.cpp test_hardwarepresence.cpp diff --git a/tests/test_mcuenv.cpp b/tests/test_mcuenv.cpp new file mode 100644 index 0000000..0abb0b3 --- /dev/null +++ b/tests/test_mcuenv.cpp @@ -0,0 +1,136 @@ +#include + +#include "fgc/Config.h" +#include "fgc/TelemetryParser.h" +#include "fgc/sensors/McuEnvSensor.h" + +#include +#include +#include +#include + +using namespace fgc; + +namespace { + +// Only latestEnv() matters here; the rest is the minimum the interface demands. +struct EnvOnlyMotor : IMotorController { + std::optional 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 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()); +}