Merge branch 'main' of tesserakt.pro:alex/arduino-filament-dryer
This commit is contained in:
127
README.md
Normal file
127
README.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Voron filament dryer
|
||||||
|
|
||||||
|
Arduino Nano firmware for a chamber dryer with four SHT31 humidity/temperature sensors, PID heater control, and PWM fan mixing.
|
||||||
|
|
||||||
|
## Pinout (Arduino Nano)
|
||||||
|
|
||||||
|
All settings below are defined in `include/config.h`.
|
||||||
|
|
||||||
|
| Signal | Nano pin | Notes |
|
||||||
|
|--------|----------|-------|
|
||||||
|
| I2C SDA | A4 | Shared bus (Wire) |
|
||||||
|
| I2C SCL | A5 | Shared bus (Wire) |
|
||||||
|
| TCA9548A mux | I2C `0x70` | 8-channel I2C multiplexer |
|
||||||
|
| SHT31 sensors | Mux channels 2, 3, 4, 5 | One sensor per channel |
|
||||||
|
| SHT31 address | `0x44` | ADDR pin low; use `0x45` if ADDR is high |
|
||||||
|
| Fan | D5 | 24 V fan via N-channel MOSFET (hardware PWM) |
|
||||||
|
| Heater | A2 | Solid-state relay (burst control) |
|
||||||
|
|
||||||
|
USB serial: **115200 baud**.
|
||||||
|
|
||||||
|
## Dev setup
|
||||||
|
|
||||||
|
### PlatformIO
|
||||||
|
|
||||||
|
This repo uses the PlatformIO CLI (see `.vscode/tasks.json`). If PlatformIO is already installed under `~/.platformio/penv/`, use that directly — you do not need to run the web installer again.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.platformio/penv/bin/pio --version
|
||||||
|
~/.platformio/penv/bin/pio run # build
|
||||||
|
~/.platformio/penv/bin/pio run -t upload # flash
|
||||||
|
~/.platformio/penv/bin/pio device monitor # serial monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
On Arch Linux / CachyOS, install via the package manager if you prefer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -S platformio
|
||||||
|
```
|
||||||
|
|
||||||
|
If upload cannot find the port, add to `platformio.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
upload_port = /dev/ttyUSB0 ; or /dev/ttyACM0, or a /dev/serial/by-id/... path
|
||||||
|
monitor_port = /dev/ttyUSB0
|
||||||
|
```
|
||||||
|
|
||||||
|
List available ports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la /dev/serial/by-id/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serial port permissions (Linux)
|
||||||
|
|
||||||
|
**Recommended — udev rules (one-time, works in Cursor without re-login):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
1. Flash firmware and open the serial monitor at 115200 baud.
|
||||||
|
2. Confirm `TCA9548A detected` and four valid sensor channels (`ch2`–`ch5`).
|
||||||
|
3. Run PID autotune once per physical unit (values are stored in EEPROM):
|
||||||
|
|
||||||
|
```
|
||||||
|
target 0
|
||||||
|
autotune 45
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start drying:
|
||||||
|
|
||||||
|
```
|
||||||
|
target 55
|
||||||
|
```
|
||||||
|
|
||||||
|
Send `help` over serial for all commands (`target`, `fan on/off`, `log on/off`, `status`, `pid`, etc.).
|
||||||
|
|
||||||
|
## Raspberry Pi control
|
||||||
|
|
||||||
|
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 |
|
||||||
70
get-platformio.py
Normal file
70
get-platformio.py
Normal file
File diff suppressed because one or more lines are too long
50
include/settings_store.h
Normal file
50
include/settings_store.h
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <EEPROM.h>
|
||||||
|
|
||||||
|
#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<const uint8_t *>(&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);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "pid_autotuner.h"
|
#include "pid_autotuner.h"
|
||||||
#include "pid_controller.h"
|
#include "pid_controller.h"
|
||||||
|
#include "settings_store.h"
|
||||||
#include "tuning_store.h"
|
#include "tuning_store.h"
|
||||||
|
|
||||||
class ThermalController {
|
class ThermalController {
|
||||||
@@ -54,6 +55,12 @@ public:
|
|||||||
Serial.println(F("Loaded learned PID from EEPROM"));
|
Serial.println(F("Loaded learned PID from EEPROM"));
|
||||||
printTuning();
|
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) {
|
void applyTuning(const TuningData &data) {
|
||||||
@@ -132,7 +139,7 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setTarget(float targetC) {
|
void setTarget(float targetC, bool persist = true) {
|
||||||
targetTempC_ = targetC;
|
targetTempC_ = targetC;
|
||||||
pid_.setSetpoint(targetC);
|
pid_.setSetpoint(targetC);
|
||||||
pid_.reset();
|
pid_.reset();
|
||||||
@@ -144,6 +151,9 @@ public:
|
|||||||
fanIdleOverride_ = false;
|
fanIdleOverride_ = false;
|
||||||
}
|
}
|
||||||
applyFan();
|
applyFan();
|
||||||
|
if (persist && targetC >= TARGET_MIN_C && targetC <= TARGET_MAX_C) {
|
||||||
|
settingsSaveTarget(targetC);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void noteSensorMax(float maxTempC) {
|
void noteSensorMax(float maxTempC) {
|
||||||
|
|||||||
@@ -18,10 +18,16 @@ sudo usermod -aG dialout $USER # then log out/in
|
|||||||
**Manual run**
|
**Manual run**
|
||||||
|
|
||||||
```bash
|
```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
|
# 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**
|
**Always-on logging**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
186
scripts/99-platformio-udev.rules
Normal file
186
scripts/99-platformio-udev.rules
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# Copyright (c) 2014-present PlatformIO <contact@platformio.org>
|
||||||
|
#
|
||||||
|
# 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"
|
||||||
BIN
scripts/__pycache__/capture_csv.cpython-314.pyc
Normal file
BIN
scripts/__pycache__/capture_csv.cpython-314.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/dryer_tui.cpython-314.pyc
Normal file
BIN
scripts/__pycache__/dryer_tui.cpython-314.pyc
Normal file
Binary file not shown.
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Capture filament dryer CSV lines from serial into a file.
|
"""Filament dryer host tools — TUI dashboard and CSV logging.
|
||||||
|
|
||||||
Designed for a Raspberry Pi attached to the dryer Nano over USB. The Arduino
|
capture_csv.py TUI when run in a terminal (default)
|
||||||
still owns sensors and control; this script only records the csv,* stream.
|
capture_csv.py tui same
|
||||||
|
capture_csv.py log headless CSV capture (Pi systemd logger)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -33,6 +34,44 @@ def detect_serial_port() -> str | None:
|
|||||||
return 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:
|
def enable_dryer_logging(ser, retries: int = 3) -> None:
|
||||||
for attempt in range(retries):
|
for attempt in range(retries):
|
||||||
ser.reset_input_buffer()
|
ser.reset_input_buffer()
|
||||||
@@ -43,7 +82,7 @@ def enable_dryer_logging(ser, retries: int = 3) -> None:
|
|||||||
raw = ser.readline()
|
raw = ser.readline()
|
||||||
if not raw:
|
if not raw:
|
||||||
continue
|
continue
|
||||||
line = raw.decode("utf-8", errors="replace").strip()
|
line = decode_line(raw)
|
||||||
if line == "OK csv logging on" or line.startswith("csv_hdr,"):
|
if line == "OK csv logging on" or line.startswith("csv_hdr,"):
|
||||||
return
|
return
|
||||||
if line.startswith("csv,"):
|
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)
|
print("WARN: did not see 'OK csv logging on' — continuing anyway", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def write_csv_row(fh, line: str, header_written: list[bool]) -> None:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
if line.startswith("csv_hdr,"):
|
||||||
parser.add_argument(
|
device_header = line[len("csv_hdr,") :]
|
||||||
"-p",
|
fh.write("wall_time," + device_header + "\n")
|
||||||
"--port",
|
header_written[0] = True
|
||||||
help="Serial port (default: auto-detect on Pi, else /dev/ttyUSB0)",
|
fh.flush()
|
||||||
)
|
return
|
||||||
parser.add_argument("-b", "--baud", type=int, default=115200)
|
if not line.startswith("csv,"):
|
||||||
parser.add_argument(
|
return
|
||||||
"-o",
|
if not header_written[0]:
|
||||||
"--output",
|
fh.write(FALLBACK_HEADER + "\n")
|
||||||
type=Path,
|
header_written[0] = True
|
||||||
help="Output CSV file (default: logs/dryer_YYYYMMDD_HHMMSS.csv)",
|
wall_time = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
)
|
fh.write(wall_time + "," + line[len("csv,") :] + "\n")
|
||||||
parser.add_argument(
|
fh.flush()
|
||||||
"--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()
|
|
||||||
|
|
||||||
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
|
out = args.output
|
||||||
if out is None:
|
if out is None:
|
||||||
out = args.log_dir / f"dryer_{datetime.now():%Y%m%d_%H%M%S}.csv"
|
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)
|
print(f"Logging {port} -> {out}", file=sys.stderr)
|
||||||
if args.auto_log_on:
|
if args.auto_log_on:
|
||||||
print("Will send 'log on' after connect", file=sys.stderr)
|
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
|
header_written = False
|
||||||
with serial.Serial(port, args.baud, timeout=1) as ser, out.open("w", encoding="utf-8") as fh:
|
with open_serial(port, args.baud) as ser, out.open("w", encoding="utf-8") as fh:
|
||||||
time.sleep(2.0) # allow Nano reset after USB open
|
|
||||||
if args.auto_log_on:
|
if args.auto_log_on:
|
||||||
enable_dryer_logging(ser)
|
enable_dryer_logging(ser)
|
||||||
|
|
||||||
@@ -125,7 +136,7 @@ def main() -> int:
|
|||||||
if not raw:
|
if not raw:
|
||||||
continue
|
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 not line.startswith("csv_hdr,") and not line.startswith("csv,"):
|
||||||
if line:
|
if line:
|
||||||
print(line)
|
print(line)
|
||||||
@@ -147,5 +158,70 @@ def main() -> int:
|
|||||||
fh.flush()
|
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__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Type=simple
|
|||||||
# Adjust user and paths to match your Pi setup
|
# Adjust user and paths to match your Pi setup
|
||||||
User=pi
|
User=pi
|
||||||
WorkingDirectory=/home/pi/voron-filament-dryer
|
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
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|
||||||
|
|||||||
495
scripts/dryer_tui.py
Normal file
495
scripts/dryer_tui.py
Normal file
@@ -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<target>\S+)\s+"
|
||||||
|
r"cutoff=(?P<cutoff_temp>\S+)\s+"
|
||||||
|
r"avg=(?P<avg>[\d.]+)C\s+"
|
||||||
|
r"min=(?P<min>[\d.]+)C\s+"
|
||||||
|
r"max=(?P<max>[\d.]+)C\s+"
|
||||||
|
r"spread=(?P<spread>[\d.]+)C\s+"
|
||||||
|
r"heatlim=(?P<heatlim>\d+)%\s+"
|
||||||
|
r"heater=(?P<heater>[\d.]+)%\s+"
|
||||||
|
r"fan=(?P<fan>\d+)(?P<fan_note>\([^)]*\))?\s+"
|
||||||
|
r"cutoff=(?P<cutoff_active>\S+)\s+"
|
||||||
|
r"failsafe=(?P<failsafe>\S+)\s+"
|
||||||
|
r"mode=(?P<mode>\S+)\s+"
|
||||||
|
r"sensors=\[(?P<sensors>.*)\]"
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
38
scripts/install-udev-rules.sh
Normal file
38
scripts/install-udev-rules.sh
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user