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