Release v2.0.0 with dual-nail control and hardened safety
This commit is contained in:
+452
-36
@@ -17,6 +17,8 @@ import logging
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import copy
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
@@ -46,8 +48,161 @@ app = Flask(__name__)
|
||||
|
||||
# These are set in main() before the app starts
|
||||
config = None # type: Config
|
||||
controller = None # type: PIDController
|
||||
tc = None # type: Thermocouple
|
||||
controllers = {}
|
||||
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
|
||||
@@ -70,18 +225,46 @@ def index():
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
"""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["nail"] = nail_id
|
||||
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"),
|
||||
"loop_size_ms": ch_cfg.get("control", "loop_size_ms"),
|
||||
"sleep_time": ch_cfg.get("control", "sleep_time"),
|
||||
"safety": ch_cfg.get("safety"),
|
||||
}
|
||||
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")
|
||||
def api_heartbeat():
|
||||
"""Lightweight health endpoint for frontend reconnect logic."""
|
||||
@@ -89,14 +272,18 @@ def api_heartbeat():
|
||||
"ok": True,
|
||||
"instance_id": APP_INSTANCE_ID,
|
||||
"ts": time.time(),
|
||||
"controller_alive": controller.is_alive,
|
||||
"watchdog_ok": controller.watchdog_ok,
|
||||
"controller_alive": {n: controllers[n].is_alive for n in controllers},
|
||||
"watchdog_ok": {n: controllers[n].watchdog_ok for n in controllers},
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/history")
|
||||
def api_history():
|
||||
"""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)
|
||||
if since > 0:
|
||||
data = controller.get_history_since(since)
|
||||
@@ -109,24 +296,29 @@ def api_history():
|
||||
def api_power():
|
||||
"""Toggle controller on/off."""
|
||||
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")
|
||||
|
||||
if enable is None:
|
||||
# Toggle
|
||||
enable = not controller.status["enabled"]
|
||||
|
||||
if enable:
|
||||
controller.start()
|
||||
else:
|
||||
controller.stop()
|
||||
controller.set_power(enable)
|
||||
|
||||
return jsonify({"enabled": enable, "ok": True})
|
||||
return jsonify({"enabled": enable, "ok": True, "nail": nail_id})
|
||||
|
||||
|
||||
@app.route("/api/setpoint", methods=["POST"])
|
||||
def api_setpoint():
|
||||
"""Change the target temperature."""
|
||||
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")
|
||||
if value is None:
|
||||
return jsonify({"error": "Missing 'setpoint' field"}), 400
|
||||
@@ -136,20 +328,196 @@ def api_setpoint():
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid setpoint value"}), 400
|
||||
|
||||
safety = config.get("safety")
|
||||
safety = ch_cfg.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})
|
||||
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"])
|
||||
def api_pid():
|
||||
"""Update PID tuning parameters."""
|
||||
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")
|
||||
ki = body.get("kI")
|
||||
@@ -171,6 +539,7 @@ def api_pid():
|
||||
"kP": kp,
|
||||
"kI": ki,
|
||||
"kD": kd,
|
||||
"nail": nail_id,
|
||||
"proportional_on_measurement": controller.status["pid"]["proportional_on_measurement"],
|
||||
"proportional_mode": mode_out,
|
||||
"ok": True,
|
||||
@@ -180,13 +549,18 @@ def api_pid():
|
||||
@app.route("/api/preset/<name>", methods=["POST"])
|
||||
def api_preset(name):
|
||||
"""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")
|
||||
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})
|
||||
return jsonify({"preset": name, "setpoint": value, "ok": True, "nail": nail_id})
|
||||
|
||||
|
||||
@app.route("/api/presets", methods=["GET"])
|
||||
@@ -236,43 +610,68 @@ def api_config():
|
||||
@app.route("/api/pid/reset", methods=["POST"])
|
||||
def api_pid_reset():
|
||||
"""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()
|
||||
return jsonify({"ok": True, "message": "PID reset"})
|
||||
return jsonify({"ok": True, "message": "PID reset", "nail": nail_id})
|
||||
|
||||
|
||||
@app.route("/api/autotune", methods=["GET"])
|
||||
def api_autotune_status():
|
||||
"""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"])
|
||||
def api_autotune_start():
|
||||
"""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"):
|
||||
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})
|
||||
return jsonify({"ok": True, "autotune": controller.autotune_status, "nail": nail_id})
|
||||
|
||||
|
||||
@app.route("/api/autotune/stop", methods=["POST"])
|
||||
def api_autotune_stop():
|
||||
"""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")
|
||||
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"])
|
||||
def api_safety_reset():
|
||||
"""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_reason = ""
|
||||
return jsonify({"ok": True})
|
||||
return jsonify({"ok": True, "nail": nail_id})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -282,13 +681,24 @@ def api_safety_reset():
|
||||
def shutdown_handler(*args):
|
||||
"""Handle SIGTERM/SIGINT gracefully."""
|
||||
log.info("Shutdown signal received")
|
||||
if controller:
|
||||
controller.cleanup()
|
||||
for nail_id in controllers:
|
||||
try:
|
||||
controllers[nail_id].cleanup()
|
||||
except Exception as e:
|
||||
log.error("Cleanup failed for %s: %s", nail_id, e)
|
||||
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():
|
||||
global config, controller, tc
|
||||
global config
|
||||
|
||||
parser = argparse.ArgumentParser(description="piNail2 — E-Nail Temperature Controller")
|
||||
parser.add_argument("--config", default="config.json", help="Path to config file")
|
||||
@@ -297,24 +707,30 @@ def main():
|
||||
# Load config
|
||||
config = Config(args.config)
|
||||
log.info("Configuration loaded from %s", args.config)
|
||||
ensure_nails_config(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"]
|
||||
)
|
||||
for nail_id in NAIL_IDS:
|
||||
ch_cfg = ChannelConfigProxy(config, nail_id)
|
||||
channel_configs[nail_id] = ch_cfg
|
||||
gpio_cfg = ch_cfg.get("gpio")
|
||||
safety_cfg = ch_cfg.get("safety")
|
||||
tc = Thermocouple(
|
||||
clk=gpio_cfg["clk"],
|
||||
cs=gpio_cfg["cs"],
|
||||
do=gpio_cfg["do"],
|
||||
spike_threshold=safety_cfg["spike_threshold_f"]
|
||||
)
|
||||
thermocouples[nail_id] = tc
|
||||
|
||||
# Initialize PID controller
|
||||
controller = PIDController(config, tc)
|
||||
controller = PIDController(ch_cfg, tc)
|
||||
controller.start_monitoring()
|
||||
controllers[nail_id] = controller
|
||||
log.info("Initialized %s (relay pin %s)", nail_id, gpio_cfg.get("relay_pin"))
|
||||
|
||||
# Register cleanup
|
||||
signal.signal(signal.SIGTERM, shutdown_handler)
|
||||
signal.signal(signal.SIGINT, shutdown_handler)
|
||||
atexit.register(controller.cleanup)
|
||||
atexit.register(cleanup_all)
|
||||
|
||||
# Start Flask
|
||||
web_cfg = config.get("web")
|
||||
|
||||
+130
-5
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"pid": {
|
||||
"kP": 10.0,
|
||||
"kI": 5.0,
|
||||
"kD": 1.0,
|
||||
"kP": 113.1768,
|
||||
"kI": 3.5335,
|
||||
"kD": 500.0,
|
||||
"proportional_on_measurement": false
|
||||
},
|
||||
"control": {
|
||||
@@ -11,12 +11,29 @@
|
||||
"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": 800,
|
||||
"max_temp_f": 750,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"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": {
|
||||
"relay_pin": 2,
|
||||
@@ -42,5 +59,113 @@
|
||||
"autotune": {
|
||||
"hysteresis_f": 8.0,
|
||||
"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
@@ -14,9 +14,9 @@ log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"pid": {
|
||||
"kP": 10.0,
|
||||
"kI": 5.0,
|
||||
"kD": 1.0,
|
||||
"kP": 113.1768,
|
||||
"kI": 3.5335,
|
||||
"kD": 500.0,
|
||||
"proportional_on_measurement": False
|
||||
},
|
||||
"control": {
|
||||
@@ -25,12 +25,29 @@ DEFAULT_CONFIG = {
|
||||
"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": 800,
|
||||
"max_temp_f": 750,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"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": {
|
||||
"relay_pin": 2,
|
||||
@@ -56,6 +73,114 @@ DEFAULT_CONFIG = {
|
||||
"autotune": {
|
||||
"hysteresis_f": 8.0,
|
||||
"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
@@ -19,7 +19,7 @@ import logging
|
||||
import os
|
||||
import csv
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,6 +73,18 @@ class PIDController:
|
||||
self._autotune_last_result = None
|
||||
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
|
||||
from simple_pid import PID
|
||||
pid_cfg = config.get("pid")
|
||||
@@ -118,42 +130,272 @@ class PIDController:
|
||||
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"
|
||||
"flight_setpoint_f", "mode", "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."""
|
||||
"""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:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
log.warning("Controller is already running")
|
||||
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._safety_tripped = False
|
||||
self._safety_reason = ""
|
||||
self._start_time = time.monotonic()
|
||||
self._idle_since = None
|
||||
self._stop_event.clear()
|
||||
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")
|
||||
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()
|
||||
def start_descent(self):
|
||||
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")
|
||||
if self._autotune_active:
|
||||
self._autotune_active = False
|
||||
self._autotune_message = "Autotune stopped: flight mode descent"
|
||||
if self._mode == "grounded":
|
||||
return
|
||||
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):
|
||||
"""Ensure relay is OFF."""
|
||||
@@ -176,6 +418,39 @@ class PIDController:
|
||||
log.error("Failed to set relay: %s", e)
|
||||
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):
|
||||
self._autotune_heating = False
|
||||
self._autotune_phase_started = None
|
||||
@@ -185,6 +460,11 @@ class PIDController:
|
||||
|
||||
def start_autotune(self, setpoint=None, hysteresis=None, cycles=None):
|
||||
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:
|
||||
self._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
|
||||
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()
|
||||
self._scheduler_check()
|
||||
|
||||
# Apply current PID tuning
|
||||
pid_cfg = self._config.get("pid")
|
||||
@@ -324,29 +603,47 @@ class PIDController:
|
||||
|
||||
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")
|
||||
log.error("Thermocouple read returned None, disabling power for safety")
|
||||
self._trip_safety("Thermocouple disconnected")
|
||||
break
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
|
||||
with self._lock:
|
||||
self._temp = temp
|
||||
|
||||
flight_setpoint = self._compute_flight_setpoint(temp)
|
||||
with self._lock:
|
||||
self._pid.setpoint = flight_setpoint
|
||||
|
||||
# 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
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
|
||||
if not self._tc.is_connected:
|
||||
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_minutes = safety.get("idle_shutoff_minutes", 0)
|
||||
@@ -376,6 +673,13 @@ class PIDController:
|
||||
with self._lock:
|
||||
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:
|
||||
output = self._update_autotune(temp, loop_size)
|
||||
else:
|
||||
@@ -383,6 +687,9 @@ class PIDController:
|
||||
with self._lock:
|
||||
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
|
||||
elapsed_ms = time.monotonic() * 1000 - start_ms
|
||||
should_be_on = elapsed_ms < output
|
||||
@@ -409,22 +716,30 @@ class PIDController:
|
||||
|
||||
def _trip_safety(self, reason):
|
||||
"""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)
|
||||
with self._lock:
|
||||
self._safety_tripped = True
|
||||
self._safety_reason = reason
|
||||
self._enabled = False
|
||||
self._enter_mode("grounded", self._temp)
|
||||
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()
|
||||
flight_setpoint = self._pid.setpoint
|
||||
row = [
|
||||
"{:.3f}".format(now),
|
||||
"{:.2f}".format(temp),
|
||||
"{:.2f}".format(self._setpoint),
|
||||
"{:.2f}".format(output),
|
||||
1 if self._relay_on else 0,
|
||||
"{:.2f}".format(flight_setpoint),
|
||||
self._mode,
|
||||
"{:.4f}".format(self._pid.Kp),
|
||||
"{:.4f}".format(self._pid.Ki),
|
||||
"{:.4f}".format(self._pid.Kd),
|
||||
@@ -444,6 +759,8 @@ class PIDController:
|
||||
"timestamp": now,
|
||||
"temp": round(temp, 2),
|
||||
"setpoint": round(self._setpoint, 2),
|
||||
"flight_setpoint": round(flight_setpoint, 2),
|
||||
"mode": self._mode,
|
||||
"output": round(output, 2),
|
||||
"relay": self._relay_on
|
||||
}
|
||||
@@ -459,6 +776,7 @@ class PIDController:
|
||||
with self._lock:
|
||||
old_setpoint = self._setpoint
|
||||
self._setpoint = float(value)
|
||||
self._target_setpoint = float(value)
|
||||
self._idle_since = None # Reset idle timer on setpoint change
|
||||
if abs(self._setpoint - old_setpoint) >= 10:
|
||||
self._pid.reset()
|
||||
@@ -488,8 +806,10 @@ class PIDController:
|
||||
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"mode": self._mode,
|
||||
"temp": round(self._temp, 2),
|
||||
"setpoint": round(self._setpoint, 2),
|
||||
"effective_setpoint": round(self._pid.setpoint, 2),
|
||||
"output": round(self._output, 2),
|
||||
"relay_on": self._relay_on,
|
||||
"loop_count": self._loop_count,
|
||||
@@ -515,6 +835,10 @@ class PIDController:
|
||||
"message": self._autotune_message,
|
||||
"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
|
||||
|
||||
+497
-355
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -35,7 +35,7 @@ header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
padding: 8px 20px;
|
||||
background: #000;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -43,13 +43,14 @@ header {
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 34px;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
max-width: 42vw;
|
||||
max-width: 56vw;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
object-fit: contain;
|
||||
@@ -61,6 +62,47 @@ header {
|
||||
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 {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
@@ -107,6 +149,84 @@ main {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -120,10 +240,24 @@ main {
|
||||
}
|
||||
|
||||
.temp-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.temp-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.wall-clock {
|
||||
font-size: 0.92rem;
|
||||
color: var(--accent-orange);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
@@ -142,6 +276,12 @@ main {
|
||||
.temp-warming { color: var(--accent-orange); }
|
||||
.temp-cold { color: var(--accent-blue); }
|
||||
|
||||
.error-stats {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hero-right {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
@@ -163,6 +303,77 @@ main {
|
||||
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 {
|
||||
color: #111;
|
||||
background: var(--accent-orange);
|
||||
@@ -282,6 +493,14 @@ main {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.controls-setpoint {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.controls-advanced-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
@@ -521,8 +740,8 @@ button:disabled {
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 28px;
|
||||
max-width: 46vw;
|
||||
height: 100%;
|
||||
max-width: 62vw;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
@@ -542,6 +761,14 @@ button:disabled {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.temp-display {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-stats {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.power-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
@@ -552,6 +779,10 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dual-simple {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
@@ -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('/');
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,13 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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="apple-touch-icon" href="/static/img/pi_favicon.png">
|
||||
<link rel="manifest" href="/static/manifest.webmanifest">
|
||||
<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>
|
||||
@@ -15,23 +21,62 @@
|
||||
<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 class="mode-switch" role="group" aria-label="UI mode">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- 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>°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>°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>°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>°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">
|
||||
<div class="temp-display">
|
||||
<span id="current-temp" class="temp-value">---</span>
|
||||
<span class="temp-unit">°F</span>
|
||||
<div id="advanced-nail-label" class="wall-clock">Nail 1</div>
|
||||
<div id="wall-clock" class="wall-clock">--:--:--</div>
|
||||
<div class="temp-main">
|
||||
<span id="current-temp" class="temp-value">---</span>
|
||||
<span class="temp-unit">°F</span>
|
||||
</div>
|
||||
<div id="error-stats" class="error-stats">Err(3m): --</div>
|
||||
</div>
|
||||
<div class="hero-right">
|
||||
<div class="setpoint-display">
|
||||
Target: <span id="current-setpoint">---</span>°F
|
||||
</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>
|
||||
<button id="power-btn" class="power-btn off" onclick="togglePower()">OFF</button>
|
||||
</div>
|
||||
@@ -52,9 +97,8 @@
|
||||
<canvas id="temp-chart"></canvas>
|
||||
</section>
|
||||
|
||||
<!-- Controls -->
|
||||
<section class="controls">
|
||||
<!-- Setpoint -->
|
||||
<!-- Setpoint (always visible) -->
|
||||
<section class="controls controls-setpoint">
|
||||
<div class="control-group">
|
||||
<h3>Setpoint</h3>
|
||||
<div class="setpoint-controls">
|
||||
@@ -69,9 +113,34 @@
|
||||
<!-- Filled by JS -->
|
||||
</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 -->
|
||||
<div class="control-group">
|
||||
<div class="control-group advanced-only">
|
||||
<h3>PID Tuning</h3>
|
||||
<div class="pid-controls">
|
||||
<label>
|
||||
@@ -104,8 +173,55 @@
|
||||
</div>
|
||||
</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 -->
|
||||
<section class="status-bar">
|
||||
<section class="status-bar advanced-only">
|
||||
<div class="status-item">
|
||||
<span class="label">Output</span>
|
||||
<span id="status-output" class="value">0</span>
|
||||
|
||||
+48
-7
@@ -10,6 +10,9 @@ Wraps the MAX6675 thermocouple reader with:
|
||||
|
||||
import logging
|
||||
import collections
|
||||
import math
|
||||
import importlib
|
||||
import types
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,8 +51,29 @@ class Thermocouple:
|
||||
self._total_reads = 0
|
||||
|
||||
try:
|
||||
import MAX6675.MAX6675 as MAX6675
|
||||
self._sensor = MAX6675.MAX6675(clk, cs, do)
|
||||
sensor_ctor = None
|
||||
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
|
||||
log.info("MAX6675 thermocouple initialized (CLK=%d, CS=%d, DO=%d)", clk, cs, do)
|
||||
except Exception as e:
|
||||
@@ -76,8 +100,20 @@ class Thermocouple:
|
||||
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:
|
||||
# Check for open thermocouple / invalid frame.
|
||||
# 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)
|
||||
self._is_connected = False
|
||||
return self._last_good_temp
|
||||
@@ -149,10 +185,15 @@ class Thermocouple:
|
||||
@property
|
||||
def stats(self):
|
||||
"""Return diagnostic stats."""
|
||||
def safe(value):
|
||||
if isinstance(value, (int, float)) and math.isfinite(float(value)):
|
||||
return round(float(value), 2)
|
||||
return None
|
||||
|
||||
return {
|
||||
"raw_temp": round(self._raw_temp, 2),
|
||||
"filtered_temp": round(self._filtered_temp, 2),
|
||||
"raw_temp": safe(self._raw_temp),
|
||||
"filtered_temp": safe(self._filtered_temp),
|
||||
"is_connected": self._is_connected,
|
||||
"total_reads": self._total_reads,
|
||||
"recent_readings": [round(r, 2) for r in self._readings],
|
||||
"recent_readings": [safe(r) for r in self._readings],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user