""" piNail2 — Flask Web Application Main entry point. Runs the Flask web server and initializes the PID controller. Provides REST API endpoints and serves the single-page dashboard. Usage: python3 app.py python3 app.py --config /path/to/config.json """ import sys import os import signal import atexit import logging import argparse import json import time from datetime import datetime from flask import Flask, render_template, jsonify, request from config import Config from thermocouple import Thermocouple from pid_controller import PIDController # --------------------------------------------------------------------------- # Logging setup # --------------------------------------------------------------------------- logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[ logging.StreamHandler(sys.stdout), ] ) log = logging.getLogger("piNail2") APP_INSTANCE_ID = str(int(time.time())) APP_VERSION = "v2.1.0" # --------------------------------------------------------------------------- # Flask app # --------------------------------------------------------------------------- app = Flask(__name__) # These are set in main() before the app starts config = None # type: Config controller = None # type: PIDController tc = None # type: Thermocouple # --------------------------------------------------------------------------- # Routes — Pages # --------------------------------------------------------------------------- @app.route("/") def index(): """Serve the main dashboard.""" return render_template( "index.html", app_version=APP_VERSION, copyright_year=datetime.now().year, ) # --------------------------------------------------------------------------- # Routes — API # --------------------------------------------------------------------------- @app.route("/api/status") def api_status(): """Return current controller state.""" status = controller.status status["instance_id"] = APP_INSTANCE_ID status["thermocouple"] = tc.stats status["presets"] = config.get("presets") status["config"] = { "loop_size_ms": config.get("control", "loop_size_ms"), "sleep_time": config.get("control", "sleep_time"), "safety": config.get("safety"), } return jsonify(status) @app.route("/api/heartbeat") def api_heartbeat(): """Lightweight health endpoint for frontend reconnect logic.""" return jsonify({ "ok": True, "instance_id": APP_INSTANCE_ID, "ts": time.time(), "controller_alive": controller.is_alive, "watchdog_ok": controller.watchdog_ok, }) @app.route("/api/history") def api_history(): """Return recent temperature history for charting.""" since = request.args.get("since", 0, type=float) if since > 0: data = controller.get_history_since(since) else: data = controller.history return jsonify(data) @app.route("/api/power", methods=["POST"]) def api_power(): """Toggle controller on/off.""" body = request.get_json(force=True, silent=True) or {} enable = body.get("enabled") if enable is None: # Toggle enable = not controller.status["enabled"] if enable: controller.start() else: controller.stop() return jsonify({"enabled": enable, "ok": True}) @app.route("/api/setpoint", methods=["POST"]) def api_setpoint(): """Change the target temperature.""" body = request.get_json(force=True, silent=True) or {} value = body.get("setpoint") if value is None: return jsonify({"error": "Missing 'setpoint' field"}), 400 try: value = float(value) except (TypeError, ValueError): return jsonify({"error": "Invalid setpoint value"}), 400 safety = config.get("safety") if value > safety["max_temp_f"]: return jsonify({"error": "Setpoint {} exceeds max {}F".format(value, safety['max_temp_f'])}), 400 if value < safety["min_temp_f"]: return jsonify({"error": "Setpoint {} below min {}F".format(value, safety['min_temp_f'])}), 400 controller.set_setpoint(value) return jsonify({"setpoint": value, "ok": True}) @app.route("/api/pid", methods=["POST"]) def api_pid(): """Update PID tuning parameters.""" body = request.get_json(force=True, silent=True) or {} kp = body.get("kP") ki = body.get("kI") kd = body.get("kD") p_on_m = body.get("proportional_on_measurement") p_mode = body.get("proportional_mode") if kp is None or ki is None or kd is None: return jsonify({"error": "Missing kP, kI, or kD"}), 400 try: kp, ki, kd = float(kp), float(ki), float(kd) except (TypeError, ValueError): return jsonify({"error": "Invalid PID values"}), 400 controller.set_pid_tuning(kp, ki, kd, p_on_m, p_mode) mode_out = "measurement" if controller.status["pid"]["proportional_on_measurement"] else "error" return jsonify({ "kP": kp, "kI": ki, "kD": kd, "proportional_on_measurement": controller.status["pid"]["proportional_on_measurement"], "proportional_mode": mode_out, "ok": True, }) @app.route("/api/preset/", methods=["POST"]) def api_preset(name): """Apply a named temperature preset.""" presets = config.get("presets") if name not in presets: return jsonify({"error": "Unknown preset '{}'".format(name), "available": list(presets.keys())}), 404 value = presets[name] controller.set_setpoint(value) return jsonify({"preset": name, "setpoint": value, "ok": True}) @app.route("/api/presets", methods=["GET"]) def api_presets(): """List all presets.""" return jsonify(config.get("presets")) @app.route("/api/presets", methods=["POST"]) def api_presets_update(): """Add or update a preset.""" body = request.get_json(force=True, silent=True) or {} name = body.get("name") value = body.get("setpoint") if not name or value is None: return jsonify({"error": "Missing 'name' or 'setpoint'"}), 400 try: value = float(value) except (TypeError, ValueError): return jsonify({"error": "Invalid setpoint value"}), 400 presets = config.get("presets") presets[name] = value config.update_section("presets", presets) return jsonify({"ok": True, "presets": presets}) @app.route("/api/presets/", methods=["DELETE"]) def api_preset_delete(name): """Delete a preset.""" presets = config.get("presets") if name not in presets: return jsonify({"error": "Unknown preset '{}'".format(name)}), 404 del presets[name] # Overwrite entire presets section config._data["presets"] = presets config.save() return jsonify({"ok": True, "presets": presets}) @app.route("/api/config", methods=["GET"]) def api_config(): """Return full config (read-only view).""" return jsonify(config.data) @app.route("/api/pid/reset", methods=["POST"]) def api_pid_reset(): """Reset PID controller internals (clears integral windup).""" controller._pid.reset() return jsonify({"ok": True, "message": "PID reset"}) @app.route("/api/autotune", methods=["GET"]) def api_autotune_status(): """Return current autotune state.""" return jsonify(controller.autotune_status) @app.route("/api/autotune/start", methods=["POST"]) def api_autotune_start(): """Start relay-based PID autotune.""" if not controller.status.get("enabled"): controller.start() time.sleep(0.2) body = request.get_json(force=True, silent=True) or {} setpoint = body.get("setpoint") hysteresis = body.get("hysteresis") cycles = body.get("cycles") controller.start_autotune(setpoint=setpoint, hysteresis=hysteresis, cycles=cycles) return jsonify({"ok": True, "autotune": controller.autotune_status}) @app.route("/api/autotune/stop", methods=["POST"]) def api_autotune_stop(): """Stop PID autotune.""" controller.stop_autotune("Autotune stopped by user") return jsonify({"ok": True, "autotune": controller.autotune_status}) @app.route("/api/safety/reset", methods=["POST"]) def api_safety_reset(): """Reset safety trip (clears the safety flag so controller can be restarted).""" controller._safety_tripped = False controller._safety_reason = "" return jsonify({"ok": True}) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def shutdown_handler(*args): """Handle SIGTERM/SIGINT gracefully.""" log.info("Shutdown signal received") if controller: controller.cleanup() sys.exit(0) def main(): global config, controller, tc parser = argparse.ArgumentParser(description="piNail2 — E-Nail Temperature Controller") parser.add_argument("--config", default="config.json", help="Path to config file") args = parser.parse_args() # Load config config = Config(args.config) log.info("Configuration loaded from %s", args.config) # Initialize thermocouple gpio_cfg = config.get("gpio") safety_cfg = config.get("safety") tc = Thermocouple( clk=gpio_cfg["clk"], cs=gpio_cfg["cs"], do=gpio_cfg["do"], spike_threshold=safety_cfg["spike_threshold_f"] ) # Initialize PID controller controller = PIDController(config, tc) # Register cleanup signal.signal(signal.SIGTERM, shutdown_handler) signal.signal(signal.SIGINT, shutdown_handler) atexit.register(controller.cleanup) # Start Flask web_cfg = config.get("web") log.info("Starting web server on %s:%d", web_cfg["host"], web_cfg["port"]) app.run( host=web_cfg["host"], port=web_cfg["port"], debug=False, threaded=True ) if __name__ == "__main__": main()