Files
piNail/piNail2/tty_status_display.py
T
Seth e7e827c41a Improve spacing between nail panels
- Expanded panel width from 20 to 29 characters per nail
- Better formatted text with more descriptive labels
- Full 'Nail 1' and 'Nail 2' names instead of abbreviations
- More readable temperature and error displays
- Improved overall visual separation and readability
- Deployed and verified on Pi
2026-03-12 04:19:28 +00:00

353 lines
11 KiB
Python

#!/usr/bin/env python3
"""
piNail2 — TTY Status Display Panel
Displays a fancy real-time status panel on the HDMI display showing:
- Dual nail temperature status and control mode
- PID parameters and error/output values
- Flight mode and current phase
- Safety status
- Performance metrics
Compatible with Python 3.5+
"""
import sys
import os
import time
import json
import requests
import signal
import threading
from datetime import datetime
# Python 3.5 compatibility - no f-strings
try:
from http.client import HTTPConnection
HTTPConnection.debuglevel = 0
except ImportError:
from httplib import HTTPConnection
HTTPConnection.debuglevel = 0
# Configuration
API_BASE = "http://localhost:5000"
UPDATE_INTERVAL = 0.5 # seconds between fetches
DISPLAY_INTERVAL = 2.0 # seconds between screen redraws
TTY_PATH = "/dev/tty1"
# ANSI color codes
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
UNDERLINE = "\033[4m"
# Foreground colors
BLACK = "\033[30m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
WHITE = "\033[37m"
# Bright colors
BRIGHT_RED = "\033[91m"
BRIGHT_GREEN = "\033[92m"
BRIGHT_YELLOW = "\033[93m"
BRIGHT_BLUE = "\033[94m"
BRIGHT_MAGENTA = "\033[95m"
BRIGHT_CYAN = "\033[96m"
# Background colors
BG_RED = "\033[41m"
BG_GREEN = "\033[42m"
BG_YELLOW = "\033[43m"
BG_BLUE = "\033[44m"
BG_MAGENTA = "\033[45m"
BG_CYAN = "\033[46m"
class StatusDisplay:
def __init__(self):
self.running = True
self.last_update = 0
self.last_draw = 0
self.last_frame = None
self.data = {
"nail1": {},
"nail2": {},
}
self.lock = threading.Lock()
def fetch_status(self):
"""Fetch status from API."""
try:
resp = requests.get("{}/api/status/all".format(API_BASE), timeout=2)
if resp.status_code == 200:
with self.lock:
payload = resp.json()
self.data = payload.get("nails", {})
self.last_update = time.time()
return True
except Exception as e:
pass
return False
def fetch_worker(self):
"""Background thread that fetches status periodically."""
while self.running:
self.fetch_status()
time.sleep(UPDATE_INTERVAL)
def format_temp(self, temp_f):
"""Format temperature with color coding."""
if temp_f is None:
return "{}ERR{}".format(Colors.RED, Colors.RESET)
temp_f = float(temp_f)
if temp_f < 0:
color = Colors.RED
elif temp_f < 100:
color = Colors.BLUE
elif temp_f < 400:
color = Colors.YELLOW
else:
color = Colors.BRIGHT_RED
return "{}{:6.1f}F{}".format(color, temp_f, Colors.RESET)
def format_status_icon(self, enabled, has_error):
"""Return status icon."""
if has_error:
return "{}{}".format(Colors.RED, Colors.RESET)
elif enabled:
return "{}{}".format(Colors.GREEN, Colors.RESET)
else:
return "{}{}".format(Colors.DIM, Colors.RESET)
def format_mode_badge(self, mode):
"""Format flight mode as a fancy badge."""
modes = {
"grounded": (Colors.CYAN, "GROUNDED"),
"takeoff": (Colors.BRIGHT_YELLOW, "TAKEOFF "),
"cruise": (Colors.BRIGHT_GREEN, "CRUISE "),
"descent": (Colors.BRIGHT_MAGENTA, "DESCENT "),
}
color, label = modes.get(mode, (Colors.WHITE, "????? "))
return "{}[{}]{}".format(color, label, Colors.RESET)
def format_phase_badge(self, phase):
"""Format phase as a badge."""
phases = {
"heating": (Colors.BRIGHT_RED, "HEATING"),
"cooling": (Colors.BRIGHT_BLUE, "COOLING"),
"idle": (Colors.DIM, " IDLE "),
}
color, label = phases.get(phase, (Colors.WHITE, " ? "))
return "{}{}{}".format(color, label, Colors.RESET)
def draw_nail_panel_compact(self, nail_id, status):
"""Draw compact status panel for a single nail (for side-by-side layout)."""
if not status:
return []
lines = []
# Header
nail_name = "Nail 1" if nail_id == "nail1" else "Nail 2"
enabled = status.get("enabled", False)
has_error = status.get("error", False)
icon = self.format_status_icon(enabled, has_error)
status_text = "ONLINE" if enabled else "OFFLINE"
lines.append("{}{} {} {}{}".format(
Colors.BOLD, nail_name, icon,
status_text,
Colors.RESET
))
# Temperature display
current = float(status.get("current_temp", 0))
setpoint = float(status.get("setpoint", 0))
temp_str = "{:6.1f}F".format(current) if current else "ERROR"
setpt_str = "{:6.1f}F".format(setpoint)
lines.append("Temp: {} / {}".format(temp_str, setpt_str))
# Error display
error = float(status.get("error", 0))
error_color = Colors.RED if abs(error) > 20 else Colors.YELLOW if abs(error) > 5 else Colors.GREEN
lines.append("{}Error: {:+7.1f}F{}".format(error_color, error, Colors.RESET))
# PID Output bar
output = float(status.get("output", 0))
output_pct = min(100.0, max(0.0, output * 100))
bar_filled = int(output_pct / 10)
bar = "{}[{}{}]{}".format(
Colors.BRIGHT_GREEN,
"=" * bar_filled,
" " * (10 - bar_filled),
Colors.RESET
)
lines.append("Output: {} {:5.1f}%".format(bar, output_pct))
# Flight mode and phase
mode = status.get("flight_mode", "grounded")
phase = status.get("phase", "idle")
lines.append("{}Mode: {:<10} Phase: {}{}".format(
Colors.CYAN,
mode[:10],
phase[:8],
Colors.RESET
))
# Safety status
safety = status.get("safety", {})
temp_ok = safety.get("temp_ok", True)
tc_ok = safety.get("tc_ok", True)
watchdog_ok = safety.get("watchdog_ok", True)
safety_status = "{}Safety: OK{}".format(Colors.GREEN, Colors.RESET) if (temp_ok and tc_ok and watchdog_ok) else "{}Safety: WARN{}".format(Colors.RED, Colors.RESET)
lines.append(safety_status)
return lines
def draw_frame(self):
"""Draw the complete status display frame."""
lines = []
# ASCII Art Title with timestamp side-by-side
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines.append("")
lines.append("{}{}{:<22} {}{}{}".format(
Colors.BRIGHT_CYAN + Colors.BOLD,
" ___ __ _ _ _ _",
"",
Colors.DIM,
timestamp,
Colors.RESET
))
lines.append("{}{}{:<22} {}{}{}".format(
Colors.BRIGHT_CYAN + Colors.BOLD,
" / _ \\ / / | \\| | | | |",
"",
Colors.DIM,
"piNail2 e-Nail",
Colors.RESET
))
lines.append("{}{}{:<22} {}{}{}".format(
Colors.BRIGHT_CYAN + Colors.BOLD,
"| |_| |/ /__ \\ / |_|_|",
"",
Colors.DIM,
"Temperature Controller",
Colors.RESET
))
lines.append("{}{}{:<22}{}".format(
Colors.BRIGHT_CYAN + Colors.BOLD,
" \\___/______| \\/ ",
"",
Colors.RESET
))
lines.append("")
# Get data safely
with self.lock:
nail1_data = self.data.get("nail1", {})
nail2_data = self.data.get("nail2", {})
# Get compact panel lines for both nails
nail1_lines = self.draw_nail_panel_compact("nail1", nail1_data)
nail2_lines = self.draw_nail_panel_compact("nail2", nail2_data)
# Pad lines to same length
max_lines = max(len(nail1_lines), len(nail2_lines))
while len(nail1_lines) < max_lines:
nail1_lines.append("")
while len(nail2_lines) < max_lines:
nail2_lines.append("")
# Draw side-by-side panels with better spacing
lines.append("{}┌─ NAIL 1 ─────────────────────┬─ NAIL 2 ─────────────────────┐{}".format(
Colors.CYAN, Colors.RESET))
for n1_line, n2_line in zip(nail1_lines, nail2_lines):
# Pad lines to 29 chars with good spacing
left = "{:<27} ".format(n1_line[:27])
right = "{:<27}".format(n2_line[:27])
lines.append("{}{}{}".format(left, right, Colors.RESET))
# Footer
lines.append("{}└───────────────────────────────┴───────────────────────────────┘{}".format(
Colors.CYAN, Colors.RESET))
return "\n".join(lines)
def display_loop(self):
"""Main display loop."""
# Start fetch worker thread
fetch_thread = threading.Thread(target=self.fetch_worker)
fetch_thread.daemon = True
fetch_thread.start()
# Initial draw
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
self.last_draw = time.time()
try:
while self.running:
now = time.time()
# Only redraw if enough time has passed (reduces flicker)
if now - self.last_draw >= DISPLAY_INTERVAL:
# Clear screen (ANSI escape)
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
# Draw frame
frame = self.draw_frame()
sys.stdout.write(frame)
sys.stdout.write("\n")
sys.stdout.flush()
self.last_draw = now
time.sleep(UPDATE_INTERVAL)
except KeyboardInterrupt:
pass
finally:
self.running = False
def run(self):
"""Run the status display."""
# Redirect stdout to TTY if available
if os.path.exists(TTY_PATH):
try:
tty_fd = os.open(TTY_PATH, os.O_WRONLY)
os.dup2(tty_fd, 1)
os.close(tty_fd)
except Exception:
pass
# Handle signals
def sigint_handler(signum, frame):
self.running = False
signal.signal(signal.SIGINT, sigint_handler)
# Run display loop
self.display_loop()
def main():
"""Main entry point."""
display = StatusDisplay()
display.run()
if __name__ == "__main__":
main()