Initialize piNail project with modern piNail2 web controller

This commit is contained in:
2026-03-11 20:11:59 +00:00
commit fe550429a5
84 changed files with 5734 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
"""
piNail2 — Flask Web Application
Main entry point. Runs the Flask web server and initializes the PID controller.
Provides REST API endpoints and serves the single-page dashboard.
Usage:
python3 app.py
python3 app.py --config /path/to/config.json
"""
import sys
import os
import signal
import atexit
import logging
import argparse
import json
import time
from datetime import datetime
from flask import Flask, render_template, jsonify, request
from config import Config
from thermocouple import Thermocouple
from pid_controller import PIDController
# ---------------------------------------------------------------------------
# Logging setup
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger("piNail2")
APP_INSTANCE_ID = str(int(time.time()))
APP_VERSION = "v2.1.0"
# ---------------------------------------------------------------------------
# Flask app
# ---------------------------------------------------------------------------
app = Flask(__name__)
# These are set in main() before the app starts
config = None # type: Config
controller = None # type: PIDController
tc = None # type: Thermocouple
# ---------------------------------------------------------------------------
# Routes — Pages
# ---------------------------------------------------------------------------
@app.route("/")
def index():
"""Serve the main dashboard."""
return render_template(
"index.html",
app_version=APP_VERSION,
copyright_year=datetime.now().year,
)
# ---------------------------------------------------------------------------
# Routes — API
# ---------------------------------------------------------------------------
@app.route("/api/status")
def api_status():
"""Return current controller state."""
status = controller.status
status["instance_id"] = APP_INSTANCE_ID
status["thermocouple"] = tc.stats
status["presets"] = config.get("presets")
status["config"] = {
"loop_size_ms": config.get("control", "loop_size_ms"),
"sleep_time": config.get("control", "sleep_time"),
"safety": config.get("safety"),
}
return jsonify(status)
@app.route("/api/heartbeat")
def api_heartbeat():
"""Lightweight health endpoint for frontend reconnect logic."""
return jsonify({
"ok": True,
"instance_id": APP_INSTANCE_ID,
"ts": time.time(),
"controller_alive": controller.is_alive,
"watchdog_ok": controller.watchdog_ok,
})
@app.route("/api/history")
def api_history():
"""Return recent temperature history for charting."""
since = request.args.get("since", 0, type=float)
if since > 0:
data = controller.get_history_since(since)
else:
data = controller.history
return jsonify(data)
@app.route("/api/power", methods=["POST"])
def api_power():
"""Toggle controller on/off."""
body = request.get_json(force=True, silent=True) or {}
enable = body.get("enabled")
if enable is None:
# Toggle
enable = not controller.status["enabled"]
if enable:
controller.start()
else:
controller.stop()
return jsonify({"enabled": enable, "ok": True})
@app.route("/api/setpoint", methods=["POST"])
def api_setpoint():
"""Change the target temperature."""
body = request.get_json(force=True, silent=True) or {}
value = body.get("setpoint")
if value is None:
return jsonify({"error": "Missing 'setpoint' field"}), 400
try:
value = float(value)
except (TypeError, ValueError):
return jsonify({"error": "Invalid setpoint value"}), 400
safety = config.get("safety")
if value > safety["max_temp_f"]:
return jsonify({"error": "Setpoint {} exceeds max {}F".format(value, safety['max_temp_f'])}), 400
if value < safety["min_temp_f"]:
return jsonify({"error": "Setpoint {} below min {}F".format(value, safety['min_temp_f'])}), 400
controller.set_setpoint(value)
return jsonify({"setpoint": value, "ok": True})
@app.route("/api/pid", methods=["POST"])
def api_pid():
"""Update PID tuning parameters."""
body = request.get_json(force=True, silent=True) or {}
kp = body.get("kP")
ki = body.get("kI")
kd = body.get("kD")
p_on_m = body.get("proportional_on_measurement")
p_mode = body.get("proportional_mode")
if kp is None or ki is None or kd is None:
return jsonify({"error": "Missing kP, kI, or kD"}), 400
try:
kp, ki, kd = float(kp), float(ki), float(kd)
except (TypeError, ValueError):
return jsonify({"error": "Invalid PID values"}), 400
controller.set_pid_tuning(kp, ki, kd, p_on_m, p_mode)
mode_out = "measurement" if controller.status["pid"]["proportional_on_measurement"] else "error"
return jsonify({
"kP": kp,
"kI": ki,
"kD": kd,
"proportional_on_measurement": controller.status["pid"]["proportional_on_measurement"],
"proportional_mode": mode_out,
"ok": True,
})
@app.route("/api/preset/<name>", methods=["POST"])
def api_preset(name):
"""Apply a named temperature preset."""
presets = config.get("presets")
if name not in presets:
return jsonify({"error": "Unknown preset '{}'".format(name), "available": list(presets.keys())}), 404
value = presets[name]
controller.set_setpoint(value)
return jsonify({"preset": name, "setpoint": value, "ok": True})
@app.route("/api/presets", methods=["GET"])
def api_presets():
"""List all presets."""
return jsonify(config.get("presets"))
@app.route("/api/presets", methods=["POST"])
def api_presets_update():
"""Add or update a preset."""
body = request.get_json(force=True, silent=True) or {}
name = body.get("name")
value = body.get("setpoint")
if not name or value is None:
return jsonify({"error": "Missing 'name' or 'setpoint'"}), 400
try:
value = float(value)
except (TypeError, ValueError):
return jsonify({"error": "Invalid setpoint value"}), 400
presets = config.get("presets")
presets[name] = value
config.update_section("presets", presets)
return jsonify({"ok": True, "presets": presets})
@app.route("/api/presets/<name>", methods=["DELETE"])
def api_preset_delete(name):
"""Delete a preset."""
presets = config.get("presets")
if name not in presets:
return jsonify({"error": "Unknown preset '{}'".format(name)}), 404
del presets[name]
# Overwrite entire presets section
config._data["presets"] = presets
config.save()
return jsonify({"ok": True, "presets": presets})
@app.route("/api/config", methods=["GET"])
def api_config():
"""Return full config (read-only view)."""
return jsonify(config.data)
@app.route("/api/pid/reset", methods=["POST"])
def api_pid_reset():
"""Reset PID controller internals (clears integral windup)."""
controller._pid.reset()
return jsonify({"ok": True, "message": "PID reset"})
@app.route("/api/autotune", methods=["GET"])
def api_autotune_status():
"""Return current autotune state."""
return jsonify(controller.autotune_status)
@app.route("/api/autotune/start", methods=["POST"])
def api_autotune_start():
"""Start relay-based PID autotune."""
if not controller.status.get("enabled"):
controller.start()
time.sleep(0.2)
body = request.get_json(force=True, silent=True) or {}
setpoint = body.get("setpoint")
hysteresis = body.get("hysteresis")
cycles = body.get("cycles")
controller.start_autotune(setpoint=setpoint, hysteresis=hysteresis, cycles=cycles)
return jsonify({"ok": True, "autotune": controller.autotune_status})
@app.route("/api/autotune/stop", methods=["POST"])
def api_autotune_stop():
"""Stop PID autotune."""
controller.stop_autotune("Autotune stopped by user")
return jsonify({"ok": True, "autotune": controller.autotune_status})
@app.route("/api/safety/reset", methods=["POST"])
def api_safety_reset():
"""Reset safety trip (clears the safety flag so controller can be restarted)."""
controller._safety_tripped = False
controller._safety_reason = ""
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def shutdown_handler(*args):
"""Handle SIGTERM/SIGINT gracefully."""
log.info("Shutdown signal received")
if controller:
controller.cleanup()
sys.exit(0)
def main():
global config, controller, tc
parser = argparse.ArgumentParser(description="piNail2 — E-Nail Temperature Controller")
parser.add_argument("--config", default="config.json", help="Path to config file")
args = parser.parse_args()
# Load config
config = Config(args.config)
log.info("Configuration loaded from %s", args.config)
# Initialize thermocouple
gpio_cfg = config.get("gpio")
safety_cfg = config.get("safety")
tc = Thermocouple(
clk=gpio_cfg["clk"],
cs=gpio_cfg["cs"],
do=gpio_cfg["do"],
spike_threshold=safety_cfg["spike_threshold_f"]
)
# Initialize PID controller
controller = PIDController(config, tc)
# Register cleanup
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
atexit.register(controller.cleanup)
# Start Flask
web_cfg = config.get("web")
log.info("Starting web server on %s:%d", web_cfg["host"], web_cfg["port"])
app.run(
host=web_cfg["host"],
port=web_cfg["port"],
debug=False,
threaded=True
)
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
{
"pid": {
"kP": 10.0,
"kI": 5.0,
"kD": 1.0,
"proportional_on_measurement": false
},
"control": {
"setpoint": 530,
"loop_size_ms": 3000,
"sleep_time": 0.4,
"enabled": false
},
"safety": {
"max_temp_f": 800,
"spike_threshold_f": 50.0,
"idle_shutoff_minutes": 30,
"watchdog_timeout_s": 10,
"min_temp_f": 0
},
"gpio": {
"relay_pin": 2,
"clk": 3,
"cs": 14,
"do": 4
},
"logging": {
"log_resolution": 1,
"log_directory": "./logs",
"max_log_lines": 10000
},
"presets": {
"Low Temp": 450,
"Medium": 530,
"High": 650
},
"web": {
"host": "0.0.0.0",
"port": 5000,
"update_interval_ms": 500
},
"autotune": {
"hysteresis_f": 8.0,
"cycles": 4
}
}
+145
View File
@@ -0,0 +1,145 @@
"""
piNail2 Configuration Management
JSON-based config with named fields, load/save, defaults, and hot-reload support.
Replaces the old positional P_I_D_values.txt format.
"""
import json
import os
import logging
import copy
log = logging.getLogger(__name__)
DEFAULT_CONFIG = {
"pid": {
"kP": 10.0,
"kI": 5.0,
"kD": 1.0,
"proportional_on_measurement": False
},
"control": {
"setpoint": 530,
"loop_size_ms": 3000,
"sleep_time": 0.4,
"enabled": False
},
"safety": {
"max_temp_f": 800,
"spike_threshold_f": 50.0,
"idle_shutoff_minutes": 30,
"watchdog_timeout_s": 10,
"min_temp_f": 0
},
"gpio": {
"relay_pin": 2,
"clk": 3,
"cs": 14,
"do": 4
},
"logging": {
"log_resolution": 1,
"log_directory": "./logs",
"max_log_lines": 10000
},
"presets": {
"Low Temp": 450,
"Medium": 530,
"High": 650
},
"web": {
"host": "0.0.0.0",
"port": 5000,
"update_interval_ms": 500
},
"autotune": {
"hysteresis_f": 8.0,
"cycles": 4
}
}
class Config:
"""Thread-safe configuration manager with file persistence."""
def __init__(self, config_path="config.json"):
self._path = config_path
self._data = copy.deepcopy(DEFAULT_CONFIG)
self._mtime = 0
self.load()
def load(self):
"""Load config from disk, merging with defaults for any missing keys."""
if not os.path.exists(self._path):
log.info("No config file found at %s, creating with defaults", self._path)
self.save()
return
try:
with open(self._path, 'r') as f:
user_config = json.load(f)
self._data = self._deep_merge(copy.deepcopy(DEFAULT_CONFIG), user_config)
self._mtime = os.path.getmtime(self._path)
log.info("Config loaded from %s", self._path)
except (json.JSONDecodeError, IOError) as e:
log.error("Failed to load config from %s: %s. Using defaults.", self._path, e)
self._data = copy.deepcopy(DEFAULT_CONFIG)
def save(self):
"""Write current config to disk."""
try:
with open(self._path, 'w') as f:
json.dump(self._data, f, indent=2)
self._mtime = os.path.getmtime(self._path)
log.info("Config saved to %s", self._path)
except IOError as e:
log.error("Failed to save config to %s: %s", self._path, e)
def reload_if_changed(self):
"""Reload config from disk if the file has been modified externally."""
try:
current_mtime = os.path.getmtime(self._path)
if current_mtime > self._mtime:
log.info("Config file changed on disk, reloading")
self.load()
return True
except OSError:
pass
return False
def get(self, section, key=None):
"""Get a config value. If key is None, returns the entire section."""
if key is None:
return copy.deepcopy(self._data.get(section, {}))
return self._data.get(section, {}).get(key)
def set(self, section, key, value):
"""Set a config value and save to disk."""
if section not in self._data:
self._data[section] = {}
self._data[section][key] = value
self.save()
def update_section(self, section, values):
"""Update multiple keys in a section and save to disk."""
if section not in self._data:
self._data[section] = {}
self._data[section].update(values)
self.save()
@property
def data(self):
"""Return a deep copy of the full config dict."""
return copy.deepcopy(self._data)
@staticmethod
def _deep_merge(base, override):
"""Recursively merge override into base. Override values win."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = Config._deep_merge(result[key], value)
else:
result[key] = value
return result
+120
View File
@@ -0,0 +1,120 @@
#!/bin/bash
#
# piNail2 Deploy Script
#
# Deploys piNail2 to the Raspberry Pi. Steps:
# 1. Copy piNail2 files to ~/piNail2/ on the Pi
# 2. Copy the MAX6675 library from the old project
# 3. Install pip dependencies (system-wide for Python 3.5)
# 4. Optionally install and enable the systemd service
#
# Usage: ./deploy.sh [--service]
# --service Also install and enable the systemd service
#
set -euo pipefail
PI_HOST="pinail" # SSH alias from ~/.ssh/config
PI_USER="pi"
REMOTE_DIR="/home/${PI_USER}/piNail2"
LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_SERVICE=false
if [[ "${1:-}" == "--service" ]]; then
INSTALL_SERVICE=true
fi
echo "=== piNail2 Deployment ==="
echo "Target: ${PI_HOST} -> ${REMOTE_DIR}"
echo ""
# -------------------------------------------------------------------
# Step 1: Copy project files
# -------------------------------------------------------------------
echo "--- Step 1: Copying piNail2 to Pi ---"
ssh ${PI_HOST} "mkdir -p ${REMOTE_DIR}/static ${REMOTE_DIR}/templates ${REMOTE_DIR}/logs"
# Copy Python source files
scp -q "${LOCAL_DIR}/app.py" \
"${LOCAL_DIR}/config.py" \
"${LOCAL_DIR}/thermocouple.py" \
"${LOCAL_DIR}/pid_controller.py" \
"${LOCAL_DIR}/config.json" \
"${LOCAL_DIR}/requirements.txt" \
"${LOCAL_DIR}/pinail.service" \
"${PI_HOST}:${REMOTE_DIR}/"
# Copy static + templates
scp -q "${LOCAL_DIR}/static/app.js" \
"${LOCAL_DIR}/static/style.css" \
"${PI_HOST}:${REMOTE_DIR}/static/"
scp -q "${LOCAL_DIR}/templates/index.html" \
"${PI_HOST}:${REMOTE_DIR}/templates/"
echo "Files copied."
echo ""
# -------------------------------------------------------------------
# Step 2: Copy MAX6675 library from old project
# -------------------------------------------------------------------
echo "--- Step 2: Copying MAX6675 library ---"
ssh ${PI_HOST} bash -s <<'REMOTE_SCRIPT'
# Copy the MAX6675 Python package from the old piNail project
if [ -d /home/pi/piNail/MAX6675-master/MAX6675 ]; then
cp -r /home/pi/piNail/MAX6675-master/MAX6675 /home/pi/piNail2/MAX6675
echo "MAX6675 library copied from old project"
else
echo "WARNING: MAX6675 library not found in old project!"
fi
REMOTE_SCRIPT
echo ""
# -------------------------------------------------------------------
# Step 3: Install pip dependencies
# -------------------------------------------------------------------
echo "--- Step 3: Installing dependencies ---"
ssh ${PI_HOST} bash -s <<'REMOTE_SCRIPT'
cd /home/pi/piNail2
echo "Installing dependencies with pip3..."
pip3 install --user -r requirements.txt 2>&1 | tail -10
echo ""
echo "Key packages:"
pip3 show Flask simple-pid RPi.GPIO Adafruit-GPIO 2>/dev/null | grep -E '^(Name|Version):'
REMOTE_SCRIPT
echo ""
# -------------------------------------------------------------------
# Step 4: Systemd service (optional)
# -------------------------------------------------------------------
if $INSTALL_SERVICE; then
echo "--- Step 4: Installing systemd service ---"
ssh ${PI_HOST} bash -s <<'REMOTE_SCRIPT'
sudo cp /home/pi/piNail2/pinail.service /etc/systemd/system/pinail2.service
sudo systemctl daemon-reload
sudo systemctl enable pinail2.service
echo "Service installed and enabled (pinail2.service)"
echo "Start with: sudo systemctl start pinail2"
echo "Logs with: sudo journalctl -u pinail2 -f"
REMOTE_SCRIPT
else
echo "--- Step 4: Skipping systemd service (use --service flag to install) ---"
fi
echo ""
echo "=== Deployment complete! ==="
echo ""
echo "To run manually:"
echo " ssh ${PI_HOST}"
echo " cd ~/piNail2"
echo " python3 app.py"
echo ""
echo "Web UI will be at: http://192.168.0.159:5000"
+575
View File
@@ -0,0 +1,575 @@
"""
piNail2 PID Controller
Runs the PID control loop in a background thread. Controls a relay via GPIO
using software PWM (duty cycle within a time window) to maintain a target
temperature on an e-nail heating coil.
Safety features:
- Hard max temperature cutoff
- Thermocouple disconnect detection -> relay OFF
- Idle auto-shutoff after configurable timeout
- Watchdog: detects if control loop stalls
- Proper GPIO cleanup on shutdown
"""
import time
import threading
import logging
import os
import csv
import math
from datetime import datetime
log = logging.getLogger(__name__)
class PIDController:
"""
Threaded PID temperature controller for e-nail heating.
The control loop runs in a background daemon thread. It reads temperature
from a Thermocouple object, computes PID output, and drives a relay GPIO
pin using software PWM (time-proportional control within each loop cycle).
Thread-safe properties expose current state to the Flask web server.
"""
def __init__(self, config, thermocouple):
"""
Args:
config: Config instance
thermocouple: Thermocouple instance
"""
self._config = config
self._tc = thermocouple
self._lock = threading.Lock()
# State
self._enabled = False
self._temp = 0.0
self._setpoint = config.get("control", "setpoint")
self._output = 0.0
self._relay_on = False
self._loop_count = 0
self._start_time = None
self._last_loop_time = None
self._idle_since = None # timestamp when temp first reached setpoint vicinity
self._safety_tripped = False
self._safety_reason = ""
self._thread = None
self._stop_event = threading.Event()
# Autotune state
self._autotune_active = False
self._autotune_target = self._setpoint
self._autotune_hysteresis = config.get("autotune", "hysteresis_f")
self._autotune_cycles = config.get("autotune", "cycles")
self._autotune_heating = False
self._autotune_phase_started = None
self._autotune_phase_extreme = None
self._autotune_high_peaks = []
self._autotune_low_peaks = []
self._autotune_last_result = None
self._autotune_message = ""
# PID instance
from simple_pid import PID
pid_cfg = config.get("pid")
self._pid = PID(
pid_cfg["kP"],
pid_cfg["kI"],
pid_cfg["kD"],
setpoint=self._setpoint
)
self._pid.proportional_on_measurement = pid_cfg.get("proportional_on_measurement", True)
loop_size = config.get("control", "loop_size_ms")
self._pid.output_limits = (0, loop_size)
# GPIO setup
self._relay_pin = config.get("gpio", "relay_pin")
self._gpio = None
try:
import RPi.GPIO as GPIO
self._gpio = GPIO
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
GPIO.setup(self._relay_pin, GPIO.OUT, initial=GPIO.LOW)
log.info("GPIO initialized, relay pin %d set LOW", self._relay_pin)
except Exception as e:
log.error("Failed to initialize GPIO: %s", e)
# Logging setup
self._log_dir = config.get("logging", "log_directory")
os.makedirs(self._log_dir, exist_ok=True)
self._log_file = None
self._log_writer = None
self._log_counter = 0
self._history = [] # Recent data points for the web UI
self._history_max = 1000
self._init_log_file()
def _init_log_file(self):
"""Create a new CSV log file with a timestamp in the filename."""
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join(self._log_dir, "pinail_{}.csv".format(timestamp))
self._log_file = open(path, 'w', newline='')
self._log_writer = csv.writer(self._log_file)
self._log_writer.writerow([
"timestamp", "temp_f", "setpoint_f", "output", "relay",
"kP", "kI", "kD", "loop_size_ms"
])
log.info("Log file created: %s", path)
except Exception as e:
log.error("Failed to create log file: %s", e)
def start(self):
"""Enable the controller and start the background control loop."""
with self._lock:
if self._thread is not None and self._thread.is_alive():
log.warning("Controller is already running")
return
self._enabled = True
self._safety_tripped = False
self._safety_reason = ""
self._start_time = time.monotonic()
self._idle_since = None
self._stop_event.clear()
self._pid.reset()
self._thread = threading.Thread(target=self._control_loop, daemon=True, name="pid-loop")
self._thread.start()
log.info("PID controller started (setpoint=%.0fF)", self._setpoint)
def stop(self):
"""Stop the controller and turn off the relay."""
log.info("Stopping PID controller")
self._stop_event.set()
with self._lock:
self._enabled = False
self._autotune_active = False
self._relay_off()
if self._thread is not None:
self._thread.join(timeout=5)
log.info("PID controller stopped")
def _relay_off(self):
"""Ensure relay is OFF."""
if self._gpio is not None:
try:
self._gpio.output(self._relay_pin, self._gpio.LOW)
except Exception as e:
log.error("Failed to turn relay off: %s", e)
self._relay_on = False
def _relay_set(self, on):
"""Set relay state."""
if self._gpio is not None:
try:
self._gpio.output(
self._relay_pin,
self._gpio.HIGH if on else self._gpio.LOW
)
except Exception as e:
log.error("Failed to set relay: %s", e)
self._relay_on = on
def _reset_autotune_state(self):
self._autotune_heating = False
self._autotune_phase_started = None
self._autotune_phase_extreme = None
self._autotune_high_peaks = []
self._autotune_low_peaks = []
def start_autotune(self, setpoint=None, hysteresis=None, cycles=None):
with self._lock:
if setpoint is not None:
self._setpoint = float(setpoint)
self._config.set("control", "setpoint", float(setpoint))
self._autotune_target = self._setpoint
if hysteresis is not None:
self._autotune_hysteresis = float(hysteresis)
if cycles is not None:
self._autotune_cycles = int(cycles)
self._autotune_active = True
self._autotune_message = "Autotune running"
self._autotune_last_result = None
self._reset_autotune_state()
self._pid.reset()
log.info(
"Autotune started target=%.1fF hysteresis=%.1f cycles=%d",
self._autotune_target,
self._autotune_hysteresis,
self._autotune_cycles,
)
def stop_autotune(self, message="Autotune stopped"):
with self._lock:
self._autotune_active = False
self._autotune_message = message
self._relay_off()
def _update_autotune(self, temp, loop_size):
target = self._autotune_target
h = self._autotune_hysteresis
now = time.monotonic()
if self._autotune_phase_started is None:
self._autotune_phase_started = now
self._autotune_phase_extreme = temp
self._autotune_heating = temp < target
if self._autotune_heating:
if self._autotune_phase_extreme is None or temp > self._autotune_phase_extreme:
self._autotune_phase_extreme = temp
if temp >= target + h:
self._autotune_high_peaks.append((now, self._autotune_phase_extreme))
self._autotune_heating = False
self._autotune_phase_started = now
self._autotune_phase_extreme = temp
else:
if self._autotune_phase_extreme is None or temp < self._autotune_phase_extreme:
self._autotune_phase_extreme = temp
if temp <= target - h:
self._autotune_low_peaks.append((now, self._autotune_phase_extreme))
self._autotune_heating = True
self._autotune_phase_started = now
self._autotune_phase_extreme = temp
highs = len(self._autotune_high_peaks)
lows = len(self._autotune_low_peaks)
if min(highs, lows) >= self._autotune_cycles and highs >= 2:
high_vals = [v for _, v in self._autotune_high_peaks[-self._autotune_cycles:]]
low_vals = [v for _, v in self._autotune_low_peaks[-self._autotune_cycles:]]
amp = (sum(high_vals) / float(len(high_vals)) - sum(low_vals) / float(len(low_vals))) / 2.0
if amp <= 0:
self.stop_autotune("Autotune failed: invalid oscillation amplitude")
return 0.0
high_times = [t for t, _ in self._autotune_high_peaks[-self._autotune_cycles:]]
periods = []
for i in range(1, len(high_times)):
periods.append(high_times[i] - high_times[i - 1])
if not periods:
self.stop_autotune("Autotune failed: not enough periods")
return 0.0
pu = sum(periods) / float(len(periods))
if pu <= 0:
self.stop_autotune("Autotune failed: invalid period")
return 0.0
d = loop_size / 2.0
ku = (4.0 * d) / (math.pi * amp)
kp = 0.6 * ku
ki = (1.2 * ku) / pu
kd = 0.075 * ku * pu
# Safety clamp for aggressive/unstable autotune outputs.
kp = max(0.0, min(kp, 300.0))
ki = max(0.0, min(ki, 50.0))
kd = max(0.0, min(kd, 500.0))
self._pid.tunings = (kp, ki, kd)
self._pid.reset()
self._config.update_section("pid", {
"kP": round(kp, 4),
"kI": round(ki, 4),
"kD": round(kd, 4),
})
self._autotune_last_result = {
"kP": round(kp, 4),
"kI": round(ki, 4),
"kD": round(kd, 4),
"Ku": round(ku, 4),
"Pu": round(pu, 4),
"amplitude": round(amp, 4),
}
self.stop_autotune("Autotune complete")
log.info("Autotune complete: %s", self._autotune_last_result)
return 0.0
return float(loop_size if self._autotune_heating else 0.0)
def _control_loop(self):
"""
Main PID control loop. Runs in a background thread.
Each iteration is one "loop cycle" of loop_size_ms milliseconds.
Within each cycle, the relay is ON for a proportion of time equal to
the PID output, implementing time-proportional software PWM.
"""
log.info("Control loop thread started")
try:
while not self._stop_event.is_set():
# Reload config if file changed
self._config.reload_if_changed()
# Apply current PID tuning
pid_cfg = self._config.get("pid")
self._pid.tunings = (pid_cfg["kP"], pid_cfg["kI"], pid_cfg["kD"])
self._pid.proportional_on_measurement = pid_cfg.get(
"proportional_on_measurement", True
)
control_cfg = self._config.get("control")
loop_size = control_cfg["loop_size_ms"]
sleep_time = control_cfg["sleep_time"]
log_resolution = self._config.get("logging", "log_resolution")
self._pid.output_limits = (0, loop_size)
with self._lock:
self._pid.setpoint = self._setpoint
# Read temperature
temp = self._tc.read()
if temp is None:
log.error("Thermocouple read returned None, disabling for safety")
self._trip_safety("Thermocouple disconnected")
break
with self._lock:
self._temp = temp
# Safety checks
safety = self._config.get("safety")
if temp > safety["max_temp_f"]:
self._trip_safety("Temperature {:.0f}F exceeds max {}F".format(temp, safety['max_temp_f']))
break
if not self._tc.is_connected:
self._trip_safety("Thermocouple disconnected")
break
# Idle shutoff check
idle_minutes = safety.get("idle_shutoff_minutes", 0)
if idle_minutes > 0:
near_setpoint = abs(temp - self._setpoint) < 20
if near_setpoint:
if self._idle_since is None:
self._idle_since = time.monotonic()
elif (time.monotonic() - self._idle_since) > (idle_minutes * 60):
self._trip_safety(
"Idle shutoff: at setpoint for {} minutes".format(idle_minutes)
)
break
else:
self._idle_since = None
# PID compute and software PWM cycle
start_ms = time.monotonic() * 1000
end_ms = start_ms + loop_size
while time.monotonic() * 1000 < end_ms:
if self._stop_event.is_set():
break
temp = self._tc.read()
if temp is not None:
with self._lock:
self._temp = temp
if self._autotune_active:
output = self._update_autotune(temp, loop_size)
else:
output = self._pid(temp)
with self._lock:
self._output = output
# Software PWM: relay ON for first `output` ms of the cycle
elapsed_ms = time.monotonic() * 1000 - start_ms
should_be_on = elapsed_ms < output
self._relay_set(should_be_on)
# Logging
self._log_counter += 1
if self._log_counter >= log_resolution:
self._log_data_point(temp, output)
self._log_counter = 0
with self._lock:
self._last_loop_time = time.monotonic()
self._loop_count += 1
time.sleep(sleep_time)
except Exception as e:
log.exception("Control loop crashed: %s", e)
self._trip_safety("Control loop error: {}".format(e))
finally:
self._relay_off()
log.info("Control loop thread exiting, relay OFF")
def _trip_safety(self, reason):
"""Trip safety shutdown."""
log.warning("SAFETY TRIP: %s", reason)
with self._lock:
self._safety_tripped = True
self._safety_reason = reason
self._enabled = False
self._relay_off()
def _log_data_point(self, temp, output):
"""Write a data point to the CSV log and in-memory history."""
now = time.time()
row = [
"{:.3f}".format(now),
"{:.2f}".format(temp),
"{:.2f}".format(self._setpoint),
"{:.2f}".format(output),
1 if self._relay_on else 0,
"{:.4f}".format(self._pid.Kp),
"{:.4f}".format(self._pid.Ki),
"{:.4f}".format(self._pid.Kd),
self._config.get("control", "loop_size_ms")
]
# CSV file
if self._log_writer:
try:
self._log_writer.writerow(row)
self._log_file.flush()
except Exception as e:
log.error("Failed to write log: %s", e)
# In-memory history for web UI
data_point = {
"timestamp": now,
"temp": round(temp, 2),
"setpoint": round(self._setpoint, 2),
"output": round(output, 2),
"relay": self._relay_on
}
with self._lock:
self._history.append(data_point)
if len(self._history) > self._history_max:
self._history = self._history[-self._history_max:]
# --- Public API (thread-safe) ---
def set_setpoint(self, value):
"""Change the target temperature."""
with self._lock:
old_setpoint = self._setpoint
self._setpoint = float(value)
self._idle_since = None # Reset idle timer on setpoint change
if abs(self._setpoint - old_setpoint) >= 10:
self._pid.reset()
self._config.set("control", "setpoint", float(value))
log.info("Setpoint changed to %.0fF", value)
def set_pid_tuning(self, kp, ki, kd, proportional_on_measurement=None, proportional_mode=None):
"""Update PID gains."""
section = {"kP": kp, "kI": ki, "kD": kd}
if proportional_mode in ("error", "measurement"):
proportional_on_measurement = (proportional_mode == "measurement")
if proportional_on_measurement is not None:
section["proportional_on_measurement"] = bool(proportional_on_measurement)
self._pid.proportional_on_measurement = bool(proportional_on_measurement)
self._config.update_section("pid", section)
self._pid.tunings = (kp, ki, kd)
self._pid.reset()
log.info("PID tuning updated: kP=%.4f, kI=%.4f, kD=%.4f", kp, ki, kd)
@property
def status(self):
"""Return a dict of current controller state (thread-safe snapshot)."""
with self._lock:
uptime = None
if self._start_time is not None and self._enabled:
uptime = round(time.monotonic() - self._start_time, 1)
return {
"enabled": self._enabled,
"temp": round(self._temp, 2),
"setpoint": round(self._setpoint, 2),
"output": round(self._output, 2),
"relay_on": self._relay_on,
"loop_count": self._loop_count,
"uptime_seconds": uptime,
"safety_tripped": self._safety_tripped,
"safety_reason": self._safety_reason,
"thermocouple_connected": self._tc.is_connected,
"pid": {
"kP": self._pid.Kp,
"kI": self._pid.Ki,
"kD": self._pid.Kd,
"proportional_on_measurement": self._pid.proportional_on_measurement,
"proportional_mode": "measurement" if self._pid.proportional_on_measurement else "error",
},
"autotune": {
"active": self._autotune_active,
"target": round(self._autotune_target, 2),
"hysteresis": round(self._autotune_hysteresis, 2),
"cycles": self._autotune_cycles,
"high_peaks": len(self._autotune_high_peaks),
"low_peaks": len(self._autotune_low_peaks),
"phase": "heating" if self._autotune_heating else "cooling",
"message": self._autotune_message,
"last_result": self._autotune_last_result,
},
}
@property
def autotune_status(self):
with self._lock:
return {
"active": self._autotune_active,
"target": round(self._autotune_target, 2),
"hysteresis": round(self._autotune_hysteresis, 2),
"cycles": self._autotune_cycles,
"high_peaks": len(self._autotune_high_peaks),
"low_peaks": len(self._autotune_low_peaks),
"phase": "heating" if self._autotune_heating else "cooling",
"message": self._autotune_message,
"last_result": self._autotune_last_result,
}
@property
def history(self):
"""Return recent data points for charting."""
with self._lock:
return list(self._history)
def get_history_since(self, since_timestamp):
"""Return data points newer than the given timestamp."""
with self._lock:
return [p for p in self._history if p["timestamp"] > since_timestamp]
@property
def is_alive(self):
"""Check if the control loop thread is alive."""
return self._thread is not None and self._thread.is_alive()
@property
def watchdog_ok(self):
"""Check if the control loop has updated recently."""
if not self._enabled:
return True
with self._lock:
if self._last_loop_time is None:
return True
timeout = self._config.get("safety", "watchdog_timeout_s")
return (time.monotonic() - self._last_loop_time) < timeout
def cleanup(self):
"""Clean up GPIO and close log file. Call on application shutdown."""
self.stop()
if self._log_file:
try:
self._log_file.close()
except Exception:
pass
if self._gpio is not None:
try:
self._gpio.cleanup()
log.info("GPIO cleaned up")
except Exception as e:
log.error("GPIO cleanup error: %s", e)
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=piNail2 E-Nail Temperature Controller
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/piNail2
ExecStart=/usr/bin/python3 app.py
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# Safety: if the service crashes, GPIO cleanup happens in the app.
# But as an extra safety net, run a one-shot relay-off on stop.
ExecStopPost=/bin/bash -c 'echo "2" > /sys/class/gpio/unexport 2>/dev/null || true'
[Install]
WantedBy=multi-user.target
+6
View File
@@ -0,0 +1,6 @@
# piNail2 dependencies
# Compatible with Python 3.5 on Raspbian Stretch
Flask>=0.12,<1.0
simple-pid>=0.1,<2.0
RPi.GPIO>=0.6
Adafruit-GPIO>=1.0
+564
View File
@@ -0,0 +1,564 @@
/**
* piNail2 Frontend — Dashboard Controller
*
* Polls the REST API for status updates, renders a live Chart.js chart,
* and provides controls for setpoint, PID tuning, and power toggle.
*/
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let pollInterval = 500; // ms between status polls
let chartMaxPoints = 300; // max data points on chart
let lastTimestamp = 0; // for incremental history fetches
let isEnabled = false;
let currentSetpoint = 530;
let chart = null;
let lastApiError = '';
let actionBannerTimer = null;
let heartbeatMisses = 0;
let heartbeatInstanceId = null;
let controlsEnabled = true;
function nowHms() {
const d = new Date();
return d.toLocaleTimeString();
}
function setLastAck(message, ok=true) {
const el = document.getElementById('last-ack');
if (!el) return;
el.className = 'last-ack ' + (ok ? 'ok' : 'err');
el.textContent = 'Last command: ' + message + ' at ' + nowHms();
}
function showAction(message, type='info', timeoutMs=3000) {
const banner = document.getElementById('action-banner');
const msg = document.getElementById('action-message');
if (!banner || !msg) return;
msg.textContent = message;
banner.className = 'action-banner ' + type;
if (actionBannerTimer) clearTimeout(actionBannerTimer);
if (timeoutMs > 0) {
actionBannerTimer = setTimeout(function() {
banner.className = 'action-banner hidden';
}, timeoutMs);
}
}
function setControlsEnabled(enabled) {
if (controlsEnabled === enabled) return;
controlsEnabled = enabled;
const btns = document.querySelectorAll('button');
btns.forEach(function(b) {
if (b.id === 'autotune-stop-btn' && enabled) {
// stop button is governed by autotune state in setAutotuneUi()
return;
}
b.disabled = !enabled;
});
}
function setBackendStatus(mode, text) {
const el = document.getElementById('backend-status');
if (!el) return;
el.className = 'backend-status ' + mode;
el.textContent = text;
}
function setAutotuneUi(tune) {
const statusEl = document.getElementById('autotune-status');
const startBtn = document.getElementById('autotune-start-btn');
const stopBtn = document.getElementById('autotune-stop-btn');
const pill = document.getElementById('autotune-pill');
if (!statusEl || !startBtn || !stopBtn) return;
if (tune && tune.active) {
statusEl.className = 'autotune-status running';
const phase = tune.phase ? (' ' + tune.phase) : '';
statusEl.textContent = 'Running' + phase + ' (' + tune.high_peaks + '/' + tune.cycles + ' peaks)';
if (pill) {
pill.className = 'autotune-pill running';
pill.textContent = 'Autotune: Running' + phase;
}
startBtn.disabled = true;
stopBtn.disabled = false;
return;
}
startBtn.disabled = false;
stopBtn.disabled = true;
if (tune && tune.last_result) {
statusEl.className = 'autotune-status done';
statusEl.textContent = 'Complete: kP ' + tune.last_result.kP + ', kI ' + tune.last_result.kI + ', kD ' + tune.last_result.kD;
if (pill) {
pill.className = 'autotune-pill done';
pill.textContent = 'Autotune: Complete';
}
} else if (tune && tune.message) {
const lower = String(tune.message).toLowerCase();
statusEl.className = 'autotune-status ' + (lower.indexOf('failed') >= 0 ? 'error' : 'idle');
statusEl.textContent = tune.message;
if (pill) {
const failed = lower.indexOf('failed') >= 0 || lower.indexOf('error') >= 0;
pill.className = 'autotune-pill ' + (failed ? 'error' : 'idle');
pill.textContent = failed ? 'Autotune: Error' : 'Autotune: Idle';
}
} else {
statusEl.className = 'autotune-status idle';
statusEl.textContent = 'Idle';
if (pill) {
pill.className = 'autotune-pill idle';
pill.textContent = 'Autotune: Idle';
}
}
}
// ---------------------------------------------------------------------------
// Chart Setup
// ---------------------------------------------------------------------------
function initChart() {
const ctx = document.getElementById('temp-chart').getContext('2d');
chart = new Chart(ctx, {
type: 'line',
data: {
datasets: [
{
label: 'Temperature (F)',
borderColor: '#ff6b35',
backgroundColor: 'rgba(255, 107, 53, 0.1)',
borderWidth: 2,
pointRadius: 0,
fill: true,
data: [],
yAxisID: 'y'
},
{
label: 'Setpoint (F)',
borderColor: '#4ecdc4',
borderWidth: 1.5,
borderDash: [5, 5],
pointRadius: 0,
fill: false,
data: [],
yAxisID: 'y'
},
{
label: 'Output',
borderColor: '#45b7d1',
backgroundColor: 'rgba(69, 183, 209, 0.1)',
borderWidth: 1,
pointRadius: 0,
fill: true,
data: [],
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false
},
scales: {
x: {
type: 'linear',
display: true,
title: { display: false },
ticks: {
color: '#888',
callback: function(value) {
// Show relative seconds
return Math.round(value) + 's';
},
maxTicksLimit: 10
},
grid: { color: 'rgba(255,255,255,0.05)' }
},
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: 'Temperature (F)', color: '#aaa' },
ticks: { color: '#ff6b35' },
grid: { color: 'rgba(255,255,255,0.05)' },
suggestedMin: 0,
suggestedMax: 700
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: { display: true, text: 'PID Output', color: '#aaa' },
ticks: { color: '#45b7d1' },
grid: { drawOnChartArea: false },
suggestedMin: 0
}
},
plugins: {
legend: {
labels: { color: '#ccc', boxWidth: 12 }
}
}
}
});
}
// ---------------------------------------------------------------------------
// Data Update
// ---------------------------------------------------------------------------
let firstTimestamp = null;
function addChartData(points) {
if (!chart || !points.length) return;
if (firstTimestamp === null) {
firstTimestamp = points[0].timestamp;
}
for (const p of points) {
const x = p.timestamp - firstTimestamp; // relative seconds
chart.data.datasets[0].data.push({ x: x, y: p.temp });
chart.data.datasets[1].data.push({ x: x, y: p.setpoint });
chart.data.datasets[2].data.push({ x: x, y: p.output });
}
// Trim to max points
for (const ds of chart.data.datasets) {
if (ds.data.length > chartMaxPoints) {
ds.data = ds.data.slice(ds.data.length - chartMaxPoints);
}
}
chart.update('none'); // skip animation for performance
}
// ---------------------------------------------------------------------------
// API Calls
// ---------------------------------------------------------------------------
async function fetchJSON(url, options) {
try {
const resp = await fetch(url, options);
const payload = await resp.json();
if (!resp.ok) {
throw new Error(payload.error || ('HTTP ' + resp.status));
}
lastApiError = '';
return payload;
} catch (e) {
console.error('API error:', url, e);
lastApiError = String(e);
if (String(e).indexOf('HTTP') >= 0) {
// API-level error, connection is still fine.
} else {
setConnectionStatus(false);
}
return null;
}
}
async function pollStatus() {
const status = await fetchJSON('/api/status');
if (!status) return;
setConnectionStatus(true);
// Temperature display
const tempEl = document.getElementById('current-temp');
tempEl.textContent = status.temp.toFixed(1);
// Color the temp based on proximity to setpoint
const delta = Math.abs(status.temp - status.setpoint);
if (!status.enabled) {
tempEl.className = 'temp-value';
} else if (delta < 15) {
tempEl.className = 'temp-value temp-good';
} else if (delta < 50) {
tempEl.className = 'temp-value temp-warming';
} else {
tempEl.className = 'temp-value temp-cold';
}
// Setpoint display
document.getElementById('current-setpoint').textContent = status.setpoint.toFixed(0);
currentSetpoint = status.setpoint;
// Power button
isEnabled = status.enabled;
const powerBtn = document.getElementById('power-btn');
if (isEnabled) {
powerBtn.textContent = 'ON';
powerBtn.className = 'power-btn on';
} else {
powerBtn.textContent = 'OFF';
powerBtn.className = 'power-btn off';
}
// Safety banner
const banner = document.getElementById('safety-banner');
if (status.safety_tripped) {
banner.classList.remove('hidden');
document.getElementById('safety-message').textContent =
'SAFETY TRIP: ' + status.safety_reason;
} else {
banner.classList.add('hidden');
}
// Status bar
document.getElementById('status-output').textContent =
status.output.toFixed(1) + ' / ' + (status.config.loop_size_ms || '?');
const relayEl = document.getElementById('status-relay');
relayEl.textContent = status.relay_on ? 'ON' : 'OFF';
relayEl.className = 'value ' + (status.relay_on ? 'relay-on' : 'relay-off');
if (status.uptime_seconds !== null) {
const mins = Math.floor(status.uptime_seconds / 60);
const secs = Math.floor(status.uptime_seconds % 60);
document.getElementById('status-uptime').textContent =
mins + 'm ' + secs + 's';
} else {
document.getElementById('status-uptime').textContent = '--';
}
document.getElementById('status-loops').textContent = status.loop_count;
const tcEl = document.getElementById('status-tc');
tcEl.textContent = status.thermocouple_connected ? 'OK' : 'DISCONNECTED';
tcEl.className = 'value ' + (status.thermocouple_connected ? 'tc-ok' : 'tc-err');
// PID fields (only update if user isn't focused on them)
if (document.activeElement.id !== 'pid-kp')
document.getElementById('pid-kp').value = status.pid.kP;
if (document.activeElement.id !== 'pid-ki')
document.getElementById('pid-ki').value = status.pid.kI;
if (document.activeElement.id !== 'pid-kd')
document.getElementById('pid-kd').value = status.pid.kD;
if (document.activeElement.id !== 'pid-pmode') {
const mode = status.pid.proportional_mode || (status.pid.proportional_on_measurement ? 'measurement' : 'error');
document.getElementById('pid-pmode').value = mode;
}
const tune = status.autotune || {};
setAutotuneUi(tune);
// Setpoint input (only update if user isn't focused)
if (document.activeElement.id !== 'setpoint-input')
document.getElementById('setpoint-input').value = status.setpoint;
// Presets
if (status.presets) {
renderPresets(status.presets);
}
}
async function pollHistory() {
const data = await fetchJSON('/api/history?since=' + lastTimestamp);
if (!data || !data.length) return;
lastTimestamp = data[data.length - 1].timestamp;
addChartData(data);
}
function setConnectionStatus(connected) {
const dot = document.getElementById('connection-status');
if (connected) {
dot.className = 'status-dot connected';
dot.title = 'Connected';
setBackendStatus('online', 'Backend: Online');
} else {
dot.className = 'status-dot disconnected';
dot.title = 'Disconnected';
setBackendStatus('offline', 'Backend: Offline');
}
}
async function pollHeartbeat() {
const hb = await fetchJSON('/api/heartbeat?ts=' + Date.now());
if (!hb || !hb.ok) {
heartbeatMisses += 1;
if (heartbeatMisses >= 2) {
setBackendStatus('reconnecting', 'Backend: Reconnecting...');
setControlsEnabled(false);
}
return;
}
if (heartbeatMisses >= 2) {
showAction('Backend reconnected.', 'success', 2500);
}
heartbeatMisses = 0;
setConnectionStatus(true);
setControlsEnabled(true);
if (heartbeatInstanceId === null) {
heartbeatInstanceId = hb.instance_id;
} else if (heartbeatInstanceId !== hb.instance_id) {
showAction('Backend restarted. Reloading UI...', 'info', 1500);
setTimeout(function() { window.location.reload(); }, 1200);
}
}
// ---------------------------------------------------------------------------
// User Actions
// ---------------------------------------------------------------------------
async function togglePower() {
const result = await fetchJSON('/api/power', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !isEnabled })
});
if (!result) {
setLastAck('power failed', false);
return;
}
setLastAck('power ' + (result.enabled ? 'ON' : 'OFF'), true);
// Reset chart on power toggle
if (!isEnabled) {
firstTimestamp = null;
lastTimestamp = 0;
if (chart) {
for (const ds of chart.data.datasets) ds.data = [];
chart.update('none');
}
}
}
async function applySetpoint() {
const value = parseFloat(document.getElementById('setpoint-input').value);
if (isNaN(value)) return;
const result = await fetchJSON('/api/setpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ setpoint: value })
});
if (!result) {
setLastAck('setpoint failed', false);
return;
}
setLastAck('setpoint ' + value + 'F', true);
}
function adjustSetpoint(delta) {
const input = document.getElementById('setpoint-input');
const newVal = parseFloat(input.value) + delta;
input.value = newVal;
applySetpoint();
}
async function applyPID() {
const kp = parseFloat(document.getElementById('pid-kp').value);
const ki = parseFloat(document.getElementById('pid-ki').value);
const kd = parseFloat(document.getElementById('pid-kd').value);
const pMode = document.getElementById('pid-pmode').value;
if (isNaN(kp) || isNaN(ki) || isNaN(kd)) return;
const result = await fetchJSON('/api/pid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kP: kp, kI: ki, kD: kd, proportional_mode: pMode })
});
if (!result) {
setLastAck('PID apply failed', false);
return;
}
setLastAck('PID applied (' + pMode + ')', true);
}
async function resetPID() {
const result = await fetchJSON('/api/pid/reset', { method: 'POST' });
if (!result) {
setLastAck('PID reset failed', false);
return;
}
setLastAck('PID reset', true);
}
async function startAutotune() {
const target = parseFloat(document.getElementById('setpoint-input').value);
showAction('Starting autotune at ' + target + 'F (auto-enables heater if needed)...', 'info', 5000);
setAutotuneUi({ message: 'Starting autotune...', last_result: null, active: true, high_peaks: 0, cycles: 0 });
const result = await fetchJSON('/api/autotune/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
setpoint: target
})
});
if (!result) {
setAutotuneUi({ message: lastApiError || 'Failed to start autotune', active: false, last_result: null });
showAction(lastApiError || 'Failed to start autotune', 'error', 6000);
setLastAck('autotune start failed', false);
return;
}
showAction('Autotune started. Watch peaks progress.', 'success', 5000);
setAutotuneUi(result.autotune || { message: 'Autotune started', active: true });
setLastAck('autotune started', true);
}
async function stopAutotune() {
setAutotuneUi({ message: 'Stopping autotune...', last_result: null, active: false });
showAction('Stopping autotune...', 'info', 3000);
const result = await fetchJSON('/api/autotune/stop', { method: 'POST' });
if (!result) {
setAutotuneUi({ message: lastApiError || 'Failed to stop autotune', active: false, last_result: null });
showAction(lastApiError || 'Failed to stop autotune', 'error', 6000);
setLastAck('autotune stop failed', false);
return;
}
showAction('Autotune stopped.', 'success', 4000);
setAutotuneUi(result.autotune || { message: 'Autotune stopped', active: false, last_result: null });
setLastAck('autotune stopped', true);
}
async function applyPreset(name) {
const result = await fetchJSON('/api/preset/' + encodeURIComponent(name), { method: 'POST' });
if (!result) {
setLastAck('preset failed', false);
return;
}
setLastAck('preset ' + name, true);
}
async function resetSafety() {
const result = await fetchJSON('/api/safety/reset', { method: 'POST' });
if (!result) {
setLastAck('safety reset failed', false);
return;
}
setLastAck('safety reset', true);
}
function renderPresets(presets) {
const container = document.getElementById('presets-container');
const buttons = Object.entries(presets).map(([name, temp]) =>
'<button class="preset-btn" onclick="applyPreset(\'' +
name.replace(/'/g, "\\'") + '\')">' + name + ' (' + temp + '&deg;F)</button>'
).join('');
container.innerHTML = buttons;
}
// Handle Enter key in setpoint input
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('setpoint-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') applySetpoint();
});
setAutotuneUi({ active: false, message: 'Idle', last_result: null });
});
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
initChart();
// Start polling loops
setInterval(pollStatus, pollInterval);
setInterval(pollHistory, pollInterval);
setInterval(pollHeartbeat, 2000);
// Initial fetch
pollStatus();
pollHistory();
pollHeartbeat();
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1087" height="220" viewBox="0 0 1087.330 220.000">
<title>PINAIL</title>
<g transform="translate(0.000,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
<ns0:path d="M52.509,125.877v-29.491h70.012c10.707,0,19.062-3.034,25.056-9.111,5.994-6.072,8.991-14.063,8.991-23.977,0-10.069-2.997-18.061-8.991-23.977-5.994-5.911-14.349-8.871-25.056-8.871H30.21v143.859H0V0h122.521c10.069,0,19.062,1.479,26.974,4.436,7.912,2.96,14.584,7.114,20.021,12.468,5.432,5.357,9.59,11.868,12.468,19.541,2.877,7.673,4.315,16.304,4.315,25.895,0,9.434-1.438,18.024-4.315,25.775-2.878,7.755-7.036,14.427-12.468,20.021-5.437,5.597-12.108,9.95-20.021,13.067-7.912,3.116-16.904,4.675-26.974,4.675H52.509Z" fill="#D35400" />
</ns0:g>
</g><g transform="translate(251.130,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
<ns0:path d="M0,174.31V0h30.45v174.31H0Z" fill="#D35400" />
</ns0:g>
</g><g transform="translate(305.562,0.000) scale(1.231624) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
<ns0:path d="M29.012,48.912v127.556H0V18.223c0-5.594,1.397-10.029,4.196-13.307C6.991,1.642,10.79,0,15.585,0c2.236,0,4.395.479,6.474,1.438,2.075.96,4.233,2.56,6.474,4.796l123,122.041V.72h29.012v159.684c0,5.755-1.401,10.231-4.196,13.427-2.798,3.196-6.436,4.796-10.909,4.796-4.956,0-9.591-2.158-13.906-6.474L29.012,48.912Z" fill="#D35400" />
</ns0:g>
</g><g transform="translate(543.924,0.000) scale(1.244992) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
<ns0:path d="M193.491,176.708l-26.134-43.877h-82.479l14.386-24.696h53.468l-38.842-65.217L34.766,176.708H0L100.222,9.831c1.757-3.035,3.836-5.433,6.234-7.193,2.397-1.757,5.274-2.638,8.631-2.638s6.193.881,8.512,2.638c2.315,1.761,4.353,4.158,6.114,7.193l100.462,166.877h-36.685Z" fill="#D35400" />
</ns0:g>
</g><g transform="translate(846.490,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
<ns0:path d="M0,174.31V0h30.45v174.31H0Z" fill="#D35400" />
</ns0:g>
</g><g transform="translate(900.922,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
<ns0:path d="M0,174.31V0h30.45v143.859h117.245v30.45H0Z" fill="#D35400" />
</ns0:g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+571
View File
@@ -0,0 +1,571 @@
/* piNail2 — Dark theme dashboard */
:root {
--bg: #1a1a1a;
--bg-card: #252525;
--bg-input: #2a2a2a;
--text: #e0e0e0;
--text-dim: #cccccc;
--accent-orange: #d35400;
--accent-orange-hover: #e65c00;
--accent-orange-deep: #a84300;
--accent-teal: #d35400;
--accent-blue: #d35400;
--accent-red: #e74c3c;
--accent-green: #2ecc71;
--border: #333333;
--radius: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
background: radial-gradient(circle at 20% 0%, #222 0%, #1a1a1a 35%, #000 100%);
color: var(--text);
min-height: 100vh;
}
/* Header */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: #000;
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
height: 34px;
width: auto;
max-width: 42vw;
border: none;
border-radius: 0;
object-fit: contain;
}
.conn-wrap {
display: flex;
align-items: center;
gap: 10px;
}
.backend-status {
font-size: 0.8rem;
color: var(--text-dim);
}
.last-ack {
font-size: 0.8rem;
color: var(--text-dim);
}
.last-ack.ok {
color: var(--accent-green);
}
.last-ack.err {
color: var(--accent-red);
}
.backend-status.online { color: var(--accent-green); }
.backend-status.reconnecting { color: var(--accent-orange); }
.backend-status.offline { color: var(--accent-red); }
header h1 {
font-size: 1rem;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.status-dot.connected { background: var(--accent-green); }
.status-dot.disconnected { background: var(--accent-red); }
/* Main */
main {
max-width: 900px;
margin: 0 auto;
padding: 16px;
}
/* Hero: temp + power */
.hero {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px 30px;
margin-bottom: 12px;
border: 1px solid var(--border);
}
.temp-display {
display: flex;
align-items: baseline;
}
.temp-value {
font-size: 4rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
transition: color 0.3s;
}
.temp-unit {
font-size: 1.5rem;
color: var(--text-dim);
margin-left: 4px;
}
.temp-good { color: var(--accent-green); }
.temp-warming { color: var(--accent-orange); }
.temp-cold { color: var(--accent-blue); }
.hero-right {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.autotune-pill {
font-size: 0.8rem;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border);
letter-spacing: 0.02em;
}
.autotune-pill.idle {
color: var(--text-dim);
background: rgba(255, 255, 255, 0.05);
}
.autotune-pill.running {
color: #111;
background: var(--accent-orange);
border-color: var(--accent-orange);
animation: autotunePulse 1.2s infinite;
}
.autotune-pill.done {
color: #111;
background: var(--accent-green);
border-color: var(--accent-green);
}
.autotune-pill.error {
color: #fff;
background: var(--accent-red);
border-color: var(--accent-red);
}
.setpoint-display {
font-size: 1.1rem;
color: var(--accent-teal);
}
.power-btn {
width: 80px;
height: 80px;
border-radius: 50%;
border: 3px solid;
font-size: 1.2rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.power-btn.off {
background: transparent;
border-color: var(--text-dim);
color: var(--text-dim);
}
.power-btn.on {
background: rgba(46, 204, 113, 0.15);
border-color: var(--accent-green);
color: var(--accent-green);
box-shadow: 0 0 20px rgba(46, 204, 113, 0.3);
}
.power-btn:hover { opacity: 0.8; }
.power-btn:active { transform: scale(0.95); }
/* Safety Banner */
.safety-banner {
background: rgba(231, 76, 60, 0.15);
border: 1px solid var(--accent-red);
border-radius: var(--radius);
padding: 12px 20px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--accent-red);
font-weight: 600;
}
.safety-banner.hidden { display: none; }
.safety-banner button {
background: var(--accent-red);
color: white;
border: none;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.action-banner {
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
font-weight: 600;
}
.action-banner.hidden { display: none; }
.action-banner.info { border-color: var(--accent-blue); color: var(--accent-blue); }
.action-banner.success { border-color: var(--accent-green); color: var(--accent-green); }
.action-banner.error { border-color: var(--accent-red); color: var(--accent-red); }
/* Chart */
.chart-section {
background: var(--bg-card);
border-radius: var(--radius);
border: 1px solid var(--border);
padding: 12px;
margin-bottom: 12px;
height: 280px;
}
.chart-section {
box-shadow: inset 0 0 0 1px rgba(211, 84, 0, 0.12);
}
.chart-section canvas {
width: 100% !important;
height: 100% !important;
}
/* Controls */
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.control-group {
background: var(--bg-card);
border-radius: var(--radius);
border: 1px solid var(--border);
padding: 16px;
}
.control-group h3 {
font-size: 0.85rem;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 12px;
letter-spacing: 0.05em;
}
/* Setpoint controls */
.setpoint-controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.adj-btn {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
}
.adj-btn:hover { background: var(--accent-orange-deep); color: #fff; }
.adj-btn:active { transform: scale(0.95); }
input[type="number"] {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
padding: 8px 10px;
border-radius: 4px;
width: 80px;
text-align: center;
font-size: 1rem;
font-family: inherit;
}
select {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
padding: 8px 10px;
border-radius: 4px;
font-size: 0.9rem;
font-family: inherit;
}
select:focus {
outline: none;
border-color: var(--accent-teal);
}
input[type="number"]:focus {
outline: none;
border-color: var(--accent-teal);
}
.apply-btn {
background: var(--accent-teal);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 700;
font-size: 0.9rem;
}
.apply-btn:hover { background: var(--accent-orange-hover); opacity: 1; }
.apply-btn:active { transform: scale(0.95); }
button:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none !important;
}
/* Presets */
.presets {
margin-top: 10px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.preset-btn {
background: var(--bg-input);
color: var(--accent-teal);
border: 1px solid var(--accent-teal);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.preset-btn:hover {
background: var(--accent-teal);
color: #000;
}
/* PID controls */
.pid-controls {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.pid-controls label {
display: flex;
flex-direction: column;
font-size: 0.8rem;
color: var(--text-dim);
gap: 4px;
}
.checkbox-label {
flex-direction: row !important;
align-items: center;
gap: 6px;
color: var(--text);
}
.checkbox-label input {
width: auto;
}
.autotune-controls {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.autotune-status {
font-size: 0.85rem;
color: var(--text-dim);
font-weight: 600;
}
.autotune-status.idle {
color: var(--text-dim);
}
.autotune-status.running {
color: var(--accent-orange);
animation: autotunePulse 1.2s infinite;
}
.autotune-status.done {
color: var(--accent-green);
}
.autotune-status.error {
color: var(--accent-red);
}
@keyframes autotunePulse {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
.pid-controls input {
width: 70px;
}
/* Status Bar */
.status-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.status-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 16px;
flex: 1;
min-width: 100px;
text-align: center;
}
.status-item .label {
display: block;
font-size: 0.7rem;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 4px;
}
.status-item .value {
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.app-footer {
margin-top: 14px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(0, 0, 0, 0.4);
color: var(--text-dim);
font-size: 0.78rem;
display: flex;
justify-content: space-between;
gap: 10px;
}
.relay-on { color: var(--accent-green); }
.relay-off { color: var(--text-dim); }
.tc-ok { color: var(--accent-green); }
.tc-err { color: var(--accent-red); }
/* Mobile responsive */
@media (max-width: 600px) {
.hero {
flex-direction: column;
text-align: center;
gap: 16px;
padding: 16px;
}
.brand-logo {
height: 28px;
max-width: 46vw;
}
header h1 {
display: none;
}
.app-footer {
flex-direction: column;
text-align: center;
}
.hero-right {
align-items: center;
}
.temp-value {
font-size: 3rem;
}
.power-btn {
width: 64px;
height: 64px;
font-size: 1rem;
}
.controls {
grid-template-columns: 1fr;
}
.chart-section {
height: 220px;
}
.setpoint-controls {
justify-content: center;
}
.pid-controls {
justify-content: center;
}
.status-bar {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>piNail2 Controller</title>
<link rel="icon" type="image/png" href="/static/img/pi_favicon.png">
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</head>
<body>
<header>
<div class="brand">
<img class="brand-logo" src="/static/img/pinail_logo.svg" alt="PINAIL logo">
<h1>Controller</h1>
</div>
<div class="conn-wrap">
<span id="last-ack" class="last-ack">Last command: none</span>
<span id="backend-status" class="backend-status">Backend: Online</span>
<div id="connection-status" class="status-dot connected" title="Connected"></div>
</div>
</header>
<main>
<!-- Top row: Big temp display + power toggle -->
<section class="hero">
<div class="temp-display">
<span id="current-temp" class="temp-value">---</span>
<span class="temp-unit">&deg;F</span>
</div>
<div class="hero-right">
<div class="setpoint-display">
Target: <span id="current-setpoint">---</span>&deg;F
</div>
<div id="autotune-pill" class="autotune-pill idle">Autotune: Idle</div>
<button id="power-btn" class="power-btn off" onclick="togglePower()">OFF</button>
</div>
</section>
<!-- Safety alert banner (hidden by default) -->
<section id="safety-banner" class="safety-banner hidden">
<span id="safety-message"></span>
<button onclick="resetSafety()">Reset</button>
</section>
<section id="action-banner" class="action-banner hidden">
<span id="action-message"></span>
</section>
<!-- Chart -->
<section class="chart-section">
<canvas id="temp-chart"></canvas>
</section>
<!-- Controls -->
<section class="controls">
<!-- Setpoint -->
<div class="control-group">
<h3>Setpoint</h3>
<div class="setpoint-controls">
<button class="adj-btn" onclick="adjustSetpoint(-10)">-10</button>
<button class="adj-btn" onclick="adjustSetpoint(-5)">-5</button>
<input type="number" id="setpoint-input" value="530" min="0" max="800" step="5">
<button class="adj-btn" onclick="adjustSetpoint(+5)">+5</button>
<button class="adj-btn" onclick="adjustSetpoint(+10)">+10</button>
<button class="apply-btn" onclick="applySetpoint()">Set</button>
</div>
<div class="presets" id="presets-container">
<!-- Filled by JS -->
</div>
</div>
<!-- PID Tuning -->
<div class="control-group">
<h3>PID Tuning</h3>
<div class="pid-controls">
<label>
kP
<input type="number" id="pid-kp" step="0.1" value="10">
</label>
<label>
kI
<input type="number" id="pid-ki" step="0.1" value="5">
</label>
<label>
kD
<input type="number" id="pid-kd" step="0.1" value="1">
</label>
<label>
P Mode
<select id="pid-pmode">
<option value="error">P-on-Error</option>
<option value="measurement">P-on-Measurement</option>
</select>
</label>
<button class="apply-btn" onclick="applyPID()">Apply</button>
<button class="apply-btn" onclick="resetPID()">Reset I</button>
</div>
<div class="autotune-controls">
<button id="autotune-start-btn" class="apply-btn" onclick="startAutotune()">Start Autotune</button>
<button id="autotune-stop-btn" class="adj-btn" onclick="stopAutotune()">Stop Autotune</button>
<span id="autotune-status" class="autotune-status idle">Idle</span>
</div>
</div>
</section>
<!-- Status bar -->
<section class="status-bar">
<div class="status-item">
<span class="label">Output</span>
<span id="status-output" class="value">0</span>
</div>
<div class="status-item">
<span class="label">Relay</span>
<span id="status-relay" class="value relay-off">OFF</span>
</div>
<div class="status-item">
<span class="label">Uptime</span>
<span id="status-uptime" class="value">--</span>
</div>
<div class="status-item">
<span class="label">Loops</span>
<span id="status-loops" class="value">0</span>
</div>
<div class="status-item">
<span class="label">TC</span>
<span id="status-tc" class="value">--</span>
</div>
</section>
<footer class="app-footer">
<span>{{ app_version }}</span>
<span>Copyright &copy; {{ copyright_year }} SethPC Labs</span>
</footer>
</main>
<script src="/static/app.js"></script>
</body>
</html>
+158
View File
@@ -0,0 +1,158 @@
"""
piNail2 Thermocouple Driver
Wraps the MAX6675 thermocouple reader with:
- Celsius to Fahrenheit conversion
- Spike/outlier filtering (median of recent readings)
- Open thermocouple detection
- Error handling
"""
import logging
import collections
log = logging.getLogger(__name__)
def c_to_f(c):
"""Convert Celsius to Fahrenheit."""
return c * 9.0 / 5.0 + 32.0
class Thermocouple:
"""
MAX6675 thermocouple reader with spike filtering.
The MAX6675 occasionally returns spurious readings (we've seen 869F spikes
in otherwise stable ~680F data). This class maintains a sliding window of
recent readings and rejects outliers.
"""
def __init__(self, clk, cs, do, spike_threshold=50.0, window_size=5):
"""
Args:
clk: GPIO pin for SPI clock
cs: GPIO pin for chip select
do: GPIO pin for data out (MISO)
spike_threshold: Maximum allowed jump between filtered readings (in F)
window_size: Number of recent readings to keep for median filtering
"""
self._spike_threshold = spike_threshold
self._window_size = window_size
self._readings = collections.deque(maxlen=window_size)
self._last_good_temp = None
self._raw_temp = 0.0
self._filtered_temp = 0.0
self._is_connected = False
self._spike_count = 0
self._total_reads = 0
try:
import MAX6675.MAX6675 as MAX6675
self._sensor = MAX6675.MAX6675(clk, cs, do)
self._is_connected = True
log.info("MAX6675 thermocouple initialized (CLK=%d, CS=%d, DO=%d)", clk, cs, do)
except Exception as e:
log.error("Failed to initialize MAX6675: %s", e)
self._sensor = None
self._is_connected = False
def read(self):
"""
Read temperature from the thermocouple.
Returns:
float: Filtered temperature in Fahrenheit, or None if sensor is disconnected.
"""
if self._sensor is None:
return None
self._total_reads += 1
try:
raw_c = self._sensor.readTempC()
except Exception as e:
log.error("Thermocouple read error: %s", e)
self._is_connected = False
return self._last_good_temp
# Check for open thermocouple (MAX6675 returns very high values or specific error codes)
if raw_c is None or raw_c > 1023:
log.warning("Thermocouple appears disconnected (raw_c=%s)", raw_c)
self._is_connected = False
return self._last_good_temp
self._is_connected = True
raw_f = c_to_f(raw_c)
self._raw_temp = raw_f
# Spike detection: if we have a previous good reading, check for unreasonable jumps
if self._last_good_temp is not None:
delta = abs(raw_f - self._last_good_temp)
if delta > self._spike_threshold:
self._spike_count += 1
log.warning(
"Spike detected: %.1fF -> %.1fF (delta=%.1fF, count=%d). Using last good value.",
self._last_good_temp, raw_f, delta, self._spike_count
)
# Still add to window but return last good — if multiple consecutive
# readings are in the "spike" range, they'll become the new normal
# via the median filter
self._readings.append(raw_f)
# If we've had many consecutive "spikes", the temperature probably
# genuinely changed fast (e.g., touching the nail with concentrate)
if self._spike_count >= self._window_size:
log.info("Spike count exceeded window size, accepting new baseline %.1fF", raw_f)
self._spike_count = 0
self._last_good_temp = raw_f
self._filtered_temp = raw_f
return raw_f
self._filtered_temp = self._last_good_temp
return self._last_good_temp
# Normal reading — reset spike counter, add to window
self._spike_count = 0
self._readings.append(raw_f)
# Median filter
if len(self._readings) >= 3:
sorted_readings = sorted(self._readings)
median = sorted_readings[len(sorted_readings) // 2]
self._filtered_temp = median
self._last_good_temp = median
else:
self._filtered_temp = raw_f
self._last_good_temp = raw_f
return self._filtered_temp
@property
def raw_temp(self):
"""Last raw (unfiltered) temperature reading in Fahrenheit."""
return self._raw_temp
@property
def filtered_temp(self):
"""Last filtered temperature reading in Fahrenheit."""
return self._filtered_temp
@property
def is_connected(self):
"""Whether the thermocouple appears to be connected."""
return self._is_connected
@property
def spike_count(self):
"""Number of spike events detected since last normal reading."""
return self._spike_count
@property
def stats(self):
"""Return diagnostic stats."""
return {
"raw_temp": round(self._raw_temp, 2),
"filtered_temp": round(self._filtered_temp, 2),
"is_connected": self._is_connected,
"total_reads": self._total_reads,
"recent_readings": [round(r, 2) for r in self._readings],
}