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()
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"pid": {
|
||||
"kP": 10.0,
|
||||
"kI": 5.0,
|
||||
"kD": 1.0,
|
||||
"proportional_on_measurement": false
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"enabled": false
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
"min_temp_f": 0
|
||||
},
|
||||
"gpio": {
|
||||
"relay_pin": 2,
|
||||
"clk": 3,
|
||||
"cs": 14,
|
||||
"do": 4
|
||||
},
|
||||
"logging": {
|
||||
"log_resolution": 1,
|
||||
"log_directory": "./logs",
|
||||
"max_log_lines": 10000
|
||||
},
|
||||
"presets": {
|
||||
"Low Temp": 450,
|
||||
"Medium": 530,
|
||||
"High": 650
|
||||
},
|
||||
"web": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 5000,
|
||||
"update_interval_ms": 500
|
||||
},
|
||||
"autotune": {
|
||||
"hysteresis_f": 8.0,
|
||||
"cycles": 4
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
piNail2 Configuration Management
|
||||
|
||||
JSON-based config with named fields, load/save, defaults, and hot-reload support.
|
||||
Replaces the old positional P_I_D_values.txt format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import copy
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"pid": {
|
||||
"kP": 10.0,
|
||||
"kI": 5.0,
|
||||
"kD": 1.0,
|
||||
"proportional_on_measurement": False
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"enabled": False
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
"min_temp_f": 0
|
||||
},
|
||||
"gpio": {
|
||||
"relay_pin": 2,
|
||||
"clk": 3,
|
||||
"cs": 14,
|
||||
"do": 4
|
||||
},
|
||||
"logging": {
|
||||
"log_resolution": 1,
|
||||
"log_directory": "./logs",
|
||||
"max_log_lines": 10000
|
||||
},
|
||||
"presets": {
|
||||
"Low Temp": 450,
|
||||
"Medium": 530,
|
||||
"High": 650
|
||||
},
|
||||
"web": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 5000,
|
||||
"update_interval_ms": 500
|
||||
},
|
||||
"autotune": {
|
||||
"hysteresis_f": 8.0,
|
||||
"cycles": 4
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Config:
|
||||
"""Thread-safe configuration manager with file persistence."""
|
||||
|
||||
def __init__(self, config_path="config.json"):
|
||||
self._path = config_path
|
||||
self._data = copy.deepcopy(DEFAULT_CONFIG)
|
||||
self._mtime = 0
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""Load config from disk, merging with defaults for any missing keys."""
|
||||
if not os.path.exists(self._path):
|
||||
log.info("No config file found at %s, creating with defaults", self._path)
|
||||
self.save()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self._path, 'r') as f:
|
||||
user_config = json.load(f)
|
||||
self._data = self._deep_merge(copy.deepcopy(DEFAULT_CONFIG), user_config)
|
||||
self._mtime = os.path.getmtime(self._path)
|
||||
log.info("Config loaded from %s", self._path)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
log.error("Failed to load config from %s: %s. Using defaults.", self._path, e)
|
||||
self._data = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
def save(self):
|
||||
"""Write current config to disk."""
|
||||
try:
|
||||
with open(self._path, 'w') as f:
|
||||
json.dump(self._data, f, indent=2)
|
||||
self._mtime = os.path.getmtime(self._path)
|
||||
log.info("Config saved to %s", self._path)
|
||||
except IOError as e:
|
||||
log.error("Failed to save config to %s: %s", self._path, e)
|
||||
|
||||
def reload_if_changed(self):
|
||||
"""Reload config from disk if the file has been modified externally."""
|
||||
try:
|
||||
current_mtime = os.path.getmtime(self._path)
|
||||
if current_mtime > self._mtime:
|
||||
log.info("Config file changed on disk, reloading")
|
||||
self.load()
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
def get(self, section, key=None):
|
||||
"""Get a config value. If key is None, returns the entire section."""
|
||||
if key is None:
|
||||
return copy.deepcopy(self._data.get(section, {}))
|
||||
return self._data.get(section, {}).get(key)
|
||||
|
||||
def set(self, section, key, value):
|
||||
"""Set a config value and save to disk."""
|
||||
if section not in self._data:
|
||||
self._data[section] = {}
|
||||
self._data[section][key] = value
|
||||
self.save()
|
||||
|
||||
def update_section(self, section, values):
|
||||
"""Update multiple keys in a section and save to disk."""
|
||||
if section not in self._data:
|
||||
self._data[section] = {}
|
||||
self._data[section].update(values)
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
"""Return a deep copy of the full config dict."""
|
||||
return copy.deepcopy(self._data)
|
||||
|
||||
@staticmethod
|
||||
def _deep_merge(base, override):
|
||||
"""Recursively merge override into base. Override values win."""
|
||||
result = base.copy()
|
||||
for key, value in override.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = Config._deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# piNail2 Deploy Script
|
||||
#
|
||||
# Deploys piNail2 to the Raspberry Pi. Steps:
|
||||
# 1. Copy piNail2 files to ~/piNail2/ on the Pi
|
||||
# 2. Copy the MAX6675 library from the old project
|
||||
# 3. Install pip dependencies (system-wide for Python 3.5)
|
||||
# 4. Optionally install and enable the systemd service
|
||||
#
|
||||
# Usage: ./deploy.sh [--service]
|
||||
# --service Also install and enable the systemd service
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PI_HOST="pinail" # SSH alias from ~/.ssh/config
|
||||
PI_USER="pi"
|
||||
REMOTE_DIR="/home/${PI_USER}/piNail2"
|
||||
LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTALL_SERVICE=false
|
||||
|
||||
if [[ "${1:-}" == "--service" ]]; then
|
||||
INSTALL_SERVICE=true
|
||||
fi
|
||||
|
||||
echo "=== piNail2 Deployment ==="
|
||||
echo "Target: ${PI_HOST} -> ${REMOTE_DIR}"
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Step 1: Copy project files
|
||||
# -------------------------------------------------------------------
|
||||
echo "--- Step 1: Copying piNail2 to Pi ---"
|
||||
|
||||
ssh ${PI_HOST} "mkdir -p ${REMOTE_DIR}/static ${REMOTE_DIR}/templates ${REMOTE_DIR}/logs"
|
||||
|
||||
# Copy Python source files
|
||||
scp -q "${LOCAL_DIR}/app.py" \
|
||||
"${LOCAL_DIR}/config.py" \
|
||||
"${LOCAL_DIR}/thermocouple.py" \
|
||||
"${LOCAL_DIR}/pid_controller.py" \
|
||||
"${LOCAL_DIR}/config.json" \
|
||||
"${LOCAL_DIR}/requirements.txt" \
|
||||
"${LOCAL_DIR}/pinail.service" \
|
||||
"${PI_HOST}:${REMOTE_DIR}/"
|
||||
|
||||
# Copy static + templates
|
||||
scp -q "${LOCAL_DIR}/static/app.js" \
|
||||
"${LOCAL_DIR}/static/style.css" \
|
||||
"${PI_HOST}:${REMOTE_DIR}/static/"
|
||||
|
||||
scp -q "${LOCAL_DIR}/templates/index.html" \
|
||||
"${PI_HOST}:${REMOTE_DIR}/templates/"
|
||||
|
||||
echo "Files copied."
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Step 2: Copy MAX6675 library from old project
|
||||
# -------------------------------------------------------------------
|
||||
echo "--- Step 2: Copying MAX6675 library ---"
|
||||
|
||||
ssh ${PI_HOST} bash -s <<'REMOTE_SCRIPT'
|
||||
# Copy the MAX6675 Python package from the old piNail project
|
||||
if [ -d /home/pi/piNail/MAX6675-master/MAX6675 ]; then
|
||||
cp -r /home/pi/piNail/MAX6675-master/MAX6675 /home/pi/piNail2/MAX6675
|
||||
echo "MAX6675 library copied from old project"
|
||||
else
|
||||
echo "WARNING: MAX6675 library not found in old project!"
|
||||
fi
|
||||
REMOTE_SCRIPT
|
||||
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Step 3: Install pip dependencies
|
||||
# -------------------------------------------------------------------
|
||||
echo "--- Step 3: Installing dependencies ---"
|
||||
|
||||
ssh ${PI_HOST} bash -s <<'REMOTE_SCRIPT'
|
||||
cd /home/pi/piNail2
|
||||
|
||||
echo "Installing dependencies with pip3..."
|
||||
pip3 install --user -r requirements.txt 2>&1 | tail -10
|
||||
|
||||
echo ""
|
||||
echo "Key packages:"
|
||||
pip3 show Flask simple-pid RPi.GPIO Adafruit-GPIO 2>/dev/null | grep -E '^(Name|Version):'
|
||||
REMOTE_SCRIPT
|
||||
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Step 4: Systemd service (optional)
|
||||
# -------------------------------------------------------------------
|
||||
if $INSTALL_SERVICE; then
|
||||
echo "--- Step 4: Installing systemd service ---"
|
||||
|
||||
ssh ${PI_HOST} bash -s <<'REMOTE_SCRIPT'
|
||||
sudo cp /home/pi/piNail2/pinail.service /etc/systemd/system/pinail2.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable pinail2.service
|
||||
echo "Service installed and enabled (pinail2.service)"
|
||||
echo "Start with: sudo systemctl start pinail2"
|
||||
echo "Logs with: sudo journalctl -u pinail2 -f"
|
||||
REMOTE_SCRIPT
|
||||
else
|
||||
echo "--- Step 4: Skipping systemd service (use --service flag to install) ---"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Deployment complete! ==="
|
||||
echo ""
|
||||
echo "To run manually:"
|
||||
echo " ssh ${PI_HOST}"
|
||||
echo " cd ~/piNail2"
|
||||
echo " python3 app.py"
|
||||
echo ""
|
||||
echo "Web UI will be at: http://192.168.0.159:5000"
|
||||
@@ -0,0 +1,575 @@
|
||||
"""
|
||||
piNail2 PID Controller
|
||||
|
||||
Runs the PID control loop in a background thread. Controls a relay via GPIO
|
||||
using software PWM (duty cycle within a time window) to maintain a target
|
||||
temperature on an e-nail heating coil.
|
||||
|
||||
Safety features:
|
||||
- Hard max temperature cutoff
|
||||
- Thermocouple disconnect detection -> relay OFF
|
||||
- Idle auto-shutoff after configurable timeout
|
||||
- Watchdog: detects if control loop stalls
|
||||
- Proper GPIO cleanup on shutdown
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
import os
|
||||
import csv
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PIDController:
|
||||
"""
|
||||
Threaded PID temperature controller for e-nail heating.
|
||||
|
||||
The control loop runs in a background daemon thread. It reads temperature
|
||||
from a Thermocouple object, computes PID output, and drives a relay GPIO
|
||||
pin using software PWM (time-proportional control within each loop cycle).
|
||||
|
||||
Thread-safe properties expose current state to the Flask web server.
|
||||
"""
|
||||
|
||||
def __init__(self, config, thermocouple):
|
||||
"""
|
||||
Args:
|
||||
config: Config instance
|
||||
thermocouple: Thermocouple instance
|
||||
"""
|
||||
self._config = config
|
||||
self._tc = thermocouple
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# State
|
||||
self._enabled = False
|
||||
self._temp = 0.0
|
||||
self._setpoint = config.get("control", "setpoint")
|
||||
self._output = 0.0
|
||||
self._relay_on = False
|
||||
self._loop_count = 0
|
||||
self._start_time = None
|
||||
self._last_loop_time = None
|
||||
self._idle_since = None # timestamp when temp first reached setpoint vicinity
|
||||
self._safety_tripped = False
|
||||
self._safety_reason = ""
|
||||
self._thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Autotune state
|
||||
self._autotune_active = False
|
||||
self._autotune_target = self._setpoint
|
||||
self._autotune_hysteresis = config.get("autotune", "hysteresis_f")
|
||||
self._autotune_cycles = config.get("autotune", "cycles")
|
||||
self._autotune_heating = False
|
||||
self._autotune_phase_started = None
|
||||
self._autotune_phase_extreme = None
|
||||
self._autotune_high_peaks = []
|
||||
self._autotune_low_peaks = []
|
||||
self._autotune_last_result = None
|
||||
self._autotune_message = ""
|
||||
|
||||
# PID instance
|
||||
from simple_pid import PID
|
||||
pid_cfg = config.get("pid")
|
||||
self._pid = PID(
|
||||
pid_cfg["kP"],
|
||||
pid_cfg["kI"],
|
||||
pid_cfg["kD"],
|
||||
setpoint=self._setpoint
|
||||
)
|
||||
self._pid.proportional_on_measurement = pid_cfg.get("proportional_on_measurement", True)
|
||||
loop_size = config.get("control", "loop_size_ms")
|
||||
self._pid.output_limits = (0, loop_size)
|
||||
|
||||
# GPIO setup
|
||||
self._relay_pin = config.get("gpio", "relay_pin")
|
||||
self._gpio = None
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
self._gpio = GPIO
|
||||
GPIO.setwarnings(False)
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
GPIO.setup(self._relay_pin, GPIO.OUT, initial=GPIO.LOW)
|
||||
log.info("GPIO initialized, relay pin %d set LOW", self._relay_pin)
|
||||
except Exception as e:
|
||||
log.error("Failed to initialize GPIO: %s", e)
|
||||
|
||||
# Logging setup
|
||||
self._log_dir = config.get("logging", "log_directory")
|
||||
os.makedirs(self._log_dir, exist_ok=True)
|
||||
self._log_file = None
|
||||
self._log_writer = None
|
||||
self._log_counter = 0
|
||||
self._history = [] # Recent data points for the web UI
|
||||
self._history_max = 1000
|
||||
self._init_log_file()
|
||||
|
||||
def _init_log_file(self):
|
||||
"""Create a new CSV log file with a timestamp in the filename."""
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = os.path.join(self._log_dir, "pinail_{}.csv".format(timestamp))
|
||||
self._log_file = open(path, 'w', newline='')
|
||||
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"
|
||||
])
|
||||
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."""
|
||||
with self._lock:
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
log.warning("Controller is already running")
|
||||
return
|
||||
|
||||
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._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()
|
||||
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")
|
||||
|
||||
def _relay_off(self):
|
||||
"""Ensure relay is OFF."""
|
||||
if self._gpio is not None:
|
||||
try:
|
||||
self._gpio.output(self._relay_pin, self._gpio.LOW)
|
||||
except Exception as e:
|
||||
log.error("Failed to turn relay off: %s", e)
|
||||
self._relay_on = False
|
||||
|
||||
def _relay_set(self, on):
|
||||
"""Set relay state."""
|
||||
if self._gpio is not None:
|
||||
try:
|
||||
self._gpio.output(
|
||||
self._relay_pin,
|
||||
self._gpio.HIGH if on else self._gpio.LOW
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Failed to set relay: %s", e)
|
||||
self._relay_on = on
|
||||
|
||||
def _reset_autotune_state(self):
|
||||
self._autotune_heating = False
|
||||
self._autotune_phase_started = None
|
||||
self._autotune_phase_extreme = None
|
||||
self._autotune_high_peaks = []
|
||||
self._autotune_low_peaks = []
|
||||
|
||||
def start_autotune(self, setpoint=None, hysteresis=None, cycles=None):
|
||||
with self._lock:
|
||||
if setpoint is not None:
|
||||
self._setpoint = float(setpoint)
|
||||
self._config.set("control", "setpoint", float(setpoint))
|
||||
self._autotune_target = self._setpoint
|
||||
if hysteresis is not None:
|
||||
self._autotune_hysteresis = float(hysteresis)
|
||||
if cycles is not None:
|
||||
self._autotune_cycles = int(cycles)
|
||||
self._autotune_active = True
|
||||
self._autotune_message = "Autotune running"
|
||||
self._autotune_last_result = None
|
||||
self._reset_autotune_state()
|
||||
self._pid.reset()
|
||||
log.info(
|
||||
"Autotune started target=%.1fF hysteresis=%.1f cycles=%d",
|
||||
self._autotune_target,
|
||||
self._autotune_hysteresis,
|
||||
self._autotune_cycles,
|
||||
)
|
||||
|
||||
def stop_autotune(self, message="Autotune stopped"):
|
||||
with self._lock:
|
||||
self._autotune_active = False
|
||||
self._autotune_message = message
|
||||
self._relay_off()
|
||||
|
||||
def _update_autotune(self, temp, loop_size):
|
||||
target = self._autotune_target
|
||||
h = self._autotune_hysteresis
|
||||
now = time.monotonic()
|
||||
|
||||
if self._autotune_phase_started is None:
|
||||
self._autotune_phase_started = now
|
||||
self._autotune_phase_extreme = temp
|
||||
self._autotune_heating = temp < target
|
||||
|
||||
if self._autotune_heating:
|
||||
if self._autotune_phase_extreme is None or temp > self._autotune_phase_extreme:
|
||||
self._autotune_phase_extreme = temp
|
||||
if temp >= target + h:
|
||||
self._autotune_high_peaks.append((now, self._autotune_phase_extreme))
|
||||
self._autotune_heating = False
|
||||
self._autotune_phase_started = now
|
||||
self._autotune_phase_extreme = temp
|
||||
else:
|
||||
if self._autotune_phase_extreme is None or temp < self._autotune_phase_extreme:
|
||||
self._autotune_phase_extreme = temp
|
||||
if temp <= target - h:
|
||||
self._autotune_low_peaks.append((now, self._autotune_phase_extreme))
|
||||
self._autotune_heating = True
|
||||
self._autotune_phase_started = now
|
||||
self._autotune_phase_extreme = temp
|
||||
|
||||
highs = len(self._autotune_high_peaks)
|
||||
lows = len(self._autotune_low_peaks)
|
||||
if min(highs, lows) >= self._autotune_cycles and highs >= 2:
|
||||
high_vals = [v for _, v in self._autotune_high_peaks[-self._autotune_cycles:]]
|
||||
low_vals = [v for _, v in self._autotune_low_peaks[-self._autotune_cycles:]]
|
||||
amp = (sum(high_vals) / float(len(high_vals)) - sum(low_vals) / float(len(low_vals))) / 2.0
|
||||
if amp <= 0:
|
||||
self.stop_autotune("Autotune failed: invalid oscillation amplitude")
|
||||
return 0.0
|
||||
|
||||
high_times = [t for t, _ in self._autotune_high_peaks[-self._autotune_cycles:]]
|
||||
periods = []
|
||||
for i in range(1, len(high_times)):
|
||||
periods.append(high_times[i] - high_times[i - 1])
|
||||
if not periods:
|
||||
self.stop_autotune("Autotune failed: not enough periods")
|
||||
return 0.0
|
||||
|
||||
pu = sum(periods) / float(len(periods))
|
||||
if pu <= 0:
|
||||
self.stop_autotune("Autotune failed: invalid period")
|
||||
return 0.0
|
||||
|
||||
d = loop_size / 2.0
|
||||
ku = (4.0 * d) / (math.pi * amp)
|
||||
|
||||
kp = 0.6 * ku
|
||||
ki = (1.2 * ku) / pu
|
||||
kd = 0.075 * ku * pu
|
||||
|
||||
# Safety clamp for aggressive/unstable autotune outputs.
|
||||
kp = max(0.0, min(kp, 300.0))
|
||||
ki = max(0.0, min(ki, 50.0))
|
||||
kd = max(0.0, min(kd, 500.0))
|
||||
|
||||
self._pid.tunings = (kp, ki, kd)
|
||||
self._pid.reset()
|
||||
self._config.update_section("pid", {
|
||||
"kP": round(kp, 4),
|
||||
"kI": round(ki, 4),
|
||||
"kD": round(kd, 4),
|
||||
})
|
||||
|
||||
self._autotune_last_result = {
|
||||
"kP": round(kp, 4),
|
||||
"kI": round(ki, 4),
|
||||
"kD": round(kd, 4),
|
||||
"Ku": round(ku, 4),
|
||||
"Pu": round(pu, 4),
|
||||
"amplitude": round(amp, 4),
|
||||
}
|
||||
self.stop_autotune("Autotune complete")
|
||||
log.info("Autotune complete: %s", self._autotune_last_result)
|
||||
return 0.0
|
||||
|
||||
return float(loop_size if self._autotune_heating else 0.0)
|
||||
|
||||
def _control_loop(self):
|
||||
"""
|
||||
Main PID control loop. Runs in a background thread.
|
||||
|
||||
Each iteration is one "loop cycle" of loop_size_ms milliseconds.
|
||||
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()
|
||||
|
||||
# Apply current PID tuning
|
||||
pid_cfg = self._config.get("pid")
|
||||
self._pid.tunings = (pid_cfg["kP"], pid_cfg["kI"], pid_cfg["kD"])
|
||||
self._pid.proportional_on_measurement = pid_cfg.get(
|
||||
"proportional_on_measurement", True
|
||||
)
|
||||
|
||||
control_cfg = self._config.get("control")
|
||||
loop_size = control_cfg["loop_size_ms"]
|
||||
sleep_time = control_cfg["sleep_time"]
|
||||
log_resolution = self._config.get("logging", "log_resolution")
|
||||
|
||||
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")
|
||||
self._trip_safety("Thermocouple disconnected")
|
||||
break
|
||||
|
||||
with self._lock:
|
||||
self._temp = temp
|
||||
|
||||
# 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
|
||||
|
||||
if not self._tc.is_connected:
|
||||
self._trip_safety("Thermocouple disconnected")
|
||||
break
|
||||
|
||||
# Idle shutoff check
|
||||
idle_minutes = safety.get("idle_shutoff_minutes", 0)
|
||||
if idle_minutes > 0:
|
||||
near_setpoint = abs(temp - self._setpoint) < 20
|
||||
if near_setpoint:
|
||||
if self._idle_since is None:
|
||||
self._idle_since = time.monotonic()
|
||||
elif (time.monotonic() - self._idle_since) > (idle_minutes * 60):
|
||||
self._trip_safety(
|
||||
"Idle shutoff: at setpoint for {} minutes".format(idle_minutes)
|
||||
)
|
||||
break
|
||||
else:
|
||||
self._idle_since = None
|
||||
|
||||
# PID compute and software PWM cycle
|
||||
start_ms = time.monotonic() * 1000
|
||||
end_ms = start_ms + loop_size
|
||||
|
||||
while time.monotonic() * 1000 < end_ms:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
temp = self._tc.read()
|
||||
if temp is not None:
|
||||
with self._lock:
|
||||
self._temp = temp
|
||||
|
||||
if self._autotune_active:
|
||||
output = self._update_autotune(temp, loop_size)
|
||||
else:
|
||||
output = self._pid(temp)
|
||||
with self._lock:
|
||||
self._output = output
|
||||
|
||||
# Software PWM: relay ON for first `output` ms of the cycle
|
||||
elapsed_ms = time.monotonic() * 1000 - start_ms
|
||||
should_be_on = elapsed_ms < output
|
||||
self._relay_set(should_be_on)
|
||||
|
||||
# Logging
|
||||
self._log_counter += 1
|
||||
if self._log_counter >= log_resolution:
|
||||
self._log_data_point(temp, output)
|
||||
self._log_counter = 0
|
||||
|
||||
with self._lock:
|
||||
self._last_loop_time = time.monotonic()
|
||||
self._loop_count += 1
|
||||
|
||||
time.sleep(sleep_time)
|
||||
|
||||
except Exception as e:
|
||||
log.exception("Control loop crashed: %s", e)
|
||||
self._trip_safety("Control loop error: {}".format(e))
|
||||
finally:
|
||||
self._relay_off()
|
||||
log.info("Control loop thread exiting, relay OFF")
|
||||
|
||||
def _trip_safety(self, reason):
|
||||
"""Trip safety shutdown."""
|
||||
log.warning("SAFETY TRIP: %s", reason)
|
||||
with self._lock:
|
||||
self._safety_tripped = True
|
||||
self._safety_reason = reason
|
||||
self._enabled = False
|
||||
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()
|
||||
row = [
|
||||
"{:.3f}".format(now),
|
||||
"{:.2f}".format(temp),
|
||||
"{:.2f}".format(self._setpoint),
|
||||
"{:.2f}".format(output),
|
||||
1 if self._relay_on else 0,
|
||||
"{:.4f}".format(self._pid.Kp),
|
||||
"{:.4f}".format(self._pid.Ki),
|
||||
"{:.4f}".format(self._pid.Kd),
|
||||
self._config.get("control", "loop_size_ms")
|
||||
]
|
||||
|
||||
# CSV file
|
||||
if self._log_writer:
|
||||
try:
|
||||
self._log_writer.writerow(row)
|
||||
self._log_file.flush()
|
||||
except Exception as e:
|
||||
log.error("Failed to write log: %s", e)
|
||||
|
||||
# In-memory history for web UI
|
||||
data_point = {
|
||||
"timestamp": now,
|
||||
"temp": round(temp, 2),
|
||||
"setpoint": round(self._setpoint, 2),
|
||||
"output": round(output, 2),
|
||||
"relay": self._relay_on
|
||||
}
|
||||
with self._lock:
|
||||
self._history.append(data_point)
|
||||
if len(self._history) > self._history_max:
|
||||
self._history = self._history[-self._history_max:]
|
||||
|
||||
# --- Public API (thread-safe) ---
|
||||
|
||||
def set_setpoint(self, value):
|
||||
"""Change the target temperature."""
|
||||
with self._lock:
|
||||
old_setpoint = self._setpoint
|
||||
self._setpoint = float(value)
|
||||
self._idle_since = None # Reset idle timer on setpoint change
|
||||
if abs(self._setpoint - old_setpoint) >= 10:
|
||||
self._pid.reset()
|
||||
self._config.set("control", "setpoint", float(value))
|
||||
log.info("Setpoint changed to %.0fF", value)
|
||||
|
||||
def set_pid_tuning(self, kp, ki, kd, proportional_on_measurement=None, proportional_mode=None):
|
||||
"""Update PID gains."""
|
||||
section = {"kP": kp, "kI": ki, "kD": kd}
|
||||
if proportional_mode in ("error", "measurement"):
|
||||
proportional_on_measurement = (proportional_mode == "measurement")
|
||||
if proportional_on_measurement is not None:
|
||||
section["proportional_on_measurement"] = bool(proportional_on_measurement)
|
||||
self._pid.proportional_on_measurement = bool(proportional_on_measurement)
|
||||
self._config.update_section("pid", section)
|
||||
self._pid.tunings = (kp, ki, kd)
|
||||
self._pid.reset()
|
||||
log.info("PID tuning updated: kP=%.4f, kI=%.4f, kD=%.4f", kp, ki, kd)
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
"""Return a dict of current controller state (thread-safe snapshot)."""
|
||||
with self._lock:
|
||||
uptime = None
|
||||
if self._start_time is not None and self._enabled:
|
||||
uptime = round(time.monotonic() - self._start_time, 1)
|
||||
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"temp": round(self._temp, 2),
|
||||
"setpoint": round(self._setpoint, 2),
|
||||
"output": round(self._output, 2),
|
||||
"relay_on": self._relay_on,
|
||||
"loop_count": self._loop_count,
|
||||
"uptime_seconds": uptime,
|
||||
"safety_tripped": self._safety_tripped,
|
||||
"safety_reason": self._safety_reason,
|
||||
"thermocouple_connected": self._tc.is_connected,
|
||||
"pid": {
|
||||
"kP": self._pid.Kp,
|
||||
"kI": self._pid.Ki,
|
||||
"kD": self._pid.Kd,
|
||||
"proportional_on_measurement": self._pid.proportional_on_measurement,
|
||||
"proportional_mode": "measurement" if self._pid.proportional_on_measurement else "error",
|
||||
},
|
||||
"autotune": {
|
||||
"active": self._autotune_active,
|
||||
"target": round(self._autotune_target, 2),
|
||||
"hysteresis": round(self._autotune_hysteresis, 2),
|
||||
"cycles": self._autotune_cycles,
|
||||
"high_peaks": len(self._autotune_high_peaks),
|
||||
"low_peaks": len(self._autotune_low_peaks),
|
||||
"phase": "heating" if self._autotune_heating else "cooling",
|
||||
"message": self._autotune_message,
|
||||
"last_result": self._autotune_last_result,
|
||||
},
|
||||
}
|
||||
|
||||
@property
|
||||
def autotune_status(self):
|
||||
with self._lock:
|
||||
return {
|
||||
"active": self._autotune_active,
|
||||
"target": round(self._autotune_target, 2),
|
||||
"hysteresis": round(self._autotune_hysteresis, 2),
|
||||
"cycles": self._autotune_cycles,
|
||||
"high_peaks": len(self._autotune_high_peaks),
|
||||
"low_peaks": len(self._autotune_low_peaks),
|
||||
"phase": "heating" if self._autotune_heating else "cooling",
|
||||
"message": self._autotune_message,
|
||||
"last_result": self._autotune_last_result,
|
||||
}
|
||||
|
||||
@property
|
||||
def history(self):
|
||||
"""Return recent data points for charting."""
|
||||
with self._lock:
|
||||
return list(self._history)
|
||||
|
||||
def get_history_since(self, since_timestamp):
|
||||
"""Return data points newer than the given timestamp."""
|
||||
with self._lock:
|
||||
return [p for p in self._history if p["timestamp"] > since_timestamp]
|
||||
|
||||
@property
|
||||
def is_alive(self):
|
||||
"""Check if the control loop thread is alive."""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
@property
|
||||
def watchdog_ok(self):
|
||||
"""Check if the control loop has updated recently."""
|
||||
if not self._enabled:
|
||||
return True
|
||||
with self._lock:
|
||||
if self._last_loop_time is None:
|
||||
return True
|
||||
timeout = self._config.get("safety", "watchdog_timeout_s")
|
||||
return (time.monotonic() - self._last_loop_time) < timeout
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up GPIO and close log file. Call on application shutdown."""
|
||||
self.stop()
|
||||
if self._log_file:
|
||||
try:
|
||||
self._log_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self._gpio is not None:
|
||||
try:
|
||||
self._gpio.cleanup()
|
||||
log.info("GPIO cleaned up")
|
||||
except Exception as e:
|
||||
log.error("GPIO cleanup error: %s", e)
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=piNail2 E-Nail Temperature Controller
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi/piNail2
|
||||
ExecStart=/usr/bin/python3 app.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Safety: if the service crashes, GPIO cleanup happens in the app.
|
||||
# But as an extra safety net, run a one-shot relay-off on stop.
|
||||
ExecStopPost=/bin/bash -c 'echo "2" > /sys/class/gpio/unexport 2>/dev/null || true'
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,6 @@
|
||||
# piNail2 dependencies
|
||||
# Compatible with Python 3.5 on Raspbian Stretch
|
||||
Flask>=0.12,<1.0
|
||||
simple-pid>=0.1,<2.0
|
||||
RPi.GPIO>=0.6
|
||||
Adafruit-GPIO>=1.0
|
||||
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* 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 chart = null;
|
||||
let lastApiError = '';
|
||||
let actionBannerTimer = null;
|
||||
let heartbeatMisses = 0;
|
||||
let heartbeatInstanceId = null;
|
||||
let controlsEnabled = true;
|
||||
|
||||
function nowHms() {
|
||||
const d = new Date();
|
||||
return d.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function setLastAck(message, ok=true) {
|
||||
const el = document.getElementById('last-ack');
|
||||
if (!el) return;
|
||||
el.className = 'last-ack ' + (ok ? 'ok' : 'err');
|
||||
el.textContent = 'Last command: ' + message + ' at ' + nowHms();
|
||||
}
|
||||
|
||||
function showAction(message, type='info', timeoutMs=3000) {
|
||||
const banner = document.getElementById('action-banner');
|
||||
const msg = document.getElementById('action-message');
|
||||
if (!banner || !msg) return;
|
||||
msg.textContent = message;
|
||||
banner.className = 'action-banner ' + type;
|
||||
if (actionBannerTimer) clearTimeout(actionBannerTimer);
|
||||
if (timeoutMs > 0) {
|
||||
actionBannerTimer = setTimeout(function() {
|
||||
banner.className = 'action-banner hidden';
|
||||
}, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
function setBackendStatus(mode, text) {
|
||||
const el = document.getElementById('backend-status');
|
||||
if (!el) return;
|
||||
el.className = 'backend-status ' + mode;
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
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) : '';
|
||||
statusEl.textContent = 'Running' + phase + ' (' + tune.high_peaks + '/' + tune.cycles + ' peaks)';
|
||||
if (pill) {
|
||||
pill.className = 'autotune-pill running';
|
||||
pill.textContent = 'Autotune: Running' + phase;
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
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;
|
||||
if (pill) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: 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
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: '#ccc', boxWidth: 12 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Update
|
||||
// ---------------------------------------------------------------------------
|
||||
let firstTimestamp = null;
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
const status = await fetchJSON('/api/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';
|
||||
} else {
|
||||
tempEl.className = 'temp-value temp-cold';
|
||||
}
|
||||
|
||||
// Setpoint display
|
||||
document.getElementById('current-setpoint').textContent = status.setpoint.toFixed(0);
|
||||
currentSetpoint = status.setpoint;
|
||||
|
||||
// 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';
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Status bar
|
||||
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-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;
|
||||
}
|
||||
|
||||
const tune = status.autotune || {};
|
||||
setAutotuneUi(tune);
|
||||
|
||||
// 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 pollHistory() {
|
||||
const data = await fetchJSON('/api/history?since=' + lastTimestamp);
|
||||
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) {
|
||||
heartbeatMisses += 1;
|
||||
if (heartbeatMisses >= 2) {
|
||||
setBackendStatus('reconnecting', 'Backend: Reconnecting...');
|
||||
setControlsEnabled(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function adjustSetpoint(delta) {
|
||||
const input = document.getElementById('setpoint-input');
|
||||
const newVal = parseFloat(input.value) + delta;
|
||||
input.value = newVal;
|
||||
applySetpoint();
|
||||
}
|
||||
|
||||
async function applyPID() {
|
||||
const kp = parseFloat(document.getElementById('pid-kp').value);
|
||||
const ki = parseFloat(document.getElementById('pid-ki').value);
|
||||
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 })
|
||||
});
|
||||
if (!result) {
|
||||
setLastAck('PID apply failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('PID applied (' + pMode + ')', 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function resetSafety() {
|
||||
const result = await fetchJSON('/api/safety/reset', { method: 'POST' });
|
||||
if (!result) {
|
||||
setLastAck('safety reset failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('safety reset', true);
|
||||
}
|
||||
|
||||
function renderPresets(presets) {
|
||||
const container = document.getElementById('presets-container');
|
||||
const buttons = Object.entries(presets).map(([name, temp]) =>
|
||||
'<button class="preset-btn" onclick="applyPreset(\'' +
|
||||
name.replace(/'/g, "\\'") + '\')">' + name + ' (' + temp + '°F)</button>'
|
||||
).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 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
initChart();
|
||||
|
||||
// Start polling loops
|
||||
setInterval(pollStatus, pollInterval);
|
||||
setInterval(pollHistory, pollInterval);
|
||||
setInterval(pollHeartbeat, 2000);
|
||||
|
||||
// Initial fetch
|
||||
pollStatus();
|
||||
pollHistory();
|
||||
pollHeartbeat();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1087" height="220" viewBox="0 0 1087.330 220.000">
|
||||
<title>PINAIL</title>
|
||||
<g transform="translate(0.000,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M52.509,125.877v-29.491h70.012c10.707,0,19.062-3.034,25.056-9.111,5.994-6.072,8.991-14.063,8.991-23.977,0-10.069-2.997-18.061-8.991-23.977-5.994-5.911-14.349-8.871-25.056-8.871H30.21v143.859H0V0h122.521c10.069,0,19.062,1.479,26.974,4.436,7.912,2.96,14.584,7.114,20.021,12.468,5.432,5.357,9.59,11.868,12.468,19.541,2.877,7.673,4.315,16.304,4.315,25.895,0,9.434-1.438,18.024-4.315,25.775-2.878,7.755-7.036,14.427-12.468,20.021-5.437,5.597-12.108,9.95-20.021,13.067-7.912,3.116-16.904,4.675-26.974,4.675H52.509Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(251.130,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M0,174.31V0h30.45v174.31H0Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(305.562,0.000) scale(1.231624) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M29.012,48.912v127.556H0V18.223c0-5.594,1.397-10.029,4.196-13.307C6.991,1.642,10.79,0,15.585,0c2.236,0,4.395.479,6.474,1.438,2.075.96,4.233,2.56,6.474,4.796l123,122.041V.72h29.012v159.684c0,5.755-1.401,10.231-4.196,13.427-2.798,3.196-6.436,4.796-10.909,4.796-4.956,0-9.591-2.158-13.906-6.474L29.012,48.912Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(543.924,0.000) scale(1.244992) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M193.491,176.708l-26.134-43.877h-82.479l14.386-24.696h53.468l-38.842-65.217L34.766,176.708H0L100.222,9.831c1.757-3.035,3.836-5.433,6.234-7.193,2.397-1.757,5.274-2.638,8.631-2.638s6.193.881,8.512,2.638c2.315,1.761,4.353,4.158,6.114,7.193l100.462,166.877h-36.685Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(846.490,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M0,174.31V0h30.45v174.31H0Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(900.922,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M0,174.31V0h30.45v143.859h117.245v30.45H0Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,571 @@
|
||||
/* piNail2 — Dark theme dashboard */
|
||||
|
||||
:root {
|
||||
--bg: #1a1a1a;
|
||||
--bg-card: #252525;
|
||||
--bg-input: #2a2a2a;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #cccccc;
|
||||
--accent-orange: #d35400;
|
||||
--accent-orange-hover: #e65c00;
|
||||
--accent-orange-deep: #a84300;
|
||||
--accent-teal: #d35400;
|
||||
--accent-blue: #d35400;
|
||||
--accent-red: #e74c3c;
|
||||
--accent-green: #2ecc71;
|
||||
--border: #333333;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
|
||||
background: radial-gradient(circle at 20% 0%, #222 0%, #1a1a1a 35%, #000 100%);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: #000;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
max-width: 42vw;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.conn-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backend-status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.last-ack {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.last-ack.ok {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.last-ack.err {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.backend-status.online { color: var(--accent-green); }
|
||||
.backend-status.reconnecting { color: var(--accent-orange); }
|
||||
.backend-status.offline { color: var(--accent-red); }
|
||||
|
||||
header h1 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected { background: var(--accent-green); }
|
||||
.status-dot.disconnected { background: var(--accent-red); }
|
||||
|
||||
/* Main */
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Hero: temp + power */
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 30px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.temp-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.temp-unit {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-dim);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.temp-good { color: var(--accent-green); }
|
||||
.temp-warming { color: var(--accent-orange); }
|
||||
.temp-cold { color: var(--accent-blue); }
|
||||
|
||||
.hero-right {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.autotune-pill {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.autotune-pill.idle {
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.autotune-pill.running {
|
||||
color: #111;
|
||||
background: var(--accent-orange);
|
||||
border-color: var(--accent-orange);
|
||||
animation: autotunePulse 1.2s infinite;
|
||||
}
|
||||
|
||||
.autotune-pill.done {
|
||||
color: #111;
|
||||
background: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.autotune-pill.error {
|
||||
color: #fff;
|
||||
background: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
|
||||
.setpoint-display {
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.power-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.power-btn.off {
|
||||
background: transparent;
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.power-btn.on {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
box-shadow: 0 0 20px rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.power-btn:hover { opacity: 0.8; }
|
||||
.power-btn:active { transform: scale(0.95); }
|
||||
|
||||
/* Safety Banner */
|
||||
.safety-banner {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
border: 1px solid var(--accent-red);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--accent-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.safety-banner.hidden { display: none; }
|
||||
|
||||
.safety-banner button {
|
||||
background: var(--accent-red);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-banner {
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-banner.hidden { display: none; }
|
||||
.action-banner.info { border-color: var(--accent-blue); color: var(--accent-blue); }
|
||||
.action-banner.success { border-color: var(--accent-green); color: var(--accent-green); }
|
||||
.action-banner.error { border-color: var(--accent-red); color: var(--accent-red); }
|
||||
|
||||
/* Chart */
|
||||
.chart-section {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
box-shadow: inset 0 0 0 1px rgba(211, 84, 0, 0.12);
|
||||
}
|
||||
|
||||
.chart-section canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.control-group h3 {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Setpoint controls */
|
||||
.setpoint-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.adj-btn {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.adj-btn:hover { background: var(--accent-orange-deep); color: #fff; }
|
||||
.adj-btn:active { transform: scale(0.95); }
|
||||
|
||||
input[type="number"] {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
select {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
|
||||
input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: var(--accent-teal);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.apply-btn:hover { background: var(--accent-orange-hover); opacity: 1; }
|
||||
.apply-btn:active { transform: scale(0.95); }
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Presets */
|
||||
.presets {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
background: var(--bg-input);
|
||||
color: var(--accent-teal);
|
||||
border: 1px solid var(--accent-teal);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: var(--accent-teal);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* PID controls */
|
||||
.pid-controls {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pid-controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.autotune-controls {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.autotune-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.autotune-status.idle {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.autotune-status.running {
|
||||
color: var(--accent-orange);
|
||||
animation: autotunePulse 1.2s infinite;
|
||||
}
|
||||
|
||||
.autotune-status.done {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.autotune-status.error {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes autotunePulse {
|
||||
0% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.pid-controls input {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 16px;
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.relay-on { color: var(--accent-green); }
|
||||
.relay-off { color: var(--text-dim); }
|
||||
.tc-ok { color: var(--accent-green); }
|
||||
.tc-err { color: var(--accent-red); }
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 600px) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 28px;
|
||||
max-width: 46vw;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-right {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.power-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.setpoint-controls {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pid-controls {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>piNail2 Controller</title>
|
||||
<link rel="icon" type="image/png" href="/static/img/pi_favicon.png">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<img class="brand-logo" src="/static/img/pinail_logo.svg" alt="PINAIL logo">
|
||||
<h1>Controller</h1>
|
||||
</div>
|
||||
<div class="conn-wrap">
|
||||
<span id="last-ack" class="last-ack">Last command: none</span>
|
||||
<span id="backend-status" class="backend-status">Backend: Online</span>
|
||||
<div id="connection-status" class="status-dot connected" title="Connected"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Top row: Big temp display + power toggle -->
|
||||
<section class="hero">
|
||||
<div class="temp-display">
|
||||
<span id="current-temp" class="temp-value">---</span>
|
||||
<span class="temp-unit">°F</span>
|
||||
</div>
|
||||
<div class="hero-right">
|
||||
<div class="setpoint-display">
|
||||
Target: <span id="current-setpoint">---</span>°F
|
||||
</div>
|
||||
<div id="autotune-pill" class="autotune-pill idle">Autotune: Idle</div>
|
||||
<button id="power-btn" class="power-btn off" onclick="togglePower()">OFF</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Safety alert banner (hidden by default) -->
|
||||
<section id="safety-banner" class="safety-banner hidden">
|
||||
<span id="safety-message"></span>
|
||||
<button onclick="resetSafety()">Reset</button>
|
||||
</section>
|
||||
|
||||
<section id="action-banner" class="action-banner hidden">
|
||||
<span id="action-message"></span>
|
||||
</section>
|
||||
|
||||
<!-- Chart -->
|
||||
<section class="chart-section">
|
||||
<canvas id="temp-chart"></canvas>
|
||||
</section>
|
||||
|
||||
<!-- Controls -->
|
||||
<section class="controls">
|
||||
<!-- Setpoint -->
|
||||
<div class="control-group">
|
||||
<h3>Setpoint</h3>
|
||||
<div class="setpoint-controls">
|
||||
<button class="adj-btn" onclick="adjustSetpoint(-10)">-10</button>
|
||||
<button class="adj-btn" onclick="adjustSetpoint(-5)">-5</button>
|
||||
<input type="number" id="setpoint-input" value="530" min="0" max="800" step="5">
|
||||
<button class="adj-btn" onclick="adjustSetpoint(+5)">+5</button>
|
||||
<button class="adj-btn" onclick="adjustSetpoint(+10)">+10</button>
|
||||
<button class="apply-btn" onclick="applySetpoint()">Set</button>
|
||||
</div>
|
||||
<div class="presets" id="presets-container">
|
||||
<!-- Filled by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PID Tuning -->
|
||||
<div class="control-group">
|
||||
<h3>PID Tuning</h3>
|
||||
<div class="pid-controls">
|
||||
<label>
|
||||
kP
|
||||
<input type="number" id="pid-kp" step="0.1" value="10">
|
||||
</label>
|
||||
<label>
|
||||
kI
|
||||
<input type="number" id="pid-ki" step="0.1" value="5">
|
||||
</label>
|
||||
<label>
|
||||
kD
|
||||
<input type="number" id="pid-kd" step="0.1" value="1">
|
||||
</label>
|
||||
<label>
|
||||
P Mode
|
||||
<select id="pid-pmode">
|
||||
<option value="error">P-on-Error</option>
|
||||
<option value="measurement">P-on-Measurement</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="apply-btn" onclick="applyPID()">Apply</button>
|
||||
<button class="apply-btn" onclick="resetPID()">Reset I</button>
|
||||
</div>
|
||||
<div class="autotune-controls">
|
||||
<button id="autotune-start-btn" class="apply-btn" onclick="startAutotune()">Start Autotune</button>
|
||||
<button id="autotune-stop-btn" class="adj-btn" onclick="stopAutotune()">Stop Autotune</button>
|
||||
<span id="autotune-status" class="autotune-status idle">Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Status bar -->
|
||||
<section class="status-bar">
|
||||
<div class="status-item">
|
||||
<span class="label">Output</span>
|
||||
<span id="status-output" class="value">0</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">Relay</span>
|
||||
<span id="status-relay" class="value relay-off">OFF</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">Uptime</span>
|
||||
<span id="status-uptime" class="value">--</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">Loops</span>
|
||||
<span id="status-loops" class="value">0</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">TC</span>
|
||||
<span id="status-tc" class="value">--</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="app-footer">
|
||||
<span>{{ app_version }}</span>
|
||||
<span>Copyright © {{ copyright_year }} SethPC Labs</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
piNail2 Thermocouple Driver
|
||||
|
||||
Wraps the MAX6675 thermocouple reader with:
|
||||
- Celsius to Fahrenheit conversion
|
||||
- Spike/outlier filtering (median of recent readings)
|
||||
- Open thermocouple detection
|
||||
- Error handling
|
||||
"""
|
||||
|
||||
import logging
|
||||
import collections
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def c_to_f(c):
|
||||
"""Convert Celsius to Fahrenheit."""
|
||||
return c * 9.0 / 5.0 + 32.0
|
||||
|
||||
|
||||
class Thermocouple:
|
||||
"""
|
||||
MAX6675 thermocouple reader with spike filtering.
|
||||
|
||||
The MAX6675 occasionally returns spurious readings (we've seen 869F spikes
|
||||
in otherwise stable ~680F data). This class maintains a sliding window of
|
||||
recent readings and rejects outliers.
|
||||
"""
|
||||
|
||||
def __init__(self, clk, cs, do, spike_threshold=50.0, window_size=5):
|
||||
"""
|
||||
Args:
|
||||
clk: GPIO pin for SPI clock
|
||||
cs: GPIO pin for chip select
|
||||
do: GPIO pin for data out (MISO)
|
||||
spike_threshold: Maximum allowed jump between filtered readings (in F)
|
||||
window_size: Number of recent readings to keep for median filtering
|
||||
"""
|
||||
self._spike_threshold = spike_threshold
|
||||
self._window_size = window_size
|
||||
self._readings = collections.deque(maxlen=window_size)
|
||||
self._last_good_temp = None
|
||||
self._raw_temp = 0.0
|
||||
self._filtered_temp = 0.0
|
||||
self._is_connected = False
|
||||
self._spike_count = 0
|
||||
self._total_reads = 0
|
||||
|
||||
try:
|
||||
import MAX6675.MAX6675 as MAX6675
|
||||
self._sensor = MAX6675.MAX6675(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:
|
||||
log.error("Failed to initialize MAX6675: %s", e)
|
||||
self._sensor = None
|
||||
self._is_connected = False
|
||||
|
||||
def read(self):
|
||||
"""
|
||||
Read temperature from the thermocouple.
|
||||
|
||||
Returns:
|
||||
float: Filtered temperature in Fahrenheit, or None if sensor is disconnected.
|
||||
"""
|
||||
if self._sensor is None:
|
||||
return None
|
||||
|
||||
self._total_reads += 1
|
||||
|
||||
try:
|
||||
raw_c = self._sensor.readTempC()
|
||||
except Exception as e:
|
||||
log.error("Thermocouple read error: %s", e)
|
||||
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:
|
||||
log.warning("Thermocouple appears disconnected (raw_c=%s)", raw_c)
|
||||
self._is_connected = False
|
||||
return self._last_good_temp
|
||||
|
||||
self._is_connected = True
|
||||
raw_f = c_to_f(raw_c)
|
||||
self._raw_temp = raw_f
|
||||
|
||||
# Spike detection: if we have a previous good reading, check for unreasonable jumps
|
||||
if self._last_good_temp is not None:
|
||||
delta = abs(raw_f - self._last_good_temp)
|
||||
if delta > self._spike_threshold:
|
||||
self._spike_count += 1
|
||||
log.warning(
|
||||
"Spike detected: %.1fF -> %.1fF (delta=%.1fF, count=%d). Using last good value.",
|
||||
self._last_good_temp, raw_f, delta, self._spike_count
|
||||
)
|
||||
# Still add to window but return last good — if multiple consecutive
|
||||
# readings are in the "spike" range, they'll become the new normal
|
||||
# via the median filter
|
||||
self._readings.append(raw_f)
|
||||
# If we've had many consecutive "spikes", the temperature probably
|
||||
# genuinely changed fast (e.g., touching the nail with concentrate)
|
||||
if self._spike_count >= self._window_size:
|
||||
log.info("Spike count exceeded window size, accepting new baseline %.1fF", raw_f)
|
||||
self._spike_count = 0
|
||||
self._last_good_temp = raw_f
|
||||
self._filtered_temp = raw_f
|
||||
return raw_f
|
||||
self._filtered_temp = self._last_good_temp
|
||||
return self._last_good_temp
|
||||
|
||||
# Normal reading — reset spike counter, add to window
|
||||
self._spike_count = 0
|
||||
self._readings.append(raw_f)
|
||||
|
||||
# Median filter
|
||||
if len(self._readings) >= 3:
|
||||
sorted_readings = sorted(self._readings)
|
||||
median = sorted_readings[len(sorted_readings) // 2]
|
||||
self._filtered_temp = median
|
||||
self._last_good_temp = median
|
||||
else:
|
||||
self._filtered_temp = raw_f
|
||||
self._last_good_temp = raw_f
|
||||
|
||||
return self._filtered_temp
|
||||
|
||||
@property
|
||||
def raw_temp(self):
|
||||
"""Last raw (unfiltered) temperature reading in Fahrenheit."""
|
||||
return self._raw_temp
|
||||
|
||||
@property
|
||||
def filtered_temp(self):
|
||||
"""Last filtered temperature reading in Fahrenheit."""
|
||||
return self._filtered_temp
|
||||
|
||||
@property
|
||||
def is_connected(self):
|
||||
"""Whether the thermocouple appears to be connected."""
|
||||
return self._is_connected
|
||||
|
||||
@property
|
||||
def spike_count(self):
|
||||
"""Number of spike events detected since last normal reading."""
|
||||
return self._spike_count
|
||||
|
||||
@property
|
||||
def stats(self):
|
||||
"""Return diagnostic stats."""
|
||||
return {
|
||||
"raw_temp": round(self._raw_temp, 2),
|
||||
"filtered_temp": round(self._filtered_temp, 2),
|
||||
"is_connected": self._is_connected,
|
||||
"total_reads": self._total_reads,
|
||||
"recent_readings": [round(r, 2) for r in self._readings],
|
||||
}
|
||||
Reference in New Issue
Block a user