From c4c86747e516551da5bee28997a5e0d51edf4978 Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 12 Mar 2026 02:06:42 +0000 Subject: [PATCH] Release v2.0.0 with dual-nail control and hardened safety --- piNail2/app.py | 488 ++++++++++++++-- piNail2/config.json | 135 ++++- piNail2/config.py | 135 ++++- piNail2/pid_controller.py | 380 ++++++++++++- piNail2/static/app.js | 852 ++++++++++++++++------------ piNail2/static/manifest.webmanifest | 22 + piNail2/static/style.css | 243 +++++++- piNail2/static/sw.js | 53 ++ piNail2/templates/index.html | 134 ++++- piNail2/thermocouple.py | 55 +- 10 files changed, 2046 insertions(+), 451 deletions(-) create mode 100644 piNail2/static/manifest.webmanifest create mode 100644 piNail2/static/sw.js diff --git a/piNail2/app.py b/piNail2/app.py index dd2011d..4bb503f 100644 --- a/piNail2/app.py +++ b/piNail2/app.py @@ -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/", 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") diff --git a/piNail2/config.json b/piNail2/config.json index c6a3281..fd10259 100644 --- a/piNail2/config.json +++ b/piNail2/config.json @@ -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 + } + } } } diff --git a/piNail2/config.py b/piNail2/config.py index bfb96e7..55b3a85 100644 --- a/piNail2/config.py +++ b/piNail2/config.py @@ -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 + } + } } } diff --git a/piNail2/pid_controller.py b/piNail2/pid_controller.py index c24a721..3f50b82 100644 --- a/piNail2/pid_controller.py +++ b/piNail2/pid_controller.py @@ -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 diff --git a/piNail2/static/app.js b/piNail2/static/app.js index 1b8c229..b9987a1 100644 --- a/piNail2/static/app.js +++ b/piNail2/static/app.js @@ -1,28 +1,34 @@ -/** - * piNail2 Frontend — Dashboard Controller - * - * Polls the REST API for status updates, renders a live Chart.js chart, - * and provides controls for setpoint, PID tuning, and power toggle. - */ - -// --------------------------------------------------------------------------- -// State -// --------------------------------------------------------------------------- -let pollInterval = 500; // ms between status polls -let chartMaxPoints = 300; // max data points on chart -let lastTimestamp = 0; // for incremental history fetches -let isEnabled = false; -let currentSetpoint = 530; +let pollInterval = 500; +let chartMaxPoints = 300; +let lastTimestamp = 0; let chart = null; +let firstTimestamp = null; let lastApiError = ''; let actionBannerTimer = null; let heartbeatMisses = 0; let heartbeatInstanceId = null; let controlsEnabled = true; +let deferredInstallPrompt = null; +let uiMode = 'nail1'; +let activeNailId = 'nail1'; +let historyBuffer = []; +let schedulerTimesByNail = { nail1: [], nail2: [] }; +let states = { nail1: null, nail2: null }; + +function nailIdFromNum(num) { + return String(num) === '2' ? 'nail2' : 'nail1'; +} + +function nailNumFromId(id) { + return id === 'nail2' ? 2 : 1; +} + +function currentNailId() { + return activeNailId; +} function nowHms() { - const d = new Date(); - return d.toLocaleTimeString(); + return new Date().toLocaleTimeString(); } function setLastAck(message, ok=true) { @@ -47,15 +53,10 @@ function showAction(message, type='info', timeoutMs=3000) { } function setControlsEnabled(enabled) { - if (controlsEnabled === enabled) return; controlsEnabled = enabled; - const btns = document.querySelectorAll('button'); - btns.forEach(function(b) { - if (b.id === 'autotune-stop-btn' && enabled) { - // stop button is governed by autotune state in setAutotuneUi() - return; - } - b.disabled = !enabled; + document.querySelectorAll('button').forEach(function(btn) { + if (btn.id === 'autotune-stop-btn' && enabled) return; + btn.disabled = !enabled; }); } @@ -66,13 +67,165 @@ function setBackendStatus(mode, text) { el.textContent = text; } +function setConnectionStatus(connected) { + const dot = document.getElementById('connection-status'); + if (!dot) return; + if (connected) { + dot.className = 'status-dot connected'; + dot.title = 'Connected'; + setBackendStatus('online', 'Backend: Online'); + } else { + dot.className = 'status-dot disconnected'; + dot.title = 'Disconnected'; + setBackendStatus('offline', 'Backend: Offline'); + } +} + +function setUiMode(mode, persist=true) { + if (mode === 'nail2') activeNailId = 'nail2'; + if (mode === 'nail1') activeNailId = 'nail1'; + uiMode = (mode === 'simple' || mode === 'nail2' || mode === 'nail1') ? mode : 'nail1'; + document.body.classList.remove('ui-simple', 'ui-nail1', 'ui-nail2'); + document.body.classList.add('ui-' + uiMode); + + ['simple', 'nail1', 'nail2'].forEach(function(key) { + const btn = document.getElementById('mode-' + key + '-btn'); + if (btn) btn.classList.toggle('active', key === uiMode); + }); + + const label = document.getElementById('advanced-nail-label'); + if (label) label.textContent = 'Nail ' + nailNumFromId(activeNailId); + + if (persist) { + try { + localStorage.setItem('pinail_ui_mode', uiMode); + } catch (e) { + console.warn('Could not persist ui mode:', e); + } + } + + resetChartForNail(); +} + +function updateWallClock() { + const el = document.getElementById('wall-clock'); + if (!el) return; + const d = new Date(); + const hh = ('0' + d.getHours()).slice(-2); + const mm = ('0' + d.getMinutes()).slice(-2); + const ss = ('0' + d.getSeconds()).slice(-2); + el.textContent = hh + ':' + mm + ':' + ss; +} + +function normalizeTimeString(t) { + const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec((t || '').trim()); + if (!m) return null; + return ('0' + m[1]).slice(-2) + ':' + ('0' + m[2]).slice(-2); +} + +function renderSchedulerTimes() { + const id = currentNailId(); + const times = schedulerTimesByNail[id] || []; + const container = document.getElementById('sched-time-list'); + if (!container) return; + if (!times.length) { + container.innerHTML = 'No descent times configured.'; + return; + } + container.innerHTML = times.map(function(t) { + return '' + t + ''; + }).join(''); +} + +function addSchedulerTime() { + const input = document.getElementById('sched-time-input'); + if (!input) return; + const norm = normalizeTimeString(input.value); + if (!norm) { + showAction('Invalid time format. Use HH:MM.', 'error', 3000); + return; + } + const id = currentNailId(); + if (!schedulerTimesByNail[id]) schedulerTimesByNail[id] = []; + if (schedulerTimesByNail[id].indexOf(norm) === -1) { + schedulerTimesByNail[id].push(norm); + schedulerTimesByNail[id].sort(); + renderSchedulerTimes(); + saveSchedulerSettings(); + } +} + +function removeSchedulerTime(t) { + const id = currentNailId(); + schedulerTimesByNail[id] = (schedulerTimesByNail[id] || []).filter(function(x) { return x !== t; }); + renderSchedulerTimes(); + saveSchedulerSettings(); +} + +function schedulerEnabledChanged() { + saveSchedulerSettings(); +} + +function updateSimpleCards() { + ['nail1', 'nail2'].forEach(function(id) { + const s = states[id]; + if (!s) return; + const suffix = id === 'nail2' ? 'nail2' : 'nail1'; + const tEl = document.getElementById('simple-temp-' + suffix); + const mEl = document.getElementById('simple-mode-' + suffix); + const spEl = document.getElementById('simple-target-' + suffix); + const pEl = document.getElementById('simple-power-' + suffix); + const inEl = document.getElementById('simple-setpoint-' + suffix); + if (tEl) tEl.textContent = s.temp.toFixed(1); + if (mEl) mEl.textContent = s.mode || 'grounded'; + if (spEl) spEl.textContent = s.effective_setpoint.toFixed(0); + if (inEl && document.activeElement !== inEl) inEl.value = s.setpoint; + if (pEl) { + pEl.textContent = s.enabled ? 'ON' : 'OFF'; + pEl.className = 'power-mini ' + (s.enabled ? 'on' : 'off'); + } + }); +} + +function updateErrorStats() { + const el = document.getElementById('error-stats'); + if (!el) return; + if (!historyBuffer.length) { + el.textContent = 'Err(3m): --'; + return; + } + const nowTs = historyBuffer[historyBuffer.length - 1].timestamp; + const window = historyBuffer.filter(function(p) { return p.timestamp >= (nowTs - 180); }); + if (!window.length) { + el.textContent = 'Err(3m): --'; + return; + } + let sumAbs = 0; + let sumSq = 0; + let maxAbs = 0; + let sumSigned = 0; + window.forEach(function(p) { + const target = (typeof p.flight_setpoint === 'number') ? p.flight_setpoint : p.setpoint; + const err = p.temp - target; + const abs = Math.abs(err); + sumAbs += abs; + sumSq += err * err; + sumSigned += err; + if (abs > maxAbs) maxAbs = abs; + }); + const n = window.length; + const mae = sumAbs / n; + const rmse = Math.sqrt(sumSq / n); + const bias = sumSigned / n; + el.textContent = 'Err(3m) MAE ' + mae.toFixed(1) + 'F | RMSE ' + rmse.toFixed(1) + 'F | Max ' + maxAbs.toFixed(1) + 'F | Bias ' + bias.toFixed(1) + 'F'; +} + function setAutotuneUi(tune) { const statusEl = document.getElementById('autotune-status'); const startBtn = document.getElementById('autotune-start-btn'); const stopBtn = document.getElementById('autotune-stop-btn'); const pill = document.getElementById('autotune-pill'); if (!statusEl || !startBtn || !stopBtn) return; - if (tune && tune.active) { statusEl.className = 'autotune-status running'; const phase = tune.phase ? (' ' + tune.phase) : ''; @@ -85,10 +238,8 @@ function setAutotuneUi(tune) { stopBtn.disabled = false; return; } - startBtn.disabled = false; stopBtn.disabled = true; - if (tune && tune.last_result) { statusEl.className = 'autotune-status done'; statusEl.textContent = 'Complete: kP ' + tune.last_result.kP + ', kI ' + tune.last_result.kI + ', kD ' + tune.last_result.kD; @@ -96,286 +247,212 @@ function setAutotuneUi(tune) { pill.className = 'autotune-pill done'; pill.textContent = 'Autotune: Complete'; } - } else if (tune && tune.message) { - const lower = String(tune.message).toLowerCase(); - statusEl.className = 'autotune-status ' + (lower.indexOf('failed') >= 0 ? 'error' : 'idle'); - statusEl.textContent = tune.message; - if (pill) { - const failed = lower.indexOf('failed') >= 0 || lower.indexOf('error') >= 0; - pill.className = 'autotune-pill ' + (failed ? 'error' : 'idle'); - pill.textContent = failed ? 'Autotune: Error' : 'Autotune: Idle'; - } - } else { - statusEl.className = 'autotune-status idle'; - statusEl.textContent = 'Idle'; - if (pill) { - pill.className = 'autotune-pill idle'; - pill.textContent = 'Autotune: Idle'; - } + return; + } + statusEl.className = 'autotune-status idle'; + statusEl.textContent = (tune && tune.message) ? tune.message : 'Idle'; + if (pill) { + pill.className = 'autotune-pill idle'; + pill.textContent = 'Autotune: Idle'; } } -// --------------------------------------------------------------------------- -// Chart Setup -// --------------------------------------------------------------------------- function initChart() { const ctx = document.getElementById('temp-chart').getContext('2d'); chart = new Chart(ctx, { type: 'line', - data: { - datasets: [ - { - label: 'Temperature (F)', - borderColor: '#ff6b35', - backgroundColor: 'rgba(255, 107, 53, 0.1)', - borderWidth: 2, - pointRadius: 0, - fill: true, - data: [], - yAxisID: 'y' - }, - { - label: 'Setpoint (F)', - borderColor: '#4ecdc4', - borderWidth: 1.5, - borderDash: [5, 5], - pointRadius: 0, - fill: false, - data: [], - yAxisID: 'y' - }, - { - label: 'Output', - borderColor: '#45b7d1', - backgroundColor: 'rgba(69, 183, 209, 0.1)', - borderWidth: 1, - pointRadius: 0, - fill: true, - data: [], - yAxisID: 'y1' - } - ] - }, + data: { datasets: [ + { label: 'Temperature (F)', borderColor: '#ff6b35', backgroundColor: 'rgba(255, 107, 53, 0.1)', borderWidth: 2, pointRadius: 0, fill: true, data: [], yAxisID: 'y' }, + { label: 'Target Setpoint (F)', borderColor: '#4ecdc4', borderWidth: 1.5, borderDash: [5, 5], pointRadius: 0, fill: false, data: [], yAxisID: 'y' }, + { label: 'Flight Trajectory (F)', borderColor: '#f39c12', borderWidth: 2, pointRadius: 0, fill: false, data: [], yAxisID: 'y' }, + { label: 'Output', borderColor: '#45b7d1', backgroundColor: 'rgba(69, 183, 209, 0.1)', borderWidth: 1, pointRadius: 0, fill: true, data: [], yAxisID: 'y1' }, + ]}, options: { responsive: true, maintainAspectRatio: false, animation: false, - interaction: { - mode: 'index', - intersect: false - }, + interaction: { mode: 'index', intersect: false }, scales: { - x: { - type: 'linear', - display: true, - title: { display: false }, - ticks: { - color: '#888', - callback: function(value) { - // Show relative seconds - return Math.round(value) + 's'; - }, - maxTicksLimit: 10 - }, - grid: { color: 'rgba(255,255,255,0.05)' } - }, - y: { - type: 'linear', - display: true, - position: 'left', - title: { display: true, text: 'Temperature (F)', color: '#aaa' }, - ticks: { color: '#ff6b35' }, - grid: { color: 'rgba(255,255,255,0.05)' }, - suggestedMin: 0, - suggestedMax: 700 - }, - y1: { - type: 'linear', - display: true, - position: 'right', - title: { display: true, text: 'PID Output', color: '#aaa' }, - ticks: { color: '#45b7d1' }, - grid: { drawOnChartArea: false }, - suggestedMin: 0 - } + x: { type: 'linear', ticks: { color: '#888', callback: function(value) { return Math.round(value) + 's'; }, maxTicksLimit: 10 }, grid: { color: 'rgba(255,255,255,0.05)' } }, + y: { type: 'linear', position: 'left', title: { display: true, text: 'Temperature (F)', color: '#aaa' }, ticks: { color: '#ff6b35' }, grid: { color: 'rgba(255,255,255,0.05)' }, suggestedMin: 0, suggestedMax: 700 }, + y1: { type: 'linear', position: 'right', title: { display: true, text: 'PID Output', color: '#aaa' }, ticks: { color: '#45b7d1' }, grid: { drawOnChartArea: false }, suggestedMin: 0 }, }, - plugins: { - legend: { - labels: { color: '#ccc', boxWidth: 12 } - } - } - } + plugins: { legend: { labels: { color: '#ccc', boxWidth: 12 } } }, + }, }); } -// --------------------------------------------------------------------------- -// Data Update -// --------------------------------------------------------------------------- -let firstTimestamp = null; +function resetChartForNail() { + firstTimestamp = null; + lastTimestamp = 0; + historyBuffer = []; + if (chart) { + chart.data.datasets.forEach(function(ds) { ds.data = []; }); + chart.update('none'); + } + updateErrorStats(); +} function addChartData(points) { if (!chart || !points.length) return; - - if (firstTimestamp === null) { - firstTimestamp = points[0].timestamp; - } - - for (const p of points) { - const x = p.timestamp - firstTimestamp; // relative seconds + if (firstTimestamp === null) firstTimestamp = points[0].timestamp; + points.forEach(function(p) { + const x = p.timestamp - firstTimestamp; + const inRamp = p.mode === 'takeoff' || p.mode === 'descent'; + const flightSp = inRamp && (typeof p.flight_setpoint === 'number') ? p.flight_setpoint : null; chart.data.datasets[0].data.push({ x: x, y: p.temp }); chart.data.datasets[1].data.push({ x: x, y: p.setpoint }); - chart.data.datasets[2].data.push({ x: x, y: p.output }); - } - - // Trim to max points - for (const ds of chart.data.datasets) { - if (ds.data.length > chartMaxPoints) { - ds.data = ds.data.slice(ds.data.length - chartMaxPoints); - } - } - - chart.update('none'); // skip animation for performance + chart.data.datasets[2].data.push({ x: x, y: flightSp }); + chart.data.datasets[3].data.push({ x: x, y: p.output }); + historyBuffer.push(p); + }); + const newestTs = historyBuffer.length ? historyBuffer[historyBuffer.length - 1].timestamp : 0; + historyBuffer = historyBuffer.filter(function(p) { return p.timestamp >= (newestTs - 600); }); + chart.data.datasets.forEach(function(ds) { + if (ds.data.length > chartMaxPoints) ds.data = ds.data.slice(ds.data.length - chartMaxPoints); + }); + chart.update('none'); + updateErrorStats(); } -// --------------------------------------------------------------------------- -// API Calls -// --------------------------------------------------------------------------- async function fetchJSON(url, options) { try { const resp = await fetch(url, options); const payload = await resp.json(); - if (!resp.ok) { - throw new Error(payload.error || ('HTTP ' + resp.status)); - } + if (!resp.ok) throw new Error(payload.error || ('HTTP ' + resp.status)); lastApiError = ''; return payload; } catch (e) { console.error('API error:', url, e); lastApiError = String(e); - if (String(e).indexOf('HTTP') >= 0) { - // API-level error, connection is still fine. - } else { - setConnectionStatus(false); - } + if (String(e).indexOf('HTTP') < 0) setConnectionStatus(false); return null; } } -async function pollStatus() { - const status = await fetchJSON('/api/status'); +async function apiPost(path, payload, nailId) { + const body = Object.assign({}, payload || {}, { nail: nailId || currentNailId() }); + return fetchJSON(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function renderPresets(presets) { + const container = document.getElementById('presets-container'); + if (!container) return; + const buttons = Object.entries(presets || {}).map(function(entry) { + const name = entry[0]; + const temp = entry[1]; + return ''; + }).join(''); + container.innerHTML = buttons; +} + +function renderAdvanced(status) { if (!status) return; - - setConnectionStatus(true); - - // Temperature display const tempEl = document.getElementById('current-temp'); tempEl.textContent = status.temp.toFixed(1); - - // Color the temp based on proximity to setpoint const delta = Math.abs(status.temp - status.setpoint); - if (!status.enabled) { - tempEl.className = 'temp-value'; - } else if (delta < 15) { - tempEl.className = 'temp-value temp-good'; - } else if (delta < 50) { - tempEl.className = 'temp-value temp-warming'; + if (!status.enabled) tempEl.className = 'temp-value'; + else if (delta < 15) tempEl.className = 'temp-value temp-good'; + else if (delta < 50) tempEl.className = 'temp-value temp-warming'; + else tempEl.className = 'temp-value temp-cold'; + + const effective = (typeof status.effective_setpoint === 'number') ? status.effective_setpoint : status.setpoint; + document.getElementById('current-setpoint').textContent = effective.toFixed(0); + + const mode = status.mode || 'grounded'; + const modePill = document.getElementById('mode-pill'); + modePill.className = 'mode-pill ' + mode; + modePill.textContent = 'Mode: ' + mode; + + const modeEta = document.getElementById('mode-eta'); + if ((mode === 'takeoff' || mode === 'descent') && typeof status.mode_eta_seconds === 'number') { + const sec = Math.max(0, Math.round(status.mode_eta_seconds)); + const mm = Math.floor(sec / 60); + const ss = sec % 60; + modeEta.textContent = (mode === 'takeoff' ? 'Time to cruise: ' : 'Time to grounded: ') + mm + 'm ' + ss + 's'; } else { - tempEl.className = 'temp-value temp-cold'; + modeEta.textContent = 'ETA: --'; } - // Setpoint display - document.getElementById('current-setpoint').textContent = status.setpoint.toFixed(0); - currentSetpoint = status.setpoint; + const schedEta = document.getElementById('sched-eta'); + const schedEnabled = !!(status.scheduler && status.scheduler.enabled); + if (schedEnabled && typeof status.next_cutoff_seconds === 'number') { + const sec = Math.max(0, Math.round(status.next_cutoff_seconds)); + const hh = Math.floor(sec / 3600); + const mm = Math.floor((sec % 3600) / 60); + const ss = sec % 60; + schedEta.textContent = 'Time to descent: ' + hh + 'h ' + mm + 'm ' + ss + 's'; + } else if (schedEnabled) schedEta.textContent = 'Time to descent: --'; + else schedEta.textContent = 'Scheduler: off'; - // Power button - isEnabled = status.enabled; const powerBtn = document.getElementById('power-btn'); - if (isEnabled) { - powerBtn.textContent = 'ON'; - powerBtn.className = 'power-btn on'; - } else { - powerBtn.textContent = 'OFF'; - powerBtn.className = 'power-btn off'; - } + powerBtn.textContent = status.enabled ? 'ON' : 'OFF'; + powerBtn.className = 'power-btn ' + (status.enabled ? 'on' : 'off'); - // Safety banner const banner = document.getElementById('safety-banner'); if (status.safety_tripped) { banner.classList.remove('hidden'); - document.getElementById('safety-message').textContent = - 'SAFETY TRIP: ' + status.safety_reason; - } else { - banner.classList.add('hidden'); - } + document.getElementById('safety-message').textContent = 'SAFETY TRIP: ' + status.safety_reason; + } else banner.classList.add('hidden'); - // Status bar - document.getElementById('status-output').textContent = - status.output.toFixed(1) + ' / ' + (status.config.loop_size_ms || '?'); - + document.getElementById('status-output').textContent = status.output.toFixed(1) + ' / ' + (status.config.loop_size_ms || '?'); const relayEl = document.getElementById('status-relay'); relayEl.textContent = status.relay_on ? 'ON' : 'OFF'; relayEl.className = 'value ' + (status.relay_on ? 'relay-on' : 'relay-off'); - - if (status.uptime_seconds !== null) { - const mins = Math.floor(status.uptime_seconds / 60); - const secs = Math.floor(status.uptime_seconds % 60); - document.getElementById('status-uptime').textContent = - mins + 'm ' + secs + 's'; - } else { - document.getElementById('status-uptime').textContent = '--'; - } - + document.getElementById('status-uptime').textContent = status.uptime_seconds !== null ? (Math.floor(status.uptime_seconds / 60) + 'm ' + Math.floor(status.uptime_seconds % 60) + 's') : '--'; document.getElementById('status-loops').textContent = status.loop_count; - const tcEl = document.getElementById('status-tc'); tcEl.textContent = status.thermocouple_connected ? 'OK' : 'DISCONNECTED'; tcEl.className = 'value ' + (status.thermocouple_connected ? 'tc-ok' : 'tc-err'); - // PID fields (only update if user isn't focused on them) - if (document.activeElement.id !== 'pid-kp') - document.getElementById('pid-kp').value = status.pid.kP; - if (document.activeElement.id !== 'pid-ki') - document.getElementById('pid-ki').value = status.pid.kI; - if (document.activeElement.id !== 'pid-kd') - document.getElementById('pid-kd').value = status.pid.kD; - if (document.activeElement.id !== 'pid-pmode') { - const mode = status.pid.proportional_mode || (status.pid.proportional_on_measurement ? 'measurement' : 'error'); - document.getElementById('pid-pmode').value = mode; + if (document.activeElement.id !== 'setpoint-input') document.getElementById('setpoint-input').value = status.setpoint; + if (document.activeElement.id !== 'pid-kp') document.getElementById('pid-kp').value = status.pid.kP; + if (document.activeElement.id !== 'pid-ki') document.getElementById('pid-ki').value = status.pid.kI; + if (document.activeElement.id !== 'pid-kd') document.getElementById('pid-kd').value = status.pid.kD; + if (document.activeElement.id !== 'pid-pmode') document.getElementById('pid-pmode').value = status.pid.proportional_mode || (status.pid.proportional_on_measurement ? 'measurement' : 'error'); + if (document.activeElement.id !== 'control-loop-size') document.getElementById('control-loop-size').value = status.config.loop_size_ms; + if (document.activeElement.id !== 'control-sleep-time') document.getElementById('control-sleep-time').value = status.config.sleep_time; + + const flight = status.flight || {}; + if (document.activeElement.id !== 'flight-takeoff-seconds') document.getElementById('flight-takeoff-seconds').value = flight.takeoff_seconds || 90; + if (document.activeElement.id !== 'flight-descent-seconds') document.getElementById('flight-descent-seconds').value = flight.descent_seconds || 90; + if (document.activeElement.id !== 'flight-descent-target') document.getElementById('flight-descent-target').value = flight.descent_target_f || 120; + const fm = document.getElementById('flight-mode-status'); + if (fm) fm.textContent = 'Current mode: ' + (status.mode || 'grounded') + ' (Power button handles Grounded/Cruise)'; + + if (document.activeElement.id !== 'sched-enabled') document.getElementById('sched-enabled').value = status.scheduler.enabled ? 'true' : 'false'; + const incoming = (status.scheduler.cutoff_times || []).slice().sort(); + if (JSON.stringify(incoming) !== JSON.stringify(schedulerTimesByNail[currentNailId()] || [])) { + schedulerTimesByNail[currentNailId()] = incoming; + renderSchedulerTimes(); } - const tune = status.autotune || {}; - setAutotuneUi(tune); + setAutotuneUi(status.autotune || {}); +} - // Setpoint input (only update if user isn't focused) - if (document.activeElement.id !== 'setpoint-input') - document.getElementById('setpoint-input').value = status.setpoint; - - // Presets - if (status.presets) { - renderPresets(status.presets); - } +async function pollStatus() { + const payload = await fetchJSON('/api/status/all'); + if (!payload || !payload.nails) return; + setConnectionStatus(true); + states.nail1 = payload.nails.nail1 || states.nail1; + states.nail2 = payload.nails.nail2 || states.nail2; + if (payload.presets) renderPresets(payload.presets); + updateSimpleCards(); + renderAdvanced(states[currentNailId()]); } async function pollHistory() { - const data = await fetchJSON('/api/history?since=' + lastTimestamp); + const nailNum = nailNumFromId(currentNailId()); + const data = await fetchJSON('/api/history?since=' + lastTimestamp + '&nail=' + nailNum); if (!data || !data.length) return; lastTimestamp = data[data.length - 1].timestamp; addChartData(data); } -function setConnectionStatus(connected) { - const dot = document.getElementById('connection-status'); - if (connected) { - dot.className = 'status-dot connected'; - dot.title = 'Connected'; - setBackendStatus('online', 'Backend: Online'); - } else { - dot.className = 'status-dot disconnected'; - dot.title = 'Disconnected'; - setBackendStatus('offline', 'Backend: Offline'); - } -} - async function pollHeartbeat() { const hb = await fetchJSON('/api/heartbeat?ts=' + Date.now()); if (!hb || !hb.ok) { @@ -386,66 +463,56 @@ async function pollHeartbeat() { } return; } - - if (heartbeatMisses >= 2) { - showAction('Backend reconnected.', 'success', 2500); - } + if (heartbeatMisses >= 2) showAction('Backend reconnected.', 'success', 2500); heartbeatMisses = 0; setConnectionStatus(true); setControlsEnabled(true); - - if (heartbeatInstanceId === null) { - heartbeatInstanceId = hb.instance_id; - } else if (heartbeatInstanceId !== hb.instance_id) { + if (heartbeatInstanceId === null) heartbeatInstanceId = hb.instance_id; + else if (heartbeatInstanceId !== hb.instance_id) { showAction('Backend restarted. Reloading UI...', 'info', 1500); setTimeout(function() { window.location.reload(); }, 1200); } } -// --------------------------------------------------------------------------- -// User Actions -// --------------------------------------------------------------------------- async function togglePower() { - const result = await fetchJSON('/api/power', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: !isEnabled }) - }); - if (!result) { - setLastAck('power failed', false); - return; - } - setLastAck('power ' + (result.enabled ? 'ON' : 'OFF'), true); - // Reset chart on power toggle - if (!isEnabled) { - firstTimestamp = null; - lastTimestamp = 0; - if (chart) { - for (const ds of chart.data.datasets) ds.data = []; - chart.update('none'); - } - } + const id = currentNailId(); + const s = states[id]; + const target = !(s && s.enabled); + const result = await apiPost('/api/power', { enabled: target }, id); + if (!result) return setLastAck('power failed', false); + setLastAck('power ' + (result.enabled ? 'ON' : 'OFF') + ' (' + id + ')', true); +} + +async function simpleTogglePower(num) { + const id = nailIdFromNum(num); + const s = states[id]; + const target = !(s && s.enabled); + const result = await apiPost('/api/power', { enabled: target }, id); + if (!result) return setLastAck('power failed', false); + setLastAck('power ' + (result.enabled ? 'ON' : 'OFF') + ' (' + id + ')', true); } async function applySetpoint() { const value = parseFloat(document.getElementById('setpoint-input').value); if (isNaN(value)) return; - const result = await fetchJSON('/api/setpoint', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ setpoint: value }) - }); - if (!result) { - setLastAck('setpoint failed', false); - return; - } - setLastAck('setpoint ' + value + 'F', true); + const result = await apiPost('/api/setpoint', { setpoint: value }, currentNailId()); + if (!result) return setLastAck('setpoint failed', false); + setLastAck('setpoint ' + value + 'F (' + currentNailId() + ')', true); +} + +async function simpleApplySetpoint(num) { + const id = nailIdFromNum(num); + const input = document.getElementById('simple-setpoint-' + id); + const value = parseFloat(input.value); + if (isNaN(value)) return; + const result = await apiPost('/api/setpoint', { setpoint: value }, id); + if (!result) return setLastAck('setpoint failed', false); + setLastAck('setpoint ' + value + 'F (' + id + ')', true); } function adjustSetpoint(delta) { const input = document.getElementById('setpoint-input'); - const newVal = parseFloat(input.value) + delta; - input.value = newVal; + input.value = parseFloat(input.value) + delta; applySetpoint(); } @@ -455,110 +522,185 @@ async function applyPID() { const kd = parseFloat(document.getElementById('pid-kd').value); const pMode = document.getElementById('pid-pmode').value; if (isNaN(kp) || isNaN(ki) || isNaN(kd)) return; - const result = await fetchJSON('/api/pid', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ kP: kp, kI: ki, kD: kd, proportional_mode: pMode }) - }); + const result = await apiPost('/api/pid', { kP: kp, kI: ki, kD: kd, proportional_mode: pMode }, currentNailId()); + if (!result) return setLastAck('PID apply failed', false); + setLastAck('PID applied (' + currentNailId() + ')', true); +} + +async function applyControlTiming() { + const loopSize = parseInt(document.getElementById('control-loop-size').value, 10); + const sleepTime = parseFloat(document.getElementById('control-sleep-time').value); + if (isNaN(loopSize) || isNaN(sleepTime)) return; + let payload = { loop_size_ms: loopSize, sleep_time: sleepTime }; + let result = await apiPost('/api/control', payload, currentNailId()); + if (!result && (lastApiError || '').indexOf('confirm_extreme') >= 0) { + if (window.confirm('This timing is EXTREME and can overheat quickly. Continue anyway?')) { + payload.confirm_extreme = true; + result = await apiPost('/api/control', payload, currentNailId()); + } + } if (!result) { - setLastAck('PID apply failed', false); + setLastAck('timing apply failed', false); + showAction(lastApiError || 'Timing update failed', 'error', 4000); return; } - setLastAck('PID applied (' + pMode + ')', true); + setLastAck('timing updated (' + currentNailId() + ')', true); +} + +function applyTimingProfile(profile) { + if (profile === 'conservative') { + document.getElementById('control-loop-size').value = 3000; + document.getElementById('control-sleep-time').value = 0.4; + } else if (profile === 'balanced') { + document.getElementById('control-loop-size').value = 2500; + document.getElementById('control-sleep-time').value = 0.25; + } else if (profile === 'responsive') { + document.getElementById('control-loop-size').value = 1800; + document.getElementById('control-sleep-time').value = 0.2; + } + applyControlTiming(); +} + +async function setFlightMode(mode) { + const takeoff = parseFloat(document.getElementById('flight-takeoff-seconds').value); + const descent = parseFloat(document.getElementById('flight-descent-seconds').value); + const descentTarget = parseFloat(document.getElementById('flight-descent-target').value); + const result = await apiPost('/api/flight', { + mode: mode, + takeoff_seconds: takeoff, + descent_seconds: descent, + descent_target_f: descentTarget, + }, currentNailId()); + if (!result) { + setLastAck('mode ' + mode + ' failed', false); + return; + } + setLastAck('mode ' + mode + ' (' + currentNailId() + ')', true); +} + +async function saveSchedulerSettings() { + const id = currentNailId(); + const enabled = document.getElementById('sched-enabled').value === 'true'; + const times = (schedulerTimesByNail[id] || []).slice(); + const result = await apiPost('/api/scheduler', { enabled: enabled, cutoff_times: times }, id); + if (!result) { + setLastAck('scheduler update failed', false); + return; + } + schedulerTimesByNail[id] = (result.scheduler && result.scheduler.cutoff_times) ? result.scheduler.cutoff_times.slice() : times; + renderSchedulerTimes(); + setLastAck('scheduler updated (' + id + ')', true); } async function resetPID() { - const result = await fetchJSON('/api/pid/reset', { method: 'POST' }); - if (!result) { - setLastAck('PID reset failed', false); - return; - } - setLastAck('PID reset', true); + const result = await apiPost('/api/pid/reset', {}, currentNailId()); + if (!result) return setLastAck('PID reset failed', false); + setLastAck('PID reset (' + currentNailId() + ')', true); } async function startAutotune() { const target = parseFloat(document.getElementById('setpoint-input').value); - showAction('Starting autotune at ' + target + 'F (auto-enables heater if needed)...', 'info', 5000); - setAutotuneUi({ message: 'Starting autotune...', last_result: null, active: true, high_peaks: 0, cycles: 0 }); - const result = await fetchJSON('/api/autotune/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - setpoint: target - }) - }); - if (!result) { - setAutotuneUi({ message: lastApiError || 'Failed to start autotune', active: false, last_result: null }); - showAction(lastApiError || 'Failed to start autotune', 'error', 6000); - setLastAck('autotune start failed', false); - return; - } - showAction('Autotune started. Watch peaks progress.', 'success', 5000); - setAutotuneUi(result.autotune || { message: 'Autotune started', active: true }); - setLastAck('autotune started', true); + const result = await apiPost('/api/autotune/start', { setpoint: target }, currentNailId()); + if (!result) return setLastAck('autotune start failed', false); + setLastAck('autotune started (' + currentNailId() + ')', true); } async function stopAutotune() { - setAutotuneUi({ message: 'Stopping autotune...', last_result: null, active: false }); - showAction('Stopping autotune...', 'info', 3000); - const result = await fetchJSON('/api/autotune/stop', { method: 'POST' }); - if (!result) { - setAutotuneUi({ message: lastApiError || 'Failed to stop autotune', active: false, last_result: null }); - showAction(lastApiError || 'Failed to stop autotune', 'error', 6000); - setLastAck('autotune stop failed', false); - return; - } - showAction('Autotune stopped.', 'success', 4000); - setAutotuneUi(result.autotune || { message: 'Autotune stopped', active: false, last_result: null }); - setLastAck('autotune stopped', true); + const result = await apiPost('/api/autotune/stop', {}, currentNailId()); + if (!result) return setLastAck('autotune stop failed', false); + setLastAck('autotune stopped (' + currentNailId() + ')', true); } async function applyPreset(name) { - const result = await fetchJSON('/api/preset/' + encodeURIComponent(name), { method: 'POST' }); - if (!result) { - setLastAck('preset failed', false); - return; - } - setLastAck('preset ' + name, true); + const result = await apiPost('/api/preset/' + encodeURIComponent(name), {}, currentNailId()); + if (!result) return setLastAck('preset failed', false); + setLastAck('preset ' + name + ' (' + currentNailId() + ')', true); } async function resetSafety() { - const result = await fetchJSON('/api/safety/reset', { method: 'POST' }); - if (!result) { - setLastAck('safety reset failed', false); + const result = await apiPost('/api/safety/reset', {}, currentNailId()); + if (!result) return setLastAck('safety reset failed', false); + setLastAck('safety reset (' + currentNailId() + ')', true); +} + +function isStandalone() { + return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; +} + +function showInstallButton(show) { + const btn = document.getElementById('install-btn'); + if (!btn) return; + btn.hidden = !show; +} + +async function installApp() { + if (!deferredInstallPrompt) { + showAction('Install prompt unavailable. Use your browser menu to install.', 'info', 5000); return; } - setLastAck('safety reset', true); + deferredInstallPrompt.prompt(); + deferredInstallPrompt = null; + showInstallButton(false); } -function renderPresets(presets) { - const container = document.getElementById('presets-container'); - const buttons = Object.entries(presets).map(([name, temp]) => - '' - ).join(''); - container.innerHTML = buttons; -} - -// Handle Enter key in setpoint input document.addEventListener('DOMContentLoaded', function() { - document.getElementById('setpoint-input').addEventListener('keydown', function(e) { - if (e.key === 'Enter') applySetpoint(); - }); - setAutotuneUi({ active: false, message: 'Idle', last_result: null }); + const setpointInput = document.getElementById('setpoint-input'); + if (setpointInput) { + setpointInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') applySetpoint(); + }); + } + + let savedMode = null; + try { + savedMode = localStorage.getItem('pinail_ui_mode'); + } catch (e) { + savedMode = null; + } + if (savedMode === 'advanced') savedMode = 'nail1'; + setUiMode(savedMode || 'nail1', false); + + const schedInput = document.getElementById('sched-time-input'); + if (schedInput) { + schedInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addSchedulerTime(); + } + }); + schedInput.addEventListener('blur', function() { + const n = normalizeTimeString(schedInput.value); + if (n) schedInput.value = n; + }); + } + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/static/sw.js').catch(function(err) { + console.error('Service worker register failed:', err); + }); + } + + if (isStandalone()) showInstallButton(false); }); -// --------------------------------------------------------------------------- -// Init -// --------------------------------------------------------------------------- -initChart(); +window.addEventListener('beforeinstallprompt', function(e) { + e.preventDefault(); + deferredInstallPrompt = e; + if (!isStandalone()) showInstallButton(true); +}); -// Start polling loops +window.addEventListener('appinstalled', function() { + deferredInstallPrompt = null; + showInstallButton(false); + showAction('piNail2 installed.', 'success', 4000); +}); + +initChart(); setInterval(pollStatus, pollInterval); setInterval(pollHistory, pollInterval); setInterval(pollHeartbeat, 2000); - -// Initial fetch +setInterval(updateWallClock, 1000); pollStatus(); pollHistory(); pollHeartbeat(); +updateWallClock(); diff --git a/piNail2/static/manifest.webmanifest b/piNail2/static/manifest.webmanifest new file mode 100644 index 0000000..ff1768e --- /dev/null +++ b/piNail2/static/manifest.webmanifest @@ -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" + } + ] +} diff --git a/piNail2/static/style.css b/piNail2/static/style.css index bd335fd..8de1610 100644 --- a/piNail2/static/style.css +++ b/piNail2/static/style.css @@ -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; } diff --git a/piNail2/static/sw.js b/piNail2/static/sw.js new file mode 100644 index 0000000..fcc9023 --- /dev/null +++ b/piNail2/static/sw.js @@ -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('/'); + }); + }) + ); +}); diff --git a/piNail2/templates/index.html b/piNail2/templates/index.html index 9433ee2..9a21eb6 100644 --- a/piNail2/templates/index.html +++ b/piNail2/templates/index.html @@ -4,7 +4,13 @@ piNail2 Controller + + + + + + @@ -15,23 +21,62 @@

Controller

- Last command: none - Backend: Online +
+ + + +
+ + Last command: none + Backend: Online
+
+
+

Nail 1

+
---°F
+
Mode: grounded
+
Target: ---°F
+
+ + + +
+
+
+

Nail 2

+
---°F
+
Mode: grounded
+
Target: ---°F
+
+ + + +
+
+
+
- --- - °F +
Nail 1
+
--:--:--
+
+ --- + °F +
+
Err(3m): --
Target: ---°F
+
Mode: grounded
+
ETA: --
+
Next descent: --
Autotune: Idle
@@ -52,9 +97,8 @@
- -
- + +

Setpoint

@@ -69,9 +113,34 @@
+
+ + +
+ +
+

Loop Timing

+
+ + + +
+
+ + + + Limits: 1500-5000ms, 0.15-0.6s +
+
-
+

PID Tuning

+
+
+

Flight Modes

+
+ + + Use power button for Grounded/Cruise. +
+
+ + + +
+
+ +
+

Descent Scheduler

+
+ + + +
+
+
+ Scheduler only triggers descent -> grounded. No auto takeoff. +
+
+
+ -
+
Output 0 diff --git a/piNail2/thermocouple.py b/piNail2/thermocouple.py index 0d20f3c..5ce4842 100644 --- a/piNail2/thermocouple.py +++ b/piNail2/thermocouple.py @@ -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], }