From f8f5167af5ca6766f6f170e1bc531ae4ca7cc921 Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 5 Jul 2026 14:11:57 +0200 Subject: [PATCH] add tui and persistent targets --- README.md | 54 +- include/settings_store.h | 50 ++ include/thermal_controller.h | 12 +- pi-instructions.md | 8 +- scripts/99-platformio-udev.rules | 186 +++++++ .../__pycache__/capture_csv.cpython-314.pyc | Bin 0 -> 12894 bytes scripts/__pycache__/dryer_tui.cpython-314.pyc | Bin 0 -> 29529 bytes scripts/capture_csv.py | 178 +++++-- scripts/dryer-logger.service | 2 +- scripts/dryer_tui.py | 495 ++++++++++++++++++ scripts/install-udev-rules.sh | 38 ++ 11 files changed, 960 insertions(+), 63 deletions(-) create mode 100644 include/settings_store.h create mode 100644 scripts/99-platformio-udev.rules create mode 100644 scripts/__pycache__/capture_csv.cpython-314.pyc create mode 100644 scripts/__pycache__/dryer_tui.cpython-314.pyc create mode 100644 scripts/dryer_tui.py create mode 100644 scripts/install-udev-rules.sh diff --git a/README.md b/README.md index c766c85..0c9aa44 100644 --- a/README.md +++ b/README.md @@ -52,20 +52,31 @@ ls -la /dev/serial/by-id/ ### Serial port permissions (Linux) -| Distribution | Group for serial access | -|--------------|-------------------------| -| Debian, Ubuntu, Raspberry Pi OS | `dialout` | -| Arch Linux, CachyOS | `uucp` | +**Recommended — udev rules (one-time, works in Cursor without re-login):** ```bash -# Debian / Ubuntu / Pi -sudo usermod -aG dialout $USER +sudo ./scripts/install-udev-rules.sh +# unplug and replug the Arduino +``` +This installs PlatformIO’s `99-platformio-udev.rules` (covers CH340 clone Nanos and other common adapters). + +**Alternative — group membership:** + +```bash # Arch / CachyOS sudo usermod -aG uucp $USER ``` -Log out and back in after adding your user to the group. +You must **fully log out of the desktop** (not just reboot or open a new terminal) so `id` shows the new group. Cursor’s terminal often keeps the old group list until you restart Cursor after logging out. + +**Immediate workaround** (no sudo, current terminal only): + +```bash +sg uucp -c "$HOME/.platformio/penv/bin/pio run -t upload" +``` + +Verify access: `test -w /dev/ttyUSB0 && echo ok` ## First run @@ -86,6 +97,31 @@ Log out and back in after adding your user to the group. Send `help` over serial for all commands (`target`, `fan on/off`, `log on/off`, `status`, `pid`, etc.). -## Raspberry Pi logging +## Raspberry Pi control -Optional CSV capture over USB is documented in [pi-instructions.md](pi-instructions.md). +On the Pi, use the curses TUI to set targets and monitor the chamber (SSH with a TTY: `ssh -t`): + +```bash +python3 scripts/capture_csv.py tui +``` + +Keys: `0` idle · `t` target · `p` material presets · `f`/`F` fan · `l` CSV log · `a` autotune · `:` command · `q` quit + +Headless CSV-only logging (e.g. systemd) is documented in [pi-instructions.md](pi-instructions.md). + +## Filament drying table + +| Filament | Dryer temperature | Drying time (hours) | +|----------|-------------------|---------------------| +| PLA | 50–55 °C (122–131 °F) | > 6 | +| ABS | 60–65 °C (140–149 °F) | > 6 | +| PETG | 60–65 °C (140–149 °F) | > 6 | +| Nylon | 70–75 °C (158–167 °F) | > 24 | +| Desiccants | 60–65 °C (149 °F) | > 12 | +| PVA | 40–45 °C (104–113 °F) | > 24 | +| TPU/TPE | 50–55 °C (122–131 °F) | > 8 | +| ASA | 60–65 °C (140–149 °F) | > 8 | +| PP | 50–55 °C (122–131 °F) | > 6 | +| HIPS | 60–65 °C (140–149 °F) | > 8 | +| PC | 70–75 °C (158–167 °F) | > 12 | +| PEEK | 120–125 °C (248–257 °F) | > 24 | \ No newline at end of file diff --git a/include/settings_store.h b/include/settings_store.h new file mode 100644 index 0000000..19263c2 --- /dev/null +++ b/include/settings_store.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#include "config.h" + +// After TuningData (15 bytes) + checksum (1 byte) at address 0 +static const uint16_t SETTINGS_MAGIC = 0xDA7E; +static const int SETTINGS_EEPROM_ADDR = 16; + +struct SettingsData { + uint16_t magic = 0; + float targetC = TARGET_TEMP_C; +}; + +inline uint8_t settingsChecksum(const SettingsData &data) { + const uint8_t *bytes = reinterpret_cast(&data); + uint8_t sum = 0; + for (uint8_t i = 0; i < sizeof(SettingsData) - 1; ++i) { + sum ^= bytes[i]; + } + return sum; +} + +inline bool settingsLoad(SettingsData &out) { + EEPROM.get(SETTINGS_EEPROM_ADDR, out); + const uint8_t stored = EEPROM.read(SETTINGS_EEPROM_ADDR + sizeof(SettingsData)); + if (out.magic != SETTINGS_MAGIC) { + return false; + } + return settingsChecksum(out) == stored; +} + +inline void settingsSave(const SettingsData &data) { + EEPROM.put(SETTINGS_EEPROM_ADDR, data); + EEPROM.write(SETTINGS_EEPROM_ADDR + sizeof(SettingsData), settingsChecksum(data)); +} + +inline void settingsSaveTarget(float targetC) { + SettingsData data; + data.magic = SETTINGS_MAGIC; + data.targetC = targetC; + settingsSave(data); +} + +inline void settingsClear() { + SettingsData cleared; + settingsSave(cleared); +} diff --git a/include/thermal_controller.h b/include/thermal_controller.h index 0dd2929..98e9a8a 100644 --- a/include/thermal_controller.h +++ b/include/thermal_controller.h @@ -5,6 +5,7 @@ #include "config.h" #include "pid_autotuner.h" #include "pid_controller.h" +#include "settings_store.h" #include "tuning_store.h" class ThermalController { @@ -54,6 +55,12 @@ public: Serial.println(F("Loaded learned PID from EEPROM")); printTuning(); } + + SettingsData settings; + if (settingsLoad(settings) && settings.targetC >= TARGET_MIN_C && + settings.targetC <= TARGET_MAX_C) { + setTarget(settings.targetC, false); + } } void applyTuning(const TuningData &data) { @@ -122,7 +129,7 @@ public: return true; } - void setTarget(float targetC) { + void setTarget(float targetC, bool persist = true) { targetTempC_ = targetC; pid_.setSetpoint(targetC); pid_.reset(); @@ -134,6 +141,9 @@ public: fanIdleOverride_ = false; } applyFan(); + if (persist && targetC >= TARGET_MIN_C && targetC <= TARGET_MAX_C) { + settingsSaveTarget(targetC); + } } void noteSensorMax(float maxTempC) { diff --git a/pi-instructions.md b/pi-instructions.md index 9d45580..51d8fa5 100644 --- a/pi-instructions.md +++ b/pi-instructions.md @@ -18,10 +18,16 @@ sudo usermod -aG dialout $USER # then log out/in **Manual run** ```bash -python3 scripts/capture_csv.py +# Interactive dashboard (set target, presets, fan, CSV logging) +python3 scripts/capture_csv.py tui + +# Headless CSV capture only +python3 scripts/capture_csv.py log # logs/dryer_YYYYMMDD_HHMMSS.csv ``` +**TUI keys:** `0` idle · `t` target · `p` presets · `f` fan on · `F` fan off · `l` toggle CSV log · `a` autotune · `:` raw command · `q` quit + **Always-on logging** ```bash diff --git a/scripts/99-platformio-udev.rules b/scripts/99-platformio-udev.rules new file mode 100644 index 0000000..b614853 --- /dev/null +++ b/scripts/99-platformio-udev.rules @@ -0,0 +1,186 @@ +# Copyright (c) 2014-present PlatformIO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +##################################################################################### +# +# INSTALLATION +# +# Please visit > https://docs.platformio.org/en/latest/core/installation/udev-rules.html +# +##################################################################################### + +# +# Boards +# + +# CP210X USB UART +ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea[67][013]", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" +ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="80a9", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# FT231XS USB UART +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6015", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# FX2348N USB UART +ATTRS{idVendor}=="0843", ATTRS{idProduct}=="5740", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Prolific Technology, Inc. PL2303 Serial Port +ATTRS{idVendor}=="067b", ATTRS{idProduct}=="2303", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# QinHeng Electronics HL-340 USB-Serial adapter +ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" +# QinHeng Electronics CH343 USB-Serial adapter +ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d3", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" +# QinHeng Electronics CH9102 USB-Serial adapter +ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d4", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Arduino boards +ATTRS{idVendor}=="2341", ATTRS{idProduct}=="[08][023]*", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" +ATTRS{idVendor}=="2a03", ATTRS{idProduct}=="[08][02]*", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Arduino SAM-BA +ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="6124", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{MTP_NO_PROBE}="1" + +# Digistump boards +ATTRS{idVendor}=="16d0", ATTRS{idProduct}=="0753", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Maple with DFU +ATTRS{idVendor}=="1eaf", ATTRS{idProduct}=="000[34]", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# USBtiny +ATTRS{idProduct}=="0c9f", ATTRS{idVendor}=="1781", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# USBasp V2.0 +ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="05dc", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Teensy boards +ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789B]?", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" +ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789A]?", ENV{MTP_NO_PROBE}="1" +SUBSYSTEMS=="usb", ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789ABCD]?", MODE:="0666" +KERNEL=="ttyACM*", ATTRS{idVendor}=="16c0", ATTRS{idProduct}=="04[789B]?", MODE:="0666" + +# TI Stellaris Launchpad +ATTRS{idVendor}=="1cbe", ATTRS{idProduct}=="00fd", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# TI MSP430 Launchpad +ATTRS{idVendor}=="0451", ATTRS{idProduct}=="f432", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# GD32V DFU Bootloader +ATTRS{idVendor}=="28e9", ATTRS{idProduct}=="0189", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# FireBeetle-ESP32 +ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7522", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Wio Terminal +ATTRS{idVendor}=="2886", ATTRS{idProduct}=="[08]02d", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Raspberry Pi Pico +ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="[01]*", MODE:="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# AIR32F103 +ATTRS{idVendor}=="0d28", ATTRS{idProduct}=="0204", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# STM32 virtual COM port +ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# +# Debuggers +# + +# Black Magic Probe +SUBSYSTEM=="tty", ATTRS{interface}=="Black Magic GDB Server", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" +SUBSYSTEM=="tty", ATTRS{interface}=="Black Magic UART Port", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# opendous and estick +ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="204f", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Original FT232/FT245/FT2232/FT232H/FT4232 +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="60[01][104]", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# DISTORTEC JTAG-lock-pick Tiny 2 +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="8220", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# TUMPA, TUMPA Lite +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="8a9[89]", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# XDS100v2 +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="a6d0", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Xverve Signalyzer Tool (DT-USB-ST), Signalyzer LITE (DT-USB-SLITE) +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="bca[01]", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# TI/Luminary Stellaris Evaluation Board FTDI (several) +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="bcd[9a]", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# egnite Turtelizer 2 +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="bdc8", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Section5 ICEbear +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="c14[01]", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Amontec JTAGkey and JTAGkey-tiny +ATTRS{idVendor}=="0403", ATTRS{idProduct}=="cff8", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# TI ICDI +ATTRS{idVendor}=="0451", ATTRS{idProduct}=="c32a", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# STLink probes +ATTRS{idVendor}=="0483", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Hilscher NXHX Boards +ATTRS{idVendor}=="0640", ATTRS{idProduct}=="0028", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Hitex probes +ATTRS{idVendor}=="0640", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Altera USB Blaster +ATTRS{idVendor}=="09fb", ATTRS{idProduct}=="6001", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Amontec JTAGkey-HiSpeed +ATTRS{idVendor}=="0fbb", ATTRS{idProduct}=="1000", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# SEGGER J-Link +ATTRS{idVendor}=="1366", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Raisonance RLink +ATTRS{idVendor}=="138e", ATTRS{idProduct}=="9000", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Debug Board for Neo1973 +ATTRS{idVendor}=="1457", ATTRS{idProduct}=="5118", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Olimex probes +ATTRS{idVendor}=="15ba", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# USBprog with OpenOCD firmware +ATTRS{idVendor}=="1781", ATTRS{idProduct}=="0c63", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# TI/Luminary Stellaris In-Circuit Debug Interface (ICDI) Board +ATTRS{idVendor}=="1cbe", ATTRS{idProduct}=="00fd", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Marvell Sheevaplug +ATTRS{idVendor}=="9e88", ATTRS{idProduct}=="9e8f", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Keil Software, Inc. ULink +ATTRS{idVendor}=="c251", ATTRS{idProduct}=="2710", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# CMSIS-DAP compatible adapters +ATTRS{product}=="*CMSIS-DAP*", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Atmel AVR Dragon +ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2107", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Espressif USB JTAG/serial debug unit +ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" + +# Zephyr framework USB CDC-ACM +ATTRS{idVendor}=="2fe3", ATTRS{idProduct}=="0100", MODE="0666", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1" diff --git a/scripts/__pycache__/capture_csv.cpython-314.pyc b/scripts/__pycache__/capture_csv.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..341e123b9ac186cfbdb4d63d864ef08120df3965 GIT binary patch literal 12894 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 U2j225nycbW)tZ)KHW3K_AK>+gb^rhX literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/dryer_tui.cpython-314.pyc b/scripts/__pycache__/dryer_tui.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2e4d588e320f7940ddd9cf221ad3edfe06317f3 GIT binary patch literal 29529 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 str | None: return None +def resolve_port(port: str | None) -> str: + if port is not None: + return port + detected = detect_serial_port() + if detected is not None: + return detected + fallback = "/dev/ttyUSB0" + print(f"WARN: no serial device found, using {fallback}", file=sys.stderr) + return fallback + + +def import_serial(): + try: + import serial + except ImportError: + print("Install pyserial: pip install pyserial", file=sys.stderr) + raise SystemExit(1) from None + return serial + + +def open_serial(port: str, baud: int): + serial = import_serial() + ser = serial.Serial() + ser.port = port + ser.baudrate = baud + ser.timeout = 0.1 + # DTR low: avoid resetting the Nano (CH340) on every reconnect. + ser.dtr = False + ser.rts = False + ser.open() + time.sleep(0.3) + return ser + + +def decode_line(raw: bytes) -> str: + return raw.decode("utf-8", errors="replace").strip() + + def enable_dryer_logging(ser, retries: int = 3) -> None: for attempt in range(retries): ser.reset_input_buffer() @@ -43,7 +82,7 @@ def enable_dryer_logging(ser, retries: int = 3) -> None: raw = ser.readline() if not raw: continue - line = raw.decode("utf-8", errors="replace").strip() + line = decode_line(raw) if line == "OK csv logging on" or line.startswith("csv_hdr,"): return if line.startswith("csv,"): @@ -54,50 +93,25 @@ def enable_dryer_logging(ser, retries: int = 3) -> None: print("WARN: did not see 'OK csv logging on' — continuing anyway", file=sys.stderr) -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "-p", - "--port", - help="Serial port (default: auto-detect on Pi, else /dev/ttyUSB0)", - ) - parser.add_argument("-b", "--baud", type=int, default=115200) - parser.add_argument( - "-o", - "--output", - type=Path, - help="Output CSV file (default: logs/dryer_YYYYMMDD_HHMMSS.csv)", - ) - parser.add_argument( - "--log-dir", - type=Path, - default=Path("logs"), - help="Directory for default timestamped log files", - ) - parser.add_argument( - "--auto-log-on", - action=argparse.BooleanOptionalAction, - default=True, - help="Send 'log on' to the dryer after connect (default: on)", - ) - args = parser.parse_args() +def write_csv_row(fh, line: str, header_written: list[bool]) -> None: + if line.startswith("csv_hdr,"): + device_header = line[len("csv_hdr,") :] + fh.write("wall_time," + device_header + "\n") + header_written[0] = True + fh.flush() + return + if not line.startswith("csv,"): + return + if not header_written[0]: + fh.write(FALLBACK_HEADER + "\n") + header_written[0] = True + wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds") + fh.write(wall_time + "," + line[len("csv,") :] + "\n") + fh.flush() - port = args.port - if port is None: - port = detect_serial_port() - if port is None: - port = "/dev/ttyUSB0" - print( - f"WARN: no serial device found, using {port}", - file=sys.stderr, - ) - - try: - import serial - except ImportError: - print("Install pyserial: pip install pyserial", file=sys.stderr) - return 1 +def cmd_log(args: argparse.Namespace) -> int: + port = resolve_port(args.port) out = args.output if out is None: out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv" @@ -106,12 +120,9 @@ def main() -> int: print(f"Logging {port} -> {out}", file=sys.stderr) if args.auto_log_on: print("Will send 'log on' after connect", file=sys.stderr) - else: - print("Send 'log on' to the dryer if CSV rows are not appearing", file=sys.stderr) header_written = False - with serial.Serial(port, args.baud, timeout=1) as ser, out.open("w", encoding="utf-8") as fh: - time.sleep(2.0) # allow Nano reset after USB open + with open_serial(port, args.baud) as ser, out.open("w", encoding="utf-8") as fh: if args.auto_log_on: enable_dryer_logging(ser) @@ -125,7 +136,7 @@ def main() -> int: if not raw: continue - line = raw.decode("utf-8", errors="replace").strip() + line = decode_line(raw) if not line.startswith("csv_hdr,") and not line.startswith("csv,"): if line: print(line) @@ -147,5 +158,70 @@ def main() -> int: fh.flush() +def cmd_tui(args: argparse.Namespace) -> int: + from dryer_tui import run_tui + + return run_tui(args.port, args.baud, args.log_dir, args.auto_log_on) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Filament dryer host tools (CSV logging and TUI).", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " %(prog)s tui\n" + " %(prog)s log --log-dir logs\n" + " %(prog)s --log-dir logs # legacy headless logger\n" + ), + ) + parser.add_argument("-p", "--port", help="Serial port (default: auto-detect)") + parser.add_argument("-b", "--baud", type=int, default=115200) + parser.add_argument("-o", "--output", type=Path, help="Output CSV file (log mode)") + parser.add_argument("--log-dir", type=Path, default=Path("logs"), help="CSV log directory") + parser.add_argument( + "--auto-log-on", + action=argparse.BooleanOptionalAction, + default=True, + help="Send 'log on' after connect in log mode (default: on)", + ) + + subparsers = parser.add_subparsers(dest="action") + log_p = subparsers.add_parser("log", help="Headless CSV capture", add_help=False) + log_p.add_argument("-p", "--port") + log_p.add_argument("-b", "--baud", type=int, default=115200) + log_p.add_argument("-o", "--output", type=Path) + log_p.add_argument("--log-dir", type=Path, default=Path("logs")) + log_p.add_argument("--auto-log-on", action=argparse.BooleanOptionalAction, default=True) + + tui_p = subparsers.add_parser("tui", help="Interactive curses dashboard") + tui_p.add_argument("-p", "--port") + tui_p.add_argument("-b", "--baud", type=int, default=115200) + tui_p.add_argument("--log-dir", type=Path, default=Path("logs")) + tui_p.add_argument( + "--auto-log-on", + action=argparse.BooleanOptionalAction, + default=True, + help="Start CSV logging when TUI opens (default: on)", + ) + + return parser + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else list(argv) + parser = build_parser() + args = parser.parse_args(argv) + + if args.action == "tui": + return cmd_tui(args) + if args.action == "log": + return cmd_log(args) + # No subcommand: TUI in an interactive terminal, headless log otherwise (systemd). + if sys.stdin.isatty() and sys.stdout.isatty(): + return cmd_tui(args) + return cmd_log(args) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/scripts/dryer-logger.service b/scripts/dryer-logger.service index 6ac75a7..9988e84 100644 --- a/scripts/dryer-logger.service +++ b/scripts/dryer-logger.service @@ -9,7 +9,7 @@ Type=simple # Adjust user and paths to match your Pi setup User=pi WorkingDirectory=/home/pi/voron-filament-dryer -ExecStart=/usr/bin/python3 /home/pi/voron-filament-dryer/scripts/capture_csv.py --log-dir /home/pi/voron-filament-dryer/logs +ExecStart=/usr/bin/python3 /home/pi/voron-filament-dryer/scripts/capture_csv.py log --log-dir /home/pi/voron-filament-dryer/logs Restart=on-failure RestartSec=10 diff --git a/scripts/dryer_tui.py b/scripts/dryer_tui.py new file mode 100644 index 0000000..1f0300b --- /dev/null +++ b/scripts/dryer_tui.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +"""Curses TUI for the filament dryer Arduino.""" + +from __future__ import annotations + +import curses +import re +import sys +import threading +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + +from capture_csv import ( + decode_line, + write_csv_row, +) + +PRESETS: list[tuple[str, float]] = [ + ("PLA", 55), + ("PETG", 65), + ("ABS", 65), + ("ASA", 65), + ("HIPS", 65), + ("Nylon", 75), + ("PC", 75), + ("PVA", 45), + ("TPU/TPE", 55), + ("PP", 55), + ("Idle", 0), +] + +STATUS_RE = re.compile( + r"target=(?P\S+)\s+" + r"cutoff=(?P\S+)\s+" + r"avg=(?P[\d.]+)C\s+" + r"min=(?P[\d.]+)C\s+" + r"max=(?P[\d.]+)C\s+" + r"spread=(?P[\d.]+)C\s+" + r"heatlim=(?P\d+)%\s+" + r"heater=(?P[\d.]+)%\s+" + r"fan=(?P\d+)(?P\([^)]*\))?\s+" + r"cutoff=(?P\S+)\s+" + r"failsafe=(?P\S+)\s+" + r"mode=(?P\S+)\s+" + r"sensors=\[(?P.*)\]" +) + +SENSOR_RE = re.compile(r"ch(\d+):([\d.]+)C/(\d+)%|ch(\d+):ERR") + + +@dataclass +class DryerState: + target: str = "—" + cutoff: str = "—" + avg: str = "—" + min_temp: str = "—" + max_temp: str = "—" + spread: str = "—" + heatlim: str = "—" + heater: str = "—" + fan: str = "—" + fan_note: str = "" + cutoff_active: str = "no" + failsafe: str = "no" + mode: str = "—" + sensors: list[tuple[str, str, str]] = field(default_factory=list) + messages: deque[str] = field(default_factory=lambda: deque(maxlen=12)) + csv_logging: bool = False + csv_path: Path | None = None + port: str = "" + connected: bool = False + + +def parse_status(line: str) -> dict | None: + match = STATUS_RE.search(line) + if not match: + return None + data = match.groupdict() + sensors: list[tuple[str, str, str]] = [] + for part in data["sensors"].split(", "): + part = part.strip() + if not part: + continue + m = SENSOR_RE.match(part) + if not m: + continue + if m.group(4): + sensors.append((m.group(4), "ERR", "")) + else: + sensors.append((m.group(1), m.group(2), m.group(3))) + data["sensor_list"] = sensors + data["fan_note"] = data.get("fan_note") or "" + return data + + +def apply_status(state: DryerState, data: dict) -> None: + target = data["target"] + if target.startswith("idle"): + state.target = "idle" + elif target.endswith("C"): + state.target = target[:-1] + else: + state.target = target + cutoff = data["cutoff_temp"] + if cutoff in ("n/aC", "n/a"): + state.cutoff = "n/a" + elif cutoff.endswith("C"): + state.cutoff = cutoff[:-1] + else: + state.cutoff = cutoff + state.avg = data["avg"] + state.min_temp = data["min"] + state.max_temp = data["max"] + state.spread = data["spread"] + state.heatlim = data["heatlim"] + state.heater = data["heater"] + state.fan = data["fan"] + state.fan_note = data["fan_note"] + state.cutoff_active = data["cutoff_active"] + state.failsafe = data["failsafe"] + state.mode = data["mode"] + state.sensors = data["sensor_list"] + + +class SerialWorker: + def __init__(self, ser, state: DryerState, lock: threading.Lock): + self.ser = ser + self.state = state + self.lock = lock + self.stop = threading.Event() + self._log_fh = None + self._header_written = [False] + self._thread: threading.Thread | None = None + + def start(self) -> None: + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + with self.lock: + self.state.connected = True + self.state.port = getattr(self.ser, "port", "") or getattr(self.ser, "name", "") + self.state.messages.append("Connected") + + def close(self) -> None: + self.stop.set() + if self._thread is not None: + self._thread.join(timeout=1.5) + if self._log_fh is not None: + self._log_fh.close() + self._log_fh = None + + def send(self, command: str) -> None: + if self.ser is None: + return + self.ser.write((command.strip() + "\n").encode("utf-8")) + self.ser.flush() + + def set_csv_logging(self, enabled: bool, log_dir: Path) -> None: + with self.lock: + if enabled and not self.state.csv_logging: + log_dir.mkdir(parents=True, exist_ok=True) + path = log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv" + self._log_fh = path.open("w", encoding="utf-8") + self._header_written = [False] + self.state.csv_path = path + self.state.csv_logging = True + self.state.messages.append(f"CSV -> {path.name}") + self.send("log on") + elif not enabled and self.state.csv_logging: + self.send("log off") + self.state.csv_logging = False + self.state.csv_path = None + if self._log_fh is not None: + self._log_fh.close() + self._log_fh = None + self.state.messages.append("CSV logging off") + + def _note(self, line: str) -> None: + with self.lock: + self.state.messages.append(line) + + def _run(self) -> None: + assert self.ser is not None + while not self.stop.is_set(): + try: + raw = self.ser.readline() + except Exception as exc: + with self.lock: + self.state.messages.append(f"Serial error: {exc}") + break + if not raw: + continue + line = decode_line(raw) + if not line: + continue + + if line.startswith("csv,") or line.startswith("csv_hdr,"): + if self._log_fh is not None: + write_csv_row(self._log_fh, line, self._header_written) + continue + + parsed = parse_status(line) + if parsed: + with self.lock: + apply_status(self.state, parsed) + continue + + if line.startswith("OK") or line.startswith("ERR") or line.startswith("WARN"): + self._note(line) + + +def _draw_box(win, y: int, x: int, h: int, w: int, title: str) -> None: + if h < 2 or w < 4: + return + try: + win.addstr(y, x, "+" + "-" * (w - 2) + "+") + win.addstr(y, x + 2, f" {title} "[: max(0, w - 4)]) + for row in range(1, h - 1): + win.addstr(y + row, x, "|" + " " * (w - 2) + "|") + win.addstr(y + h - 1, x, "+" + "-" * (w - 2) + "+") + except curses.error: + pass + + +def _safe_addstr(win, y: int, x: int, text: str, attr: int = 0) -> None: + height, width = win.getmaxyx() + if y < 0 or y >= height or x >= width: + return + win.addnstr(y, x, text, max(0, width - x - 1), attr) + + +def _prompt(stdscr, label: str) -> str | None: + stdscr.nodelay(False) + stdscr.timeout(-1) + curses.curs_set(1) + height, width = stdscr.getmaxyx() + prompt = f" {label}: " + row = height - 1 + col = len(prompt) + buf: list[str] = [] + + _safe_addstr(stdscr, row, 0, " " * max(0, width - 1)) + _safe_addstr(stdscr, row, 0, prompt) + stdscr.move(row, col) + stdscr.refresh() + + try: + while True: + ch = stdscr.getch() + if ch in (10, 13, curses.KEY_ENTER): + break + if ch in (27,): # Esc + return None + if ch in (curses.KEY_BACKSPACE, 127, 8): + if buf: + buf.pop() + elif ch == curses.KEY_DC: # Delete — ignore + pass + elif 32 <= ch <= 126 and len(buf) < 24: + buf.append(chr(ch)) + _safe_addstr(stdscr, row, col, (" " * 24)) + _safe_addstr(stdscr, row, col, "".join(buf)) + stdscr.move(row, col + len(buf)) + stdscr.refresh() + finally: + curses.curs_set(0) + stdscr.nodelay(True) + stdscr.timeout(200) + + text = "".join(buf).strip() + return text + + +def _preset_menu(stdscr, worker: SerialWorker) -> None: + height, width = stdscr.getmaxyx() + menu_h = min(len(PRESETS) + 2, height - 4) + menu_w = 28 + y0 = (height - menu_h) // 2 + x0 = (width - menu_w) // 2 + selected = 0 + + while True: + stdscr.erase() + _draw_box(stdscr, y0, x0, menu_h, menu_w, "Presets") + for i, (name, temp) in enumerate(PRESETS): + label = f" {name:<10} {temp:>5.0f} °C" + attr = curses.A_REVERSE if i == selected else 0 + _safe_addstr(stdscr, y0 + 1 + i, x0 + 1, label.ljust(menu_w - 2), attr) + _safe_addstr(stdscr, y0 + menu_h - 1, x0 + 2, "Enter select Esc cancel") + stdscr.refresh() + key = stdscr.getch() + if key in (27, ord("q")): + return + if key in (curses.KEY_UP, ord("k")): + selected = (selected - 1) % len(PRESETS) + elif key in (curses.KEY_DOWN, ord("j")): + selected = (selected + 1) % len(PRESETS) + elif key in (10, 13, curses.KEY_ENTER): + name, temp = PRESETS[selected] + worker.send(f"target {temp:g}") + worker._note(f"Preset {name} -> {temp:g} °C") + return + + +def _draw_dashboard(stdscr, state: DryerState) -> None: + stdscr.erase() + height, width = stdscr.getmaxyx() + if height < 18 or width < 60: + _safe_addstr(stdscr, 0, 0, "Terminal too small (need 60x18).") + stdscr.refresh() + return + + title = f" Filament Dryer — {state.port} " + _safe_addstr(stdscr, 0, 1, title, curses.A_BOLD) + + row = 2 + _safe_addstr(stdscr, row, 2, f"Target: {state.target:>8} °C", curses.A_BOLD) + _safe_addstr(stdscr, row, 28, f"Mode: {state.mode}") + cutoff_attr = curses.A_BOLD | curses.color_pair(1) if state.cutoff_active == "YES" else 0 + _safe_addstr(stdscr, row, 48, f"Cutoff: {state.cutoff_active}", cutoff_attr) + + row += 1 + _safe_addstr(stdscr, row, 2, f"Avg: {state.avg} °C Min: {state.min_temp} °C Max: {state.max_temp} °C Spread: {state.spread} °C") + + row += 1 + fan_text = f"{state.fan} %{state.fan_note}" + _safe_addstr(stdscr, row, 2, f"Heater: {state.heater} % Fan: {fan_text} Limit: {state.heatlim} %") + + row += 2 + _draw_box(stdscr, row, 1, 5, width - 2, "Sensors") + sensor_row = row + 1 + col = 3 + if state.sensors: + for ch, temp, hum in state.sensors: + if temp == "ERR": + text = f"ch{ch}: ERR" + else: + text = f"ch{ch}: {temp} °C {hum} %" + _safe_addstr(stdscr, sensor_row, col, text.ljust(22)) + col += 24 + if col + 22 >= width - 2: + sensor_row += 1 + col = 3 + else: + _safe_addstr(stdscr, sensor_row, 3, "Waiting for readings…") + + row += 5 + help_y = height - 2 + log_h = help_y - row - 1 + if log_h < 4: + _safe_addstr(stdscr, row, 2, "Terminal too small for message panel.") + stdscr.refresh() + return + + _draw_box(stdscr, row, 1, log_h, width - 2, "Messages") + csv_line = "on" if state.csv_logging else "off" + if state.csv_path: + csv_line += f" ({state.csv_path.name})" + _safe_addstr(stdscr, row + 1, 3, f"CSV: {csv_line}") + msg_row = row + 2 + for msg in list(state.messages)[-(log_h - 3) :]: + _safe_addstr(stdscr, msg_row, 3, msg[: width - 6]) + msg_row += 1 + + help_y = height - 2 + _safe_addstr( + stdscr, + help_y, + 1, + "0 idle | t target | p presets | f fan | l log | a autotune | : cmd | q quit", + curses.A_DIM, + ) + stdscr.refresh() + + +def _curses_main(stdscr, ser, log_dir: Path, auto_log_on: bool) -> int: + curses.curs_set(0) + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_RED, -1) + stdscr.nodelay(True) + stdscr.timeout(200) + stdscr.clear() + + state = DryerState() + lock = threading.Lock() + worker = SerialWorker(ser, state, lock) + worker.start() + worker.send("status") + if auto_log_on: + worker.set_csv_logging(True, log_dir) + + try: + while True: + with lock: + snapshot = DryerState( + target=state.target, + cutoff=state.cutoff, + avg=state.avg, + min_temp=state.min_temp, + max_temp=state.max_temp, + spread=state.spread, + heatlim=state.heatlim, + heater=state.heater, + fan=state.fan, + fan_note=state.fan_note, + cutoff_active=state.cutoff_active, + failsafe=state.failsafe, + mode=state.mode, + sensors=list(state.sensors), + messages=deque(state.messages, maxlen=12), + csv_logging=state.csv_logging, + csv_path=state.csv_path, + port=state.port, + connected=state.connected, + ) + _draw_dashboard(stdscr, snapshot) + + key = stdscr.getch() + if key == -1: + continue + if key in (ord("q"), ord("Q"), 27): + break + if key == ord("0"): + worker.send("target 0") + elif key == ord("t"): + value = _prompt(stdscr, "Target °C (0 = idle)") + if value is not None and value != "": + worker.send(f"target {value}") + elif key == ord("p"): + stdscr.nodelay(False) + stdscr.timeout(-1) + _preset_menu(stdscr, worker) + stdscr.nodelay(True) + stdscr.timeout(200) + elif key == ord("f"): + worker.send("fan on") + elif key == ord("F"): + worker.send("fan off") + elif key == ord("l"): + with lock: + enable = not state.csv_logging + worker.set_csv_logging(enable, log_dir) + elif key == ord("a"): + value = _prompt(stdscr, "Autotune °C (Enter = 40)") + if value is not None: + cmd = "autotune" if value == "" else f"autotune {value}" + worker.send(cmd) + elif key == ord(":"): + value = _prompt(stdscr, "Command") + if value is not None and value != "": + worker.send(value) + elif key == ord("s"): + worker.send("status") + finally: + worker.close() + + return 0 + + +def run_tui(port: str | None, baud: int, log_dir: Path, auto_log_on: bool = True) -> int: + if not sys.stdin.isatty() or not sys.stdout.isatty(): + print("TUI requires an interactive terminal.", file=sys.stderr) + print("Use: ssh -t user@host 'python3 scripts/capture_csv.py tui'", file=sys.stderr) + print("Headless logging: python3 scripts/capture_csv.py log", file=sys.stderr) + return 1 + + from capture_csv import import_serial, open_serial, resolve_port + + resolved = resolve_port(port) + import_serial() + try: + ser = open_serial(resolved, baud) + except Exception as exc: + print(f"Cannot open serial port {resolved}: {exc}", file=sys.stderr) + return 1 + + try: + return curses.wrapper( + lambda stdscr: _curses_main(stdscr, ser, log_dir, auto_log_on) + ) + except curses.error as exc: + print(f"TUI failed: {exc}", file=sys.stderr) + return 1 + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + finally: + if ser.is_open: + ser.close() + + +if __name__ == "__main__": + raise SystemExit(run_tui(None, 115200, Path("logs"), True)) diff --git a/scripts/install-udev-rules.sh b/scripts/install-udev-rules.sh new file mode 100644 index 0000000..457038c --- /dev/null +++ b/scripts/install-udev-rules.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Grant serial-port access for Arduino upload (CH340, CP210x, FTDI, etc.) +# Run once: sudo ./scripts/install-udev-rules.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUNDLED_UDEV="${SCRIPT_DIR}/99-platformio-udev.rules" +DEST="/etc/udev/rules.d/99-platformio-udev.rules" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run with sudo: sudo $0" >&2 + exit 1 +fi + +# When run via sudo, HOME is /root — use the invoking user's home for PlatformIO fallback. +REAL_USER="${SUDO_USER:-${USER}}" +REAL_HOME="$(getent passwd "${REAL_USER}" | cut -d: -f6)" + +PIO_UDEV="${REAL_HOME}/.platformio/penv/lib/python3.14/site-packages/platformio/assets/system/99-platformio-udev.rules" + +if [[ -f "${BUNDLED_UDEV}" ]]; then + SOURCE_UDEV="${BUNDLED_UDEV}" +elif [[ -f "${PIO_UDEV}" ]]; then + SOURCE_UDEV="${PIO_UDEV}" +else + echo "udev rules not found. Expected one of:" >&2 + echo " ${BUNDLED_UDEV}" >&2 + echo " ${PIO_UDEV}" >&2 + exit 1 +fi + +install -m 644 "${SOURCE_UDEV}" "${DEST}" +udevadm control --reload-rules +udevadm trigger + +echo "Installed ${DEST} (from ${SOURCE_UDEV})" +echo "Unplug and replug the Arduino, then: pio run -t upload"