Release v2.0.0 with dual-nail control and hardened safety

This commit is contained in:
2026-03-12 02:06:42 +00:00
parent cd07703f67
commit c4c86747e5
10 changed files with 2046 additions and 451 deletions
+452 -36
View File
@@ -17,6 +17,8 @@ import logging
import argparse import argparse
import json import json
import time import time
import threading
import copy
from datetime import datetime from datetime import datetime
from flask import Flask, render_template, jsonify, request from flask import Flask, render_template, jsonify, request
@@ -46,8 +48,161 @@ app = Flask(__name__)
# These are set in main() before the app starts # These are set in main() before the app starts
config = None # type: Config config = None # type: Config
controller = None # type: PIDController controllers = {}
tc = None # type: Thermocouple thermocouples = {}
channel_configs = {}
NAIL_IDS = ("nail1", "nail2")
CONFIG_WRITE_LOCK = threading.Lock()
CONTROL_LIMITS = {
"loop_size_ms": {"min": 1500, "max": 5000},
"sleep_time": {"min": 0.15, "max": 0.6},
}
PID_GUARDRAILS = {
"balanced": {"kP_max": 80.0, "kI_max": 4.0, "kD_max": 50.0},
"responsive": {"kP_max": 50.0, "kI_max": 2.5, "kD_max": 25.0},
"extreme": {"kP_max": 35.0, "kI_max": 1.8, "kD_max": 15.0},
}
def classify_timing_profile(loop_size_ms, sleep_time):
"""Classify timing aggressiveness for safety guardrails."""
if loop_size_ms <= 1400 or sleep_time <= 0.14:
return "extreme"
if loop_size_ms <= 1800 or sleep_time <= 0.20:
return "responsive"
if loop_size_ms <= 2800 or sleep_time <= 0.32:
return "balanced"
return "conservative"
def clamp_pid_for_profile(profile, pid_status):
"""Clamp PID gains to guardrails for aggressive timing profiles."""
limits = PID_GUARDRAILS.get(profile)
if not limits:
return None
before = {
"kP": float(pid_status.get("kP", 0.0)),
"kI": float(pid_status.get("kI", 0.0)),
"kD": float(pid_status.get("kD", 0.0)),
}
after = {
"kP": min(before["kP"], limits["kP_max"]),
"kI": min(before["kI"], limits["kI_max"]),
"kD": min(before["kD"], limits["kD_max"]),
}
changed = (
abs(before["kP"] - after["kP"]) > 1e-9
or abs(before["kI"] - after["kI"]) > 1e-9
or abs(before["kD"] - after["kD"]) > 1e-9
)
if not changed:
return None
return {
"before": before,
"after": after,
"limits": limits,
}
def normalize_nail_id(raw_nail):
if raw_nail is None:
return "nail1"
text = str(raw_nail).strip().lower()
if text in ("1", "nail1"):
return "nail1"
if text in ("2", "nail2"):
return "nail2"
return None
def resolve_nail_id(body=None):
q_nail = request.args.get("nail")
if q_nail is not None:
nail_id = normalize_nail_id(q_nail)
if nail_id:
return nail_id
if body and "nail" in body:
nail_id = normalize_nail_id(body.get("nail"))
if nail_id:
return nail_id
return "nail1"
def get_nail_parts(nail_id):
controller = controllers.get(nail_id)
tc = thermocouples.get(nail_id)
ch_cfg = channel_configs.get(nail_id)
if controller is None or tc is None or ch_cfg is None:
return None, None, None
return controller, tc, ch_cfg
def ensure_nails_config(base_config):
nails = base_config.get("nails")
if isinstance(nails, dict) and "nail1" in nails and "nail2" in nails:
return
data = base_config.data
default_nails = copy.deepcopy(data.get("nails", {}))
nail1 = default_nails.get("nail1", {})
nail2 = default_nails.get("nail2", {})
for section in ("pid", "control", "flight", "scheduler", "safety", "logging", "autotune"):
if section in data:
nail1[section] = copy.deepcopy(data[section])
if "gpio" in data:
gpio1 = copy.deepcopy(data["gpio"])
gpio2 = copy.deepcopy(nail2.get("gpio", {}))
nail1["gpio"] = gpio1
nail2["gpio"] = {
"relay_pin": gpio2.get("relay_pin", 9),
"clk": gpio2.get("clk", 17),
"cs": gpio2.get("cs", 18),
"do": gpio2.get("do", 27),
}
default_nails["nail1"] = nail1
default_nails["nail2"] = nail2
base_config._data["nails"] = default_nails
base_config.save()
class ChannelConfigProxy:
def __init__(self, root_config, nail_id):
self._root = root_config
self._nail_id = nail_id
def _channel_dict(self):
nails = self._root._data.setdefault("nails", {})
return nails.setdefault(self._nail_id, {})
def get(self, section, key=None):
section_data = self._root._data.get("nails", {}).get(self._nail_id, {}).get(section)
if section_data is None:
section_data = self._root.get(section)
if key is None:
return copy.deepcopy(section_data if isinstance(section_data, dict) else {})
return copy.deepcopy((section_data or {}).get(key))
def set(self, section, key, value):
with CONFIG_WRITE_LOCK:
section_data = self._channel_dict().setdefault(section, {})
section_data[key] = value
self._root.save()
def update_section(self, section, values):
with CONFIG_WRITE_LOCK:
section_data = self._channel_dict().setdefault(section, {})
section_data.update(values)
self._root.save()
def reload_if_changed(self):
return self._root.reload_if_changed()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes — Pages # Routes — Pages
@@ -70,18 +225,46 @@ def index():
@app.route("/api/status") @app.route("/api/status")
def api_status(): def api_status():
"""Return current controller state.""" """Return current controller state."""
nail_id = resolve_nail_id()
controller, tc, ch_cfg = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
status = controller.status status = controller.status
status["nail"] = nail_id
status["instance_id"] = APP_INSTANCE_ID status["instance_id"] = APP_INSTANCE_ID
status["thermocouple"] = tc.stats status["thermocouple"] = tc.stats
status["presets"] = config.get("presets") status["presets"] = config.get("presets")
status["config"] = { status["config"] = {
"loop_size_ms": config.get("control", "loop_size_ms"), "loop_size_ms": ch_cfg.get("control", "loop_size_ms"),
"sleep_time": config.get("control", "sleep_time"), "sleep_time": ch_cfg.get("control", "sleep_time"),
"safety": config.get("safety"), "safety": ch_cfg.get("safety"),
} }
return jsonify(status) return jsonify(status)
@app.route("/api/status/all")
def api_status_all():
payload = {
"instance_id": APP_INSTANCE_ID,
"presets": config.get("presets"),
"nails": {},
}
for nail_id in NAIL_IDS:
controller, tc, ch_cfg = get_nail_parts(nail_id)
if controller is None:
continue
status = controller.status
status["nail"] = nail_id
status["thermocouple"] = tc.stats
status["config"] = {
"loop_size_ms": ch_cfg.get("control", "loop_size_ms"),
"sleep_time": ch_cfg.get("control", "sleep_time"),
"safety": ch_cfg.get("safety"),
}
payload["nails"][nail_id] = status
return jsonify(payload)
@app.route("/api/heartbeat") @app.route("/api/heartbeat")
def api_heartbeat(): def api_heartbeat():
"""Lightweight health endpoint for frontend reconnect logic.""" """Lightweight health endpoint for frontend reconnect logic."""
@@ -89,14 +272,18 @@ def api_heartbeat():
"ok": True, "ok": True,
"instance_id": APP_INSTANCE_ID, "instance_id": APP_INSTANCE_ID,
"ts": time.time(), "ts": time.time(),
"controller_alive": controller.is_alive, "controller_alive": {n: controllers[n].is_alive for n in controllers},
"watchdog_ok": controller.watchdog_ok, "watchdog_ok": {n: controllers[n].watchdog_ok for n in controllers},
}) })
@app.route("/api/history") @app.route("/api/history")
def api_history(): def api_history():
"""Return recent temperature history for charting.""" """Return recent temperature history for charting."""
nail_id = resolve_nail_id()
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
since = request.args.get("since", 0, type=float) since = request.args.get("since", 0, type=float)
if since > 0: if since > 0:
data = controller.get_history_since(since) data = controller.get_history_since(since)
@@ -109,24 +296,29 @@ def api_history():
def api_power(): def api_power():
"""Toggle controller on/off.""" """Toggle controller on/off."""
body = request.get_json(force=True, silent=True) or {} body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
enable = body.get("enabled") enable = body.get("enabled")
if enable is None: if enable is None:
# Toggle # Toggle
enable = not controller.status["enabled"] enable = not controller.status["enabled"]
if enable: controller.set_power(enable)
controller.start()
else:
controller.stop()
return jsonify({"enabled": enable, "ok": True}) return jsonify({"enabled": enable, "ok": True, "nail": nail_id})
@app.route("/api/setpoint", methods=["POST"]) @app.route("/api/setpoint", methods=["POST"])
def api_setpoint(): def api_setpoint():
"""Change the target temperature.""" """Change the target temperature."""
body = request.get_json(force=True, silent=True) or {} body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, ch_cfg = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
value = body.get("setpoint") value = body.get("setpoint")
if value is None: if value is None:
return jsonify({"error": "Missing 'setpoint' field"}), 400 return jsonify({"error": "Missing 'setpoint' field"}), 400
@@ -136,20 +328,196 @@ def api_setpoint():
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({"error": "Invalid setpoint value"}), 400 return jsonify({"error": "Invalid setpoint value"}), 400
safety = config.get("safety") safety = ch_cfg.get("safety")
if value > safety["max_temp_f"]: if value > safety["max_temp_f"]:
return jsonify({"error": "Setpoint {} exceeds max {}F".format(value, safety['max_temp_f'])}), 400 return jsonify({"error": "Setpoint {} exceeds max {}F".format(value, safety['max_temp_f'])}), 400
if value < safety["min_temp_f"]: if value < safety["min_temp_f"]:
return jsonify({"error": "Setpoint {} below min {}F".format(value, safety['min_temp_f'])}), 400 return jsonify({"error": "Setpoint {} below min {}F".format(value, safety['min_temp_f'])}), 400
controller.set_setpoint(value) controller.set_setpoint(value)
return jsonify({"setpoint": value, "ok": True}) return jsonify({"setpoint": value, "ok": True, "nail": nail_id})
@app.route("/api/control", methods=["POST"])
def api_control():
"""Update loop timing controls (loop_size_ms and sleep_time)."""
body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, ch_cfg = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
updates = {}
confirm_extreme = bool(body.get("confirm_extreme", False))
if "loop_size_ms" in body:
try:
loop_size_ms = int(body.get("loop_size_ms"))
except (TypeError, ValueError):
return jsonify({"error": "Invalid loop_size_ms"}), 400
if loop_size_ms < CONTROL_LIMITS["loop_size_ms"]["min"] or loop_size_ms > CONTROL_LIMITS["loop_size_ms"]["max"]:
return jsonify({
"error": "loop_size_ms out of range",
"min": CONTROL_LIMITS["loop_size_ms"]["min"],
"max": CONTROL_LIMITS["loop_size_ms"]["max"],
}), 400
updates["loop_size_ms"] = loop_size_ms
if "sleep_time" in body:
try:
sleep_time = float(body.get("sleep_time"))
except (TypeError, ValueError):
return jsonify({"error": "Invalid sleep_time"}), 400
if sleep_time < CONTROL_LIMITS["sleep_time"]["min"] or sleep_time > CONTROL_LIMITS["sleep_time"]["max"]:
return jsonify({
"error": "sleep_time out of range",
"min": CONTROL_LIMITS["sleep_time"]["min"],
"max": CONTROL_LIMITS["sleep_time"]["max"],
}), 400
updates["sleep_time"] = sleep_time
if not updates:
return jsonify({"error": "No control fields provided"}), 400
current_loop = ch_cfg.get("control", "loop_size_ms")
current_sleep = ch_cfg.get("control", "sleep_time")
new_loop = updates.get("loop_size_ms", current_loop)
new_sleep = updates.get("sleep_time", current_sleep)
profile = classify_timing_profile(new_loop, new_sleep)
if profile == "extreme" and not confirm_extreme:
return jsonify({
"error": "Extreme timing requires confirm_extreme=true",
"profile": profile,
"control": {
"loop_size_ms": new_loop,
"sleep_time": new_sleep,
},
}), 400
ch_cfg.update_section("control", updates)
pid_status = controller.status.get("pid", {})
clamp_info = clamp_pid_for_profile(profile, pid_status)
if clamp_info:
mode = pid_status.get("proportional_mode", "error")
controller.set_pid_tuning(
clamp_info["after"]["kP"],
clamp_info["after"]["kI"],
clamp_info["after"]["kD"],
None,
mode,
)
return jsonify({
"ok": True,
"control": {
"loop_size_ms": ch_cfg.get("control", "loop_size_ms"),
"sleep_time": ch_cfg.get("control", "sleep_time"),
},
"nail": nail_id,
"profile": profile,
"pid_clamped": bool(clamp_info),
"pid_guardrail": clamp_info,
"limits": CONTROL_LIMITS,
})
@app.route("/api/flight", methods=["POST"])
def api_flight():
"""Update flight behavior settings and optional mode transition."""
body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
takeoff_seconds = body.get("takeoff_seconds")
descent_seconds = body.get("descent_seconds")
turbo = body.get("turbo")
descent_target_f = body.get("descent_target_f")
mode = body.get("mode")
if takeoff_seconds is not None:
try:
takeoff_seconds = float(takeoff_seconds)
except (TypeError, ValueError):
return jsonify({"error": "Invalid takeoff_seconds"}), 400
if takeoff_seconds < 5 or takeoff_seconds > 1800:
return jsonify({"error": "takeoff_seconds out of range (5-1800)"}), 400
if descent_seconds is not None:
try:
descent_seconds = float(descent_seconds)
except (TypeError, ValueError):
return jsonify({"error": "Invalid descent_seconds"}), 400
if descent_seconds < 5 or descent_seconds > 1800:
return jsonify({"error": "descent_seconds out of range (5-1800)"}), 400
if descent_target_f is not None:
try:
descent_target_f = float(descent_target_f)
except (TypeError, ValueError):
return jsonify({"error": "Invalid descent_target_f"}), 400
controller.set_flight_config(
takeoff_seconds=takeoff_seconds,
descent_seconds=descent_seconds,
turbo=turbo,
descent_target_f=descent_target_f,
)
if mode == "takeoff":
controller.start_takeoff()
elif mode == "cruise":
controller.start_cruise()
elif mode == "descent":
controller.start_descent()
elif mode == "grounded":
controller.set_power(False)
return jsonify({"ok": True, "status": controller.status, "nail": nail_id})
@app.route("/api/scheduler", methods=["POST"])
def api_scheduler():
"""Update daily cutoff scheduler settings."""
body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
enabled = body.get("enabled")
cutoff_times = body.get("cutoff_times")
if cutoff_times is not None:
if not isinstance(cutoff_times, list):
return jsonify({"error": "cutoff_times must be a list of HH:MM strings"}), 400
normalized = []
for t in cutoff_times:
if not isinstance(t, str) or len(t) != 5 or t[2] != ":":
return jsonify({"error": "Invalid time '{}'; expected HH:MM".format(t)}), 400
hh, mm = t.split(":", 1)
if not hh.isdigit() or not mm.isdigit():
return jsonify({"error": "Invalid time '{}'; expected HH:MM".format(t)}), 400
hhv = int(hh)
mmv = int(mm)
if hhv < 0 or hhv > 23 or mmv < 0 or mmv > 59:
return jsonify({"error": "Invalid time '{}'; expected HH:MM".format(t)}), 400
normalized.append("{:02d}:{:02d}".format(hhv, mmv))
cutoff_times = sorted(list(set(normalized)))
controller.set_scheduler(enabled=enabled, cutoff_times=cutoff_times)
return jsonify({"ok": True, "scheduler": controller.status.get("scheduler"), "nail": nail_id})
@app.route("/api/pid", methods=["POST"]) @app.route("/api/pid", methods=["POST"])
def api_pid(): def api_pid():
"""Update PID tuning parameters.""" """Update PID tuning parameters."""
body = request.get_json(force=True, silent=True) or {} body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
kp = body.get("kP") kp = body.get("kP")
ki = body.get("kI") ki = body.get("kI")
@@ -171,6 +539,7 @@ def api_pid():
"kP": kp, "kP": kp,
"kI": ki, "kI": ki,
"kD": kd, "kD": kd,
"nail": nail_id,
"proportional_on_measurement": controller.status["pid"]["proportional_on_measurement"], "proportional_on_measurement": controller.status["pid"]["proportional_on_measurement"],
"proportional_mode": mode_out, "proportional_mode": mode_out,
"ok": True, "ok": True,
@@ -180,13 +549,18 @@ def api_pid():
@app.route("/api/preset/<name>", methods=["POST"]) @app.route("/api/preset/<name>", methods=["POST"])
def api_preset(name): def api_preset(name):
"""Apply a named temperature preset.""" """Apply a named temperature preset."""
body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
presets = config.get("presets") presets = config.get("presets")
if name not in presets: if name not in presets:
return jsonify({"error": "Unknown preset '{}'".format(name), "available": list(presets.keys())}), 404 return jsonify({"error": "Unknown preset '{}'".format(name), "available": list(presets.keys())}), 404
value = presets[name] value = presets[name]
controller.set_setpoint(value) controller.set_setpoint(value)
return jsonify({"preset": name, "setpoint": value, "ok": True}) return jsonify({"preset": name, "setpoint": value, "ok": True, "nail": nail_id})
@app.route("/api/presets", methods=["GET"]) @app.route("/api/presets", methods=["GET"])
@@ -236,43 +610,68 @@ def api_config():
@app.route("/api/pid/reset", methods=["POST"]) @app.route("/api/pid/reset", methods=["POST"])
def api_pid_reset(): def api_pid_reset():
"""Reset PID controller internals (clears integral windup).""" """Reset PID controller internals (clears integral windup)."""
body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
controller._pid.reset() controller._pid.reset()
return jsonify({"ok": True, "message": "PID reset"}) return jsonify({"ok": True, "message": "PID reset", "nail": nail_id})
@app.route("/api/autotune", methods=["GET"]) @app.route("/api/autotune", methods=["GET"])
def api_autotune_status(): def api_autotune_status():
"""Return current autotune state.""" """Return current autotune state."""
return jsonify(controller.autotune_status) nail_id = resolve_nail_id()
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
payload = controller.autotune_status
payload["nail"] = nail_id
return jsonify(payload)
@app.route("/api/autotune/start", methods=["POST"]) @app.route("/api/autotune/start", methods=["POST"])
def api_autotune_start(): def api_autotune_start():
"""Start relay-based PID autotune.""" """Start relay-based PID autotune."""
body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
if not controller.status.get("enabled"): if not controller.status.get("enabled"):
controller.start() controller.start()
time.sleep(0.2) time.sleep(0.2)
body = request.get_json(force=True, silent=True) or {}
setpoint = body.get("setpoint") setpoint = body.get("setpoint")
hysteresis = body.get("hysteresis") hysteresis = body.get("hysteresis")
cycles = body.get("cycles") cycles = body.get("cycles")
controller.start_autotune(setpoint=setpoint, hysteresis=hysteresis, cycles=cycles) controller.start_autotune(setpoint=setpoint, hysteresis=hysteresis, cycles=cycles)
return jsonify({"ok": True, "autotune": controller.autotune_status}) return jsonify({"ok": True, "autotune": controller.autotune_status, "nail": nail_id})
@app.route("/api/autotune/stop", methods=["POST"]) @app.route("/api/autotune/stop", methods=["POST"])
def api_autotune_stop(): def api_autotune_stop():
"""Stop PID autotune.""" """Stop PID autotune."""
body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
controller.stop_autotune("Autotune stopped by user") controller.stop_autotune("Autotune stopped by user")
return jsonify({"ok": True, "autotune": controller.autotune_status}) return jsonify({"ok": True, "autotune": controller.autotune_status, "nail": nail_id})
@app.route("/api/safety/reset", methods=["POST"]) @app.route("/api/safety/reset", methods=["POST"])
def api_safety_reset(): def api_safety_reset():
"""Reset safety trip (clears the safety flag so controller can be restarted).""" """Reset safety trip (clears the safety flag so controller can be restarted)."""
body = request.get_json(force=True, silent=True) or {}
nail_id = resolve_nail_id(body)
controller, _, _ = get_nail_parts(nail_id)
if controller is None:
return jsonify({"error": "Unknown nail"}), 404
controller._safety_tripped = False controller._safety_tripped = False
controller._safety_reason = "" controller._safety_reason = ""
return jsonify({"ok": True}) return jsonify({"ok": True, "nail": nail_id})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -282,13 +681,24 @@ def api_safety_reset():
def shutdown_handler(*args): def shutdown_handler(*args):
"""Handle SIGTERM/SIGINT gracefully.""" """Handle SIGTERM/SIGINT gracefully."""
log.info("Shutdown signal received") log.info("Shutdown signal received")
if controller: for nail_id in controllers:
controller.cleanup() try:
controllers[nail_id].cleanup()
except Exception as e:
log.error("Cleanup failed for %s: %s", nail_id, e)
sys.exit(0) sys.exit(0)
def cleanup_all():
for nail_id in controllers:
try:
controllers[nail_id].cleanup()
except Exception as e:
log.error("Cleanup failed for %s: %s", nail_id, e)
def main(): def main():
global config, controller, tc global config
parser = argparse.ArgumentParser(description="piNail2 — E-Nail Temperature Controller") parser = argparse.ArgumentParser(description="piNail2 — E-Nail Temperature Controller")
parser.add_argument("--config", default="config.json", help="Path to config file") parser.add_argument("--config", default="config.json", help="Path to config file")
@@ -297,24 +707,30 @@ def main():
# Load config # Load config
config = Config(args.config) config = Config(args.config)
log.info("Configuration loaded from %s", args.config) log.info("Configuration loaded from %s", args.config)
ensure_nails_config(config)
# Initialize thermocouple for nail_id in NAIL_IDS:
gpio_cfg = config.get("gpio") ch_cfg = ChannelConfigProxy(config, nail_id)
safety_cfg = config.get("safety") channel_configs[nail_id] = ch_cfg
tc = Thermocouple( gpio_cfg = ch_cfg.get("gpio")
clk=gpio_cfg["clk"], safety_cfg = ch_cfg.get("safety")
cs=gpio_cfg["cs"], tc = Thermocouple(
do=gpio_cfg["do"], clk=gpio_cfg["clk"],
spike_threshold=safety_cfg["spike_threshold_f"] cs=gpio_cfg["cs"],
) do=gpio_cfg["do"],
spike_threshold=safety_cfg["spike_threshold_f"]
)
thermocouples[nail_id] = tc
# Initialize PID controller controller = PIDController(ch_cfg, tc)
controller = PIDController(config, tc) controller.start_monitoring()
controllers[nail_id] = controller
log.info("Initialized %s (relay pin %s)", nail_id, gpio_cfg.get("relay_pin"))
# Register cleanup # Register cleanup
signal.signal(signal.SIGTERM, shutdown_handler) signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler) signal.signal(signal.SIGINT, shutdown_handler)
atexit.register(controller.cleanup) atexit.register(cleanup_all)
# Start Flask # Start Flask
web_cfg = config.get("web") web_cfg = config.get("web")
+130 -5
View File
@@ -1,8 +1,8 @@
{ {
"pid": { "pid": {
"kP": 10.0, "kP": 113.1768,
"kI": 5.0, "kI": 3.5335,
"kD": 1.0, "kD": 500.0,
"proportional_on_measurement": false "proportional_on_measurement": false
}, },
"control": { "control": {
@@ -11,12 +11,29 @@
"sleep_time": 0.4, "sleep_time": 0.4,
"enabled": false "enabled": false
}, },
"flight": {
"mode": "grounded",
"takeoff_seconds": 300,
"descent_seconds": 300,
"turbo": false,
"descent_target_f": 120,
"ambient_temp_f": 75
},
"scheduler": {
"enabled": true,
"cutoff_times": [
"23:00"
]
},
"safety": { "safety": {
"max_temp_f": 800, "max_temp_f": 750,
"spike_threshold_f": 50.0, "spike_threshold_f": 50.0,
"idle_shutoff_minutes": 30, "idle_shutoff_minutes": 30,
"watchdog_timeout_s": 10, "watchdog_timeout_s": 10,
"min_temp_f": 0 "min_temp_f": 0,
"sensor_stale_seconds": 8,
"sensor_stale_delta_f": 0.8,
"stale_output_ratio": 0.65
}, },
"gpio": { "gpio": {
"relay_pin": 2, "relay_pin": 2,
@@ -42,5 +59,113 @@
"autotune": { "autotune": {
"hysteresis_f": 8.0, "hysteresis_f": 8.0,
"cycles": 4 "cycles": 4
},
"nails": {
"nail1": {
"pid": {
"kP": 113.1768,
"kI": 3.5335,
"kD": 500.0,
"proportional_on_measurement": false
},
"control": {
"setpoint": 530,
"loop_size_ms": 3000,
"sleep_time": 0.4,
"enabled": false
},
"flight": {
"mode": "grounded",
"takeoff_seconds": 300,
"descent_seconds": 300,
"turbo": false,
"descent_target_f": 120,
"ambient_temp_f": 75
},
"scheduler": {
"enabled": true,
"cutoff_times": [
"23:00"
]
},
"safety": {
"max_temp_f": 750,
"spike_threshold_f": 50.0,
"idle_shutoff_minutes": 30,
"watchdog_timeout_s": 10,
"min_temp_f": 0,
"sensor_stale_seconds": 8,
"sensor_stale_delta_f": 0.8,
"stale_output_ratio": 0.65
},
"gpio": {
"relay_pin": 2,
"clk": 3,
"cs": 14,
"do": 4
},
"logging": {
"log_resolution": 1,
"log_directory": "./logs/nail1",
"max_log_lines": 10000
},
"autotune": {
"hysteresis_f": 8.0,
"cycles": 4
}
},
"nail2": {
"pid": {
"kP": 113.1768,
"kI": 3.5335,
"kD": 500.0,
"proportional_on_measurement": false
},
"control": {
"setpoint": 530,
"loop_size_ms": 3000,
"sleep_time": 0.4,
"enabled": false
},
"flight": {
"mode": "grounded",
"takeoff_seconds": 300,
"descent_seconds": 300,
"turbo": false,
"descent_target_f": 120,
"ambient_temp_f": 75
},
"scheduler": {
"enabled": true,
"cutoff_times": [
"23:00"
]
},
"safety": {
"max_temp_f": 750,
"spike_threshold_f": 50.0,
"idle_shutoff_minutes": 30,
"watchdog_timeout_s": 10,
"min_temp_f": 0,
"sensor_stale_seconds": 8,
"sensor_stale_delta_f": 0.8,
"stale_output_ratio": 0.65
},
"gpio": {
"relay_pin": 22,
"clk": 27,
"cs": 18,
"do": 17
},
"logging": {
"log_resolution": 1,
"log_directory": "./logs/nail2",
"max_log_lines": 10000
},
"autotune": {
"hysteresis_f": 8.0,
"cycles": 4
}
}
} }
} }
+130 -5
View File
@@ -14,9 +14,9 @@ log = logging.getLogger(__name__)
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"pid": { "pid": {
"kP": 10.0, "kP": 113.1768,
"kI": 5.0, "kI": 3.5335,
"kD": 1.0, "kD": 500.0,
"proportional_on_measurement": False "proportional_on_measurement": False
}, },
"control": { "control": {
@@ -25,12 +25,29 @@ DEFAULT_CONFIG = {
"sleep_time": 0.4, "sleep_time": 0.4,
"enabled": False "enabled": False
}, },
"flight": {
"mode": "grounded",
"takeoff_seconds": 300,
"descent_seconds": 300,
"turbo": False,
"descent_target_f": 120,
"ambient_temp_f": 75
},
"scheduler": {
"enabled": True,
"cutoff_times": [
"23:00"
]
},
"safety": { "safety": {
"max_temp_f": 800, "max_temp_f": 750,
"spike_threshold_f": 50.0, "spike_threshold_f": 50.0,
"idle_shutoff_minutes": 30, "idle_shutoff_minutes": 30,
"watchdog_timeout_s": 10, "watchdog_timeout_s": 10,
"min_temp_f": 0 "min_temp_f": 0,
"sensor_stale_seconds": 8,
"sensor_stale_delta_f": 0.8,
"stale_output_ratio": 0.65
}, },
"gpio": { "gpio": {
"relay_pin": 2, "relay_pin": 2,
@@ -56,6 +73,114 @@ DEFAULT_CONFIG = {
"autotune": { "autotune": {
"hysteresis_f": 8.0, "hysteresis_f": 8.0,
"cycles": 4 "cycles": 4
},
"nails": {
"nail1": {
"pid": {
"kP": 113.1768,
"kI": 3.5335,
"kD": 500.0,
"proportional_on_measurement": False
},
"control": {
"setpoint": 530,
"loop_size_ms": 3000,
"sleep_time": 0.4,
"enabled": False
},
"flight": {
"mode": "grounded",
"takeoff_seconds": 300,
"descent_seconds": 300,
"turbo": False,
"descent_target_f": 120,
"ambient_temp_f": 75
},
"scheduler": {
"enabled": True,
"cutoff_times": [
"23:00"
]
},
"safety": {
"max_temp_f": 750,
"spike_threshold_f": 50.0,
"idle_shutoff_minutes": 30,
"watchdog_timeout_s": 10,
"min_temp_f": 0,
"sensor_stale_seconds": 8,
"sensor_stale_delta_f": 0.8,
"stale_output_ratio": 0.65
},
"gpio": {
"relay_pin": 2,
"clk": 3,
"cs": 14,
"do": 4
},
"logging": {
"log_resolution": 1,
"log_directory": "./logs/nail1",
"max_log_lines": 10000
},
"autotune": {
"hysteresis_f": 8.0,
"cycles": 4
}
},
"nail2": {
"pid": {
"kP": 113.1768,
"kI": 3.5335,
"kD": 500.0,
"proportional_on_measurement": False
},
"control": {
"setpoint": 530,
"loop_size_ms": 3000,
"sleep_time": 0.4,
"enabled": False
},
"flight": {
"mode": "grounded",
"takeoff_seconds": 300,
"descent_seconds": 300,
"turbo": False,
"descent_target_f": 120,
"ambient_temp_f": 75
},
"scheduler": {
"enabled": True,
"cutoff_times": [
"23:00"
]
},
"safety": {
"max_temp_f": 750,
"spike_threshold_f": 50.0,
"idle_shutoff_minutes": 30,
"watchdog_timeout_s": 10,
"min_temp_f": 0,
"sensor_stale_seconds": 8,
"sensor_stale_delta_f": 0.8,
"stale_output_ratio": 0.65
},
"gpio": {
"relay_pin": 22,
"clk": 27,
"cs": 18,
"do": 17
},
"logging": {
"log_resolution": 1,
"log_directory": "./logs/nail2",
"max_log_lines": 10000
},
"autotune": {
"hysteresis_f": 8.0,
"cycles": 4
}
}
} }
} }
+352 -28
View File
@@ -19,7 +19,7 @@ import logging
import os import os
import csv import csv
import math import math
from datetime import datetime from datetime import datetime, timedelta
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -73,6 +73,18 @@ class PIDController:
self._autotune_last_result = None self._autotune_last_result = None
self._autotune_message = "" self._autotune_message = ""
# Sensor plausibility state
self._stale_reference_temp = None
self._stale_reference_time = None
# Flight mode state machine
self._mode = "grounded"
self._target_setpoint = self._setpoint
self._mode_started_at = None
self._mode_from_temp = None
self._takeoff_effective_seconds = None
self._scheduler_last_trigger = None
# PID instance # PID instance
from simple_pid import PID from simple_pid import PID
pid_cfg = config.get("pid") pid_cfg = config.get("pid")
@@ -118,42 +130,272 @@ class PIDController:
self._log_writer = csv.writer(self._log_file) self._log_writer = csv.writer(self._log_file)
self._log_writer.writerow([ self._log_writer.writerow([
"timestamp", "temp_f", "setpoint_f", "output", "relay", "timestamp", "temp_f", "setpoint_f", "output", "relay",
"kP", "kI", "kD", "loop_size_ms" "flight_setpoint_f", "mode", "kP", "kI", "kD", "loop_size_ms"
]) ])
log.info("Log file created: %s", path) log.info("Log file created: %s", path)
except Exception as e: except Exception as e:
log.error("Failed to create log file: %s", e) log.error("Failed to create log file: %s", e)
def start(self): def start(self):
"""Enable the controller and start the background control loop.""" """Enable power/PID control and ensure background monitor loop is running."""
self.start_cruise()
log.info("PID power enabled (setpoint=%.0fF)", self._setpoint)
def start_monitoring(self):
"""Start background temperature monitoring loop with power disabled."""
self._start_thread_if_needed()
with self._lock:
self._enabled = False
self._enter_mode("grounded", self._temp)
log.info("Background monitoring started (power disabled)")
def set_power(self, enabled):
"""Toggle power/PID control while keeping monitoring loop running."""
if enabled:
self.start_cruise()
log.info("PID power enabled")
return
self._start_thread_if_needed()
with self._lock:
self._enabled = False
self._enter_mode("grounded", self._temp)
log.info("PID power disabled")
self._relay_off()
def _start_thread_if_needed(self):
"""Start background control thread once, if not already running."""
with self._lock: with self._lock:
if self._thread is not None and self._thread.is_alive(): if self._thread is not None and self._thread.is_alive():
log.warning("Controller is already running")
return return
self._stop_event.clear()
self._thread = threading.Thread(target=self._control_loop, daemon=True, name="pid-loop")
self._thread.start()
log.info("Control loop thread started")
def stop(self):
"""Stop the monitoring/controller thread and turn off the relay."""
log.info("Stopping PID controller thread")
self._stop_event.set()
with self._lock:
self._enabled = False
self._autotune_active = False
self._enter_mode("grounded", self._temp)
self._relay_off()
if self._thread is not None:
self._thread.join(timeout=5)
log.info("PID controller thread stopped")
def _enter_mode(self, mode, now_temp=None):
self._mode = mode
self._mode_started_at = time.monotonic()
if now_temp is not None:
self._mode_from_temp = float(now_temp)
else:
self._mode_from_temp = self._temp
if mode != "takeoff":
self._takeoff_effective_seconds = None
self._config.update_section("flight", {"mode": mode})
log.info("Flight mode -> %s", mode)
def _compute_takeoff_duration(self):
"""Scale takeoff duration by how hot the coil already is."""
flight = self._config.get("flight")
base_takeoff = max(5.0, float(flight.get("takeoff_seconds", 300)))
ambient = float(flight.get("ambient_temp_f", 75))
start_temp = self._mode_from_temp if self._mode_from_temp is not None else self._temp
full_delta = self._target_setpoint - ambient
remaining_delta = self._target_setpoint - start_temp
if full_delta <= 1.0:
return base_takeoff
if remaining_delta <= 0:
return 0.0
ratio = max(0.0, min(1.0, remaining_delta / full_delta))
return max(5.0, base_takeoff * ratio)
def _compute_flight_setpoint(self, temp):
flight = self._config.get("flight")
takeoff_s = max(1.0, float(flight.get("takeoff_seconds", 90)))
descent_s = max(1.0, float(flight.get("descent_seconds", 90)))
descent_target = float(flight.get("descent_target_f", 120))
turbo = bool(flight.get("turbo", False))
if self._mode == "grounded":
self._enabled = False
return self._setpoint
if self._mode == "takeoff":
if turbo:
self._enter_mode("cruise", temp)
return self._target_setpoint
start_temp = self._mode_from_temp if self._mode_from_temp is not None else temp
effective_takeoff = self._takeoff_effective_seconds
if effective_takeoff is None:
effective_takeoff = self._compute_takeoff_duration()
self._takeoff_effective_seconds = effective_takeoff
if effective_takeoff <= 0:
self._enter_mode("cruise", temp)
return self._target_setpoint
elapsed = max(0.0, time.monotonic() - (self._mode_started_at or time.monotonic()))
p = min(1.0, elapsed / effective_takeoff)
ramp = start_temp + (self._target_setpoint - start_temp) * p
if p >= 1.0:
self._enter_mode("cruise", temp)
return self._target_setpoint
return ramp
if self._mode == "descent":
if turbo:
self._enter_mode("grounded", temp)
self._enabled = False
return descent_target
start_sp = self._mode_from_temp if self._mode_from_temp is not None else self._setpoint
elapsed = max(0.0, time.monotonic() - (self._mode_started_at or time.monotonic()))
p = min(1.0, elapsed / descent_s)
ramp = start_sp + (descent_target - start_sp) * p
if p >= 1.0:
self._enabled = False
self._enter_mode("grounded", temp)
return ramp
# cruise
return self._target_setpoint
def _scheduler_check(self):
sched = self._config.get("scheduler")
if not sched.get("enabled", False):
return
times = sched.get("cutoff_times", [])
if not isinstance(times, list):
return
now = datetime.now()
key = now.strftime("%Y-%m-%d %H:%M")
hhmm = now.strftime("%H:%M")
if key == self._scheduler_last_trigger:
return
if hhmm in times and self._mode not in ("grounded", "descent"):
self._scheduler_last_trigger = key
self.start_descent()
log.info("Scheduler triggered descent at %s", hhmm)
def _mode_eta_seconds(self):
if self._mode not in ("takeoff", "descent"):
return None
started = self._mode_started_at
if started is None:
return None
flight = self._config.get("flight")
if self._mode == "takeoff":
total = self._takeoff_effective_seconds
if total is None:
total = float(flight.get("takeoff_seconds", 300))
else:
total = float(flight.get("descent_seconds", 300))
remaining = max(0.0, total - (time.monotonic() - started))
return round(remaining, 1)
def _next_cutoff_seconds(self):
sched = self._config.get("scheduler")
if not sched.get("enabled", False):
return None
times = sched.get("cutoff_times", [])
if not isinstance(times, list) or not times:
return None
now = datetime.now()
best_seconds = None
for t in times:
try:
hh, mm = t.split(":", 1)
hhv = int(hh)
mmv = int(mm)
except Exception:
continue
target = now.replace(hour=hhv, minute=mmv, second=0, microsecond=0)
if target <= now:
target = target + timedelta(days=1)
delta_sec = (target - now).total_seconds()
if best_seconds is None or delta_sec < best_seconds:
best_seconds = delta_sec
if best_seconds is None:
return None
return round(best_seconds, 1)
def start_takeoff(self):
self._start_thread_if_needed()
with self._lock:
if self._autotune_active:
self._autotune_active = False
self._autotune_message = "Autotune stopped: flight mode takeoff"
self._enabled = True self._enabled = True
self._safety_tripped = False self._safety_tripped = False
self._safety_reason = "" self._safety_reason = ""
self._start_time = time.monotonic() self._start_time = time.monotonic()
self._idle_since = None self._idle_since = None
self._stop_event.clear()
self._pid.reset() self._pid.reset()
self._target_setpoint = self._setpoint
self._enter_mode("takeoff", self._temp)
self._takeoff_effective_seconds = self._compute_takeoff_duration()
log.info(
"Takeoff effective duration %.1fs (base %.1fs)",
self._takeoff_effective_seconds,
float(self._config.get("flight").get("takeoff_seconds", 300)),
)
self._thread = threading.Thread(target=self._control_loop, daemon=True, name="pid-loop") def start_descent(self):
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: with self._lock:
self._enabled = False if self._autotune_active:
self._autotune_active = False self._autotune_active = False
self._relay_off() self._autotune_message = "Autotune stopped: flight mode descent"
if self._thread is not None: if self._mode == "grounded":
self._thread.join(timeout=5) return
log.info("PID controller stopped") self._enter_mode("descent", self._setpoint)
self._takeoff_effective_seconds = None
def start_cruise(self):
self._start_thread_if_needed()
with self._lock:
self._enabled = True
self._safety_tripped = False
self._safety_reason = ""
self._start_time = time.monotonic()
self._idle_since = None
self._pid.reset()
self._target_setpoint = self._setpoint
self._enter_mode("cruise", self._temp)
self._takeoff_effective_seconds = None
def set_flight_config(self, takeoff_seconds=None, descent_seconds=None, turbo=None, descent_target_f=None):
flight = self._config.get("flight")
updates = {}
if takeoff_seconds is not None:
updates["takeoff_seconds"] = float(takeoff_seconds)
if descent_seconds is not None:
updates["descent_seconds"] = float(descent_seconds)
if turbo is not None:
updates["turbo"] = bool(turbo)
if descent_target_f is not None:
updates["descent_target_f"] = float(descent_target_f)
if updates:
flight.update(updates)
self._config.update_section("flight", flight)
def set_scheduler(self, enabled=None, cutoff_times=None):
sched = self._config.get("scheduler")
if enabled is not None:
sched["enabled"] = bool(enabled)
if cutoff_times is not None:
sched["cutoff_times"] = list(cutoff_times)
self._config.update_section("scheduler", sched)
def _relay_off(self): def _relay_off(self):
"""Ensure relay is OFF.""" """Ensure relay is OFF."""
@@ -176,6 +418,39 @@ class PIDController:
log.error("Failed to set relay: %s", e) log.error("Failed to set relay: %s", e)
self._relay_on = on self._relay_on = on
def _reset_stale_tracker(self, temp=None):
self._stale_reference_temp = temp
self._stale_reference_time = time.monotonic()
def _check_sensor_stale(self, temp, output, loop_size, safety):
"""Trip safety if sensor appears stuck while heater drive is high."""
if temp is None:
return False
delta_limit = float(safety.get("sensor_stale_delta_f", 0.8))
stale_seconds = float(safety.get("sensor_stale_seconds", 8.0))
high_ratio = float(safety.get("stale_output_ratio", 0.65))
if self._stale_reference_time is None or self._stale_reference_temp is None:
self._reset_stale_tracker(temp)
return False
# Only enforce stale detection while requesting strong heat output.
if output < (high_ratio * loop_size):
self._reset_stale_tracker(temp)
return False
delta = abs(temp - self._stale_reference_temp)
if delta >= delta_limit:
self._reset_stale_tracker(temp)
return False
elapsed = time.monotonic() - self._stale_reference_time
if elapsed >= stale_seconds:
self._trip_safety("Sensor stale while high heater demand")
return True
return False
def _reset_autotune_state(self): def _reset_autotune_state(self):
self._autotune_heating = False self._autotune_heating = False
self._autotune_phase_started = None self._autotune_phase_started = None
@@ -185,6 +460,11 @@ class PIDController:
def start_autotune(self, setpoint=None, hysteresis=None, cycles=None): def start_autotune(self, setpoint=None, hysteresis=None, cycles=None):
with self._lock: with self._lock:
if self._mode in ("takeoff", "descent"):
self._autotune_active = False
self._autotune_message = "Autotune disabled during {}".format(self._mode)
log.warning(self._autotune_message)
return
if setpoint is not None: if setpoint is not None:
self._setpoint = float(setpoint) self._setpoint = float(setpoint)
self._config.set("control", "setpoint", float(setpoint)) self._config.set("control", "setpoint", float(setpoint))
@@ -303,12 +583,11 @@ class PIDController:
Within each cycle, the relay is ON for a proportion of time equal to Within each cycle, the relay is ON for a proportion of time equal to
the PID output, implementing time-proportional software PWM. the PID output, implementing time-proportional software PWM.
""" """
log.info("Control loop thread started")
try: try:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
# Reload config if file changed # Reload config if file changed
self._config.reload_if_changed() self._config.reload_if_changed()
self._scheduler_check()
# Apply current PID tuning # Apply current PID tuning
pid_cfg = self._config.get("pid") pid_cfg = self._config.get("pid")
@@ -324,29 +603,47 @@ class PIDController:
self._pid.output_limits = (0, loop_size) self._pid.output_limits = (0, loop_size)
with self._lock:
self._pid.setpoint = self._setpoint
# Read temperature # Read temperature
temp = self._tc.read() temp = self._tc.read()
if temp is None: if temp is None:
log.error("Thermocouple read returned None, disabling for safety") log.error("Thermocouple read returned None, disabling power for safety")
self._trip_safety("Thermocouple disconnected") self._trip_safety("Thermocouple disconnected")
break time.sleep(0.2)
continue
with self._lock: with self._lock:
self._temp = temp self._temp = temp
flight_setpoint = self._compute_flight_setpoint(temp)
with self._lock:
self._pid.setpoint = flight_setpoint
# Safety checks # Safety checks
safety = self._config.get("safety") safety = self._config.get("safety")
if temp > safety["max_temp_f"]: if temp > safety["max_temp_f"]:
self._trip_safety("Temperature {:.0f}F exceeds max {}F".format(temp, safety['max_temp_f'])) self._trip_safety("Temperature {:.0f}F exceeds max {}F".format(temp, safety['max_temp_f']))
break time.sleep(0.2)
continue
if not self._tc.is_connected: if not self._tc.is_connected:
self._trip_safety("Thermocouple disconnected") self._trip_safety("Thermocouple disconnected")
break time.sleep(0.2)
continue
if not self._enabled:
self._relay_off()
self._reset_stale_tracker(temp)
with self._lock:
self._output = 0.0
self._last_loop_time = time.monotonic()
self._loop_count += 1
self._log_counter += 1
if self._log_counter >= log_resolution:
self._log_data_point(temp, 0.0)
self._log_counter = 0
time.sleep(max(0.2, sleep_time))
continue
# Idle shutoff check # Idle shutoff check
idle_minutes = safety.get("idle_shutoff_minutes", 0) idle_minutes = safety.get("idle_shutoff_minutes", 0)
@@ -376,6 +673,13 @@ class PIDController:
with self._lock: with self._lock:
self._temp = temp self._temp = temp
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
if self._autotune_active: if self._autotune_active:
output = self._update_autotune(temp, loop_size) output = self._update_autotune(temp, loop_size)
else: else:
@@ -383,6 +687,9 @@ class PIDController:
with self._lock: with self._lock:
self._output = output self._output = output
if self._check_sensor_stale(temp, output, loop_size, safety):
break
# Software PWM: relay ON for first `output` ms of the cycle # Software PWM: relay ON for first `output` ms of the cycle
elapsed_ms = time.monotonic() * 1000 - start_ms elapsed_ms = time.monotonic() * 1000 - start_ms
should_be_on = elapsed_ms < output should_be_on = elapsed_ms < output
@@ -409,22 +716,30 @@ class PIDController:
def _trip_safety(self, reason): def _trip_safety(self, reason):
"""Trip safety shutdown.""" """Trip safety shutdown."""
if self._safety_tripped and self._safety_reason == reason:
self._enabled = False
self._relay_off()
return
log.warning("SAFETY TRIP: %s", reason) log.warning("SAFETY TRIP: %s", reason)
with self._lock: with self._lock:
self._safety_tripped = True self._safety_tripped = True
self._safety_reason = reason self._safety_reason = reason
self._enabled = False self._enabled = False
self._enter_mode("grounded", self._temp)
self._relay_off() self._relay_off()
def _log_data_point(self, temp, output): def _log_data_point(self, temp, output):
"""Write a data point to the CSV log and in-memory history.""" """Write a data point to the CSV log and in-memory history."""
now = time.time() now = time.time()
flight_setpoint = self._pid.setpoint
row = [ row = [
"{:.3f}".format(now), "{:.3f}".format(now),
"{:.2f}".format(temp), "{:.2f}".format(temp),
"{:.2f}".format(self._setpoint), "{:.2f}".format(self._setpoint),
"{:.2f}".format(output), "{:.2f}".format(output),
1 if self._relay_on else 0, 1 if self._relay_on else 0,
"{:.2f}".format(flight_setpoint),
self._mode,
"{:.4f}".format(self._pid.Kp), "{:.4f}".format(self._pid.Kp),
"{:.4f}".format(self._pid.Ki), "{:.4f}".format(self._pid.Ki),
"{:.4f}".format(self._pid.Kd), "{:.4f}".format(self._pid.Kd),
@@ -444,6 +759,8 @@ class PIDController:
"timestamp": now, "timestamp": now,
"temp": round(temp, 2), "temp": round(temp, 2),
"setpoint": round(self._setpoint, 2), "setpoint": round(self._setpoint, 2),
"flight_setpoint": round(flight_setpoint, 2),
"mode": self._mode,
"output": round(output, 2), "output": round(output, 2),
"relay": self._relay_on "relay": self._relay_on
} }
@@ -459,6 +776,7 @@ class PIDController:
with self._lock: with self._lock:
old_setpoint = self._setpoint old_setpoint = self._setpoint
self._setpoint = float(value) self._setpoint = float(value)
self._target_setpoint = float(value)
self._idle_since = None # Reset idle timer on setpoint change self._idle_since = None # Reset idle timer on setpoint change
if abs(self._setpoint - old_setpoint) >= 10: if abs(self._setpoint - old_setpoint) >= 10:
self._pid.reset() self._pid.reset()
@@ -488,8 +806,10 @@ class PIDController:
return { return {
"enabled": self._enabled, "enabled": self._enabled,
"mode": self._mode,
"temp": round(self._temp, 2), "temp": round(self._temp, 2),
"setpoint": round(self._setpoint, 2), "setpoint": round(self._setpoint, 2),
"effective_setpoint": round(self._pid.setpoint, 2),
"output": round(self._output, 2), "output": round(self._output, 2),
"relay_on": self._relay_on, "relay_on": self._relay_on,
"loop_count": self._loop_count, "loop_count": self._loop_count,
@@ -515,6 +835,10 @@ class PIDController:
"message": self._autotune_message, "message": self._autotune_message,
"last_result": self._autotune_last_result, "last_result": self._autotune_last_result,
}, },
"flight": self._config.get("flight"),
"scheduler": self._config.get("scheduler"),
"mode_eta_seconds": self._mode_eta_seconds(),
"next_cutoff_seconds": self._next_cutoff_seconds(),
} }
@property @property
+497 -355
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "piNail2 Controller",
"short_name": "piNail2",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"description": "Raspberry Pi e-nail controller dashboard",
"icons": [
{
"src": "/static/img/pi_favicon.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/img/pi_favicon.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+237 -6
View File
@@ -35,7 +35,7 @@ header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 20px; padding: 8px 20px;
background: #000; background: #000;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -43,13 +43,14 @@ header {
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
height: 48px;
} }
.brand-logo { .brand-logo {
height: 34px; height: 100%;
width: auto; width: auto;
max-width: 42vw; max-width: 56vw;
border: none; border: none;
border-radius: 0; border-radius: 0;
object-fit: contain; object-fit: contain;
@@ -61,6 +62,47 @@ header {
gap: 10px; gap: 10px;
} }
.mode-switch {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 999px;
overflow: hidden;
background: #111;
}
.mode-btn {
background: transparent;
color: var(--text-dim);
border: none;
padding: 6px 10px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.03em;
cursor: pointer;
}
.mode-btn.active {
background: var(--accent-orange);
color: #fff;
}
.install-btn {
background: var(--accent-orange);
color: #fff;
border: 1px solid var(--accent-orange);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.03em;
cursor: pointer;
}
.install-btn:hover {
background: var(--accent-orange-hover);
border-color: var(--accent-orange-hover);
}
.backend-status { .backend-status {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-dim); color: var(--text-dim);
@@ -107,6 +149,84 @@ main {
padding: 16px; padding: 16px;
} }
body.ui-simple main {
max-width: 760px;
}
body.ui-simple .advanced-only {
display: none !important;
}
.simple-only {
display: none;
}
body.ui-simple .simple-only {
display: grid;
}
body.ui-simple .hero,
body.ui-simple .chart-section,
body.ui-simple .controls-setpoint,
body.ui-simple .app-footer {
display: none;
}
.dual-simple {
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.simple-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
}
.simple-card h3 {
font-size: 0.85rem;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.simple-temp {
font-size: 2rem;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.simple-line {
font-size: 0.82rem;
color: var(--text-dim);
margin-bottom: 6px;
}
.simple-controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.power-mini {
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
padding: 8px 12px;
font-weight: 700;
}
.power-mini.on {
color: var(--accent-green);
border-color: var(--accent-green);
background: rgba(46, 204, 113, 0.15);
}
/* Hero: temp + power */ /* Hero: temp + power */
.hero { .hero {
display: flex; display: flex;
@@ -120,10 +240,24 @@ main {
} }
.temp-display { .temp-display {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.temp-main {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
} }
.wall-clock {
font-size: 0.92rem;
color: var(--accent-orange);
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
}
.temp-value { .temp-value {
font-size: 4rem; font-size: 4rem;
font-weight: 700; font-weight: 700;
@@ -142,6 +276,12 @@ main {
.temp-warming { color: var(--accent-orange); } .temp-warming { color: var(--accent-orange); }
.temp-cold { color: var(--accent-blue); } .temp-cold { color: var(--accent-blue); }
.error-stats {
font-size: 0.74rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.hero-right { .hero-right {
text-align: right; text-align: right;
display: flex; display: flex;
@@ -163,6 +303,77 @@ main {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
.mode-pill {
font-size: 0.8rem;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-dim);
background: rgba(255, 255, 255, 0.05);
}
.mode-pill.takeoff {
color: #111;
background: var(--accent-orange);
border-color: var(--accent-orange);
}
.mode-pill.cruise {
color: #111;
background: var(--accent-green);
border-color: var(--accent-green);
}
.mode-pill.descent {
color: #111;
background: #f39c12;
border-color: #f39c12;
}
.mode-pill.grounded {
color: var(--text-dim);
background: rgba(255, 255, 255, 0.05);
border-color: var(--border);
}
.mode-eta {
font-size: 0.78rem;
color: var(--text-dim);
}
.sched-time-list {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sched-time-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: #1a1a1a;
color: var(--text);
font-size: 0.78rem;
}
#sched-time-input {
width: 122px;
text-align: center;
}
.sched-del {
border: none;
background: transparent;
color: var(--accent-red);
font-weight: 700;
cursor: pointer;
padding: 0;
}
.autotune-pill.running { .autotune-pill.running {
color: #111; color: #111;
background: var(--accent-orange); background: var(--accent-orange);
@@ -282,6 +493,14 @@ main {
margin-bottom: 12px; margin-bottom: 12px;
} }
.controls-setpoint {
grid-template-columns: 1fr;
}
.controls-advanced-row {
grid-template-columns: 1fr 1fr;
}
.control-group { .control-group {
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--radius); border-radius: var(--radius);
@@ -521,8 +740,8 @@ button:disabled {
} }
.brand-logo { .brand-logo {
height: 28px; height: 100%;
max-width: 46vw; max-width: 62vw;
} }
header h1 { header h1 {
@@ -542,6 +761,14 @@ button:disabled {
font-size: 3rem; font-size: 3rem;
} }
.temp-display {
align-items: center;
}
.error-stats {
text-align: center;
}
.power-btn { .power-btn {
width: 64px; width: 64px;
height: 64px; height: 64px;
@@ -552,6 +779,10 @@ button:disabled {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dual-simple {
grid-template-columns: 1fr;
}
.chart-section { .chart-section {
height: 220px; height: 220px;
} }
+53
View File
@@ -0,0 +1,53 @@
const CACHE_NAME = 'pinail2-cache-v1';
const APP_SHELL = [
'/',
'/static/style.css',
'/static/app.js',
'/static/img/pi_favicon.png',
'/static/img/pinail_logo.svg',
'/static/manifest.webmanifest'
];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) {
return cache.addAll(APP_SHELL);
})
);
self.skipWaiting();
});
self.addEventListener('activate', function(event) {
event.waitUntil(
caches.keys().then(function(keys) {
return Promise.all(
keys
.filter(function(key) { return key !== CACHE_NAME; })
.map(function(key) { return caches.delete(key); })
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', function(event) {
if (event.request.method !== 'GET') {
return;
}
event.respondWith(
fetch(event.request)
.then(function(response) {
const cloned = response.clone();
caches.open(CACHE_NAME).then(function(cache) {
cache.put(event.request, cloned);
});
return response;
})
.catch(function() {
return caches.match(event.request).then(function(cached) {
return cached || caches.match('/');
});
})
);
});
+125 -9
View File
@@ -4,7 +4,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>piNail2 Controller</title> <title>piNail2 Controller</title>
<meta name="theme-color" content="#000000">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="piNail2">
<link rel="icon" type="image/png" href="/static/img/pi_favicon.png"> <link rel="icon" type="image/png" href="/static/img/pi_favicon.png">
<link rel="apple-touch-icon" href="/static/img/pi_favicon.png">
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="stylesheet" href="/static/style.css"> <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> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</head> </head>
@@ -15,23 +21,62 @@
<h1>Controller</h1> <h1>Controller</h1>
</div> </div>
<div class="conn-wrap"> <div class="conn-wrap">
<span id="last-ack" class="last-ack">Last command: none</span> <div class="mode-switch" role="group" aria-label="UI mode">
<span id="backend-status" class="backend-status">Backend: Online</span> <button id="mode-simple-btn" class="mode-btn" onclick="setUiMode('simple')">Simple</button>
<button id="mode-nail1-btn" class="mode-btn" onclick="setUiMode('nail1')">Nail 1</button>
<button id="mode-nail2-btn" class="mode-btn" onclick="setUiMode('nail2')">Nail 2</button>
</div>
<button id="install-btn" class="install-btn" onclick="installApp()" hidden>Install App</button>
<span id="last-ack" class="last-ack advanced-only">Last command: none</span>
<span id="backend-status" class="backend-status advanced-only">Backend: Online</span>
<div id="connection-status" class="status-dot connected" title="Connected"></div> <div id="connection-status" class="status-dot connected" title="Connected"></div>
</div> </div>
</header> </header>
<main> <main>
<!-- Top row: Big temp display + power toggle --> <!-- Top row: Big temp display + power toggle -->
<section class="simple-only dual-simple" id="simple-dual">
<div class="simple-card" id="simple-card-nail1">
<h3>Nail 1</h3>
<div class="simple-temp"><span id="simple-temp-nail1">---</span>&deg;F</div>
<div class="simple-line">Mode: <span id="simple-mode-nail1">grounded</span></div>
<div class="simple-line">Target: <span id="simple-target-nail1">---</span>&deg;F</div>
<div class="simple-controls">
<input type="number" id="simple-setpoint-nail1" value="530" min="0" max="800" step="5">
<button class="apply-btn" onclick="simpleApplySetpoint(1)">Set</button>
<button id="simple-power-nail1" class="power-mini off" onclick="simpleTogglePower(1)">OFF</button>
</div>
</div>
<div class="simple-card" id="simple-card-nail2">
<h3>Nail 2</h3>
<div class="simple-temp"><span id="simple-temp-nail2">---</span>&deg;F</div>
<div class="simple-line">Mode: <span id="simple-mode-nail2">grounded</span></div>
<div class="simple-line">Target: <span id="simple-target-nail2">---</span>&deg;F</div>
<div class="simple-controls">
<input type="number" id="simple-setpoint-nail2" value="530" min="0" max="800" step="5">
<button class="apply-btn" onclick="simpleApplySetpoint(2)">Set</button>
<button id="simple-power-nail2" class="power-mini off" onclick="simpleTogglePower(2)">OFF</button>
</div>
</div>
</section>
<section class="hero"> <section class="hero">
<div class="temp-display"> <div class="temp-display">
<span id="current-temp" class="temp-value">---</span> <div id="advanced-nail-label" class="wall-clock">Nail 1</div>
<span class="temp-unit">&deg;F</span> <div id="wall-clock" class="wall-clock">--:--:--</div>
<div class="temp-main">
<span id="current-temp" class="temp-value">---</span>
<span class="temp-unit">&deg;F</span>
</div>
<div id="error-stats" class="error-stats">Err(3m): --</div>
</div> </div>
<div class="hero-right"> <div class="hero-right">
<div class="setpoint-display"> <div class="setpoint-display">
Target: <span id="current-setpoint">---</span>&deg;F Target: <span id="current-setpoint">---</span>&deg;F
</div> </div>
<div id="mode-pill" class="mode-pill">Mode: grounded</div>
<div id="mode-eta" class="mode-eta">ETA: --</div>
<div id="sched-eta" class="mode-eta">Next descent: --</div>
<div id="autotune-pill" class="autotune-pill idle">Autotune: Idle</div> <div id="autotune-pill" class="autotune-pill idle">Autotune: Idle</div>
<button id="power-btn" class="power-btn off" onclick="togglePower()">OFF</button> <button id="power-btn" class="power-btn off" onclick="togglePower()">OFF</button>
</div> </div>
@@ -52,9 +97,8 @@
<canvas id="temp-chart"></canvas> <canvas id="temp-chart"></canvas>
</section> </section>
<!-- Controls --> <!-- Setpoint (always visible) -->
<section class="controls"> <section class="controls controls-setpoint">
<!-- Setpoint -->
<div class="control-group"> <div class="control-group">
<h3>Setpoint</h3> <h3>Setpoint</h3>
<div class="setpoint-controls"> <div class="setpoint-controls">
@@ -69,9 +113,34 @@
<!-- Filled by JS --> <!-- Filled by JS -->
</div> </div>
</div> </div>
</section>
<!-- Advanced controls row -->
<section class="controls controls-advanced-row advanced-only">
<!-- Loop Timing -->
<div class="control-group">
<h3>Loop Timing</h3>
<div class="pid-controls">
<label>
Loop Size (ms)
<input type="number" id="control-loop-size" step="100" min="1500" max="5000" value="3000">
</label>
<label>
Sleep (s)
<input type="number" id="control-sleep-time" step="0.01" min="0.15" max="0.6" value="0.4">
</label>
<button class="apply-btn" onclick="applyControlTiming()">Apply Timing</button>
</div>
<div class="autotune-controls">
<button class="adj-btn" onclick="applyTimingProfile('conservative')">Conservative</button>
<button class="adj-btn" onclick="applyTimingProfile('balanced')">Balanced</button>
<button class="adj-btn" onclick="applyTimingProfile('responsive')">Responsive</button>
<span class="autotune-status idle">Limits: 1500-5000ms, 0.15-0.6s</span>
</div>
</div>
<!-- PID Tuning --> <!-- PID Tuning -->
<div class="control-group"> <div class="control-group advanced-only">
<h3>PID Tuning</h3> <h3>PID Tuning</h3>
<div class="pid-controls"> <div class="pid-controls">
<label> <label>
@@ -104,8 +173,55 @@
</div> </div>
</section> </section>
<section class="controls controls-advanced-row advanced-only">
<div class="control-group">
<h3>Flight Modes</h3>
<div class="autotune-controls">
<button class="adj-btn" onclick="setFlightMode('takeoff')">Takeoff</button>
<button class="adj-btn" onclick="setFlightMode('descent')">Descent</button>
<span id="flight-mode-status" class="autotune-status idle">Use power button for Grounded/Cruise.</span>
</div>
<div class="pid-controls">
<label>
Takeoff (s)
<input type="number" id="flight-takeoff-seconds" step="5" min="5" max="1800" value="300">
</label>
<label>
Descent (s)
<input type="number" id="flight-descent-seconds" step="5" min="5" max="1800" value="300">
</label>
<label>
Descent target (F)
<input type="number" id="flight-descent-target" step="5" min="80" max="500" value="120">
</label>
</div>
</div>
<div class="control-group">
<h3>Descent Scheduler</h3>
<div class="pid-controls">
<label>
Scheduler
<select id="sched-enabled" onchange="schedulerEnabledChanged()">
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</label>
<label>
Add time (HH:MM)
<input type="text" id="sched-time-input" value="23:00" placeholder="HH:MM" inputmode="numeric" maxlength="5">
</label>
<button class="adj-btn" onclick="addSchedulerTime()">Add Time</button>
</div>
<div id="sched-time-list" class="sched-time-list"></div>
<div class="autotune-controls">
<span class="autotune-status idle">Scheduler only triggers descent -> grounded. No auto takeoff.</span>
</div>
</div>
</section>
<!-- Status bar --> <!-- Status bar -->
<section class="status-bar"> <section class="status-bar advanced-only">
<div class="status-item"> <div class="status-item">
<span class="label">Output</span> <span class="label">Output</span>
<span id="status-output" class="value">0</span> <span id="status-output" class="value">0</span>
+48 -7
View File
@@ -10,6 +10,9 @@ Wraps the MAX6675 thermocouple reader with:
import logging import logging
import collections import collections
import math
import importlib
import types
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -48,8 +51,29 @@ class Thermocouple:
self._total_reads = 0 self._total_reads = 0
try: try:
import MAX6675.MAX6675 as MAX6675 sensor_ctor = None
self._sensor = MAX6675.MAX6675(clk, cs, do) module_candidates = [
("MAX6675.MAX6675", "MAX6675"),
("MAX6675.MAX6675.MAX6675", "MAX6675"),
("MAX6675.MAX6675", None),
("MAX6675.MAX6675.MAX6675", None),
]
for module_name, attr_name in module_candidates:
try:
module = importlib.import_module(module_name)
except Exception:
continue
obj = getattr(module, attr_name, None) if attr_name else module
if isinstance(obj, types.ModuleType):
obj = getattr(obj, "MAX6675", None)
if callable(obj):
sensor_ctor = obj
break
if not callable(sensor_ctor):
raise TypeError("MAX6675 constructor unavailable")
self._sensor = sensor_ctor(clk, cs, do)
self._is_connected = True self._is_connected = True
log.info("MAX6675 thermocouple initialized (CLK=%d, CS=%d, DO=%d)", clk, cs, do) log.info("MAX6675 thermocouple initialized (CLK=%d, CS=%d, DO=%d)", clk, cs, do)
except Exception as e: except Exception as e:
@@ -76,8 +100,20 @@ class Thermocouple:
self._is_connected = False self._is_connected = False
return self._last_good_temp return self._last_good_temp
# Check for open thermocouple (MAX6675 returns very high values or specific error codes) # Check for open thermocouple / invalid frame.
if raw_c is None or raw_c > 1023: # MAX6675 open probe often reports NaN; guard all non-finite values.
if raw_c is None:
log.warning("Thermocouple appears disconnected (raw_c=%s)", raw_c)
self._is_connected = False
return self._last_good_temp
try:
raw_c = float(raw_c)
except (TypeError, ValueError):
log.warning("Thermocouple appears disconnected (raw_c=%s)", raw_c)
self._is_connected = False
return self._last_good_temp
if (not math.isfinite(raw_c)) or raw_c > 1023:
log.warning("Thermocouple appears disconnected (raw_c=%s)", raw_c) log.warning("Thermocouple appears disconnected (raw_c=%s)", raw_c)
self._is_connected = False self._is_connected = False
return self._last_good_temp return self._last_good_temp
@@ -149,10 +185,15 @@ class Thermocouple:
@property @property
def stats(self): def stats(self):
"""Return diagnostic stats.""" """Return diagnostic stats."""
def safe(value):
if isinstance(value, (int, float)) and math.isfinite(float(value)):
return round(float(value), 2)
return None
return { return {
"raw_temp": round(self._raw_temp, 2), "raw_temp": safe(self._raw_temp),
"filtered_temp": round(self._filtered_temp, 2), "filtered_temp": safe(self._filtered_temp),
"is_connected": self._is_connected, "is_connected": self._is_connected,
"total_reads": self._total_reads, "total_reads": self._total_reads,
"recent_readings": [round(r, 2) for r in self._readings], "recent_readings": [safe(r) for r in self._readings],
} }