mirror of
https://github.com/migatu/vtt_work.git
synced 2026-07-14 21:38:39 +00:00
1250 lines
63 KiB
Python
1250 lines
63 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Control Board Monitor — v1.0
|
|
|
|
Changes requested:
|
|
- POST behavior matches the sample app by default: POST "/" with form body "Variable" + "value".
|
|
Switch via POST_MODE = "sample" | "legacy" (default="sample").
|
|
- Robust variable selection: case-insensitive canonical keys, no duplicates, stable Treeview IDs.
|
|
- Threshold logic exactly as specified, with color gradients:
|
|
DEAD_LOW (<dead_low) → yellow/orange; LOW [dead_low, low) → yellow (stronger near dead_low);
|
|
OPERATING [low, high) → green; HIGH [high, extreme_high) → red (stronger near extreme_high);
|
|
EXTREME_HIGH (>= extreme_high) → deep red. Default alarms ON for DEAD_LOW & EXTREME_HIGH.
|
|
|
|
Spec alignment:
|
|
- Discover POST actions from WEBSERVER_LIST_VARIABLES_JSON ("POST:").
|
|
- Discover variables & poll with WEBSERVER_BATCH_GET (bulk).
|
|
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
import queue
|
|
import re
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
try:
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
except Exception:
|
|
raise SystemExit("Tkinter is required to run this app.")
|
|
|
|
# =====================
|
|
# Global configuration
|
|
# =====================
|
|
SERVER_HOST: str = "localhost"
|
|
SERVER_PORT: int = 8785
|
|
REFRESH_INTERVAL_S: int = 60
|
|
REQUEST_TIMEOUT_S: float = 5.0
|
|
USER_AGENT: str = "ControlBoardMonitor/1.0 (+tkinter)"
|
|
BACKOFF_BASE_S: float = 1.0
|
|
BACKOFF_MAX_S: float = 15.0
|
|
MAX_RETRIES: int = 2
|
|
|
|
# ---- POST mode ----
|
|
# "sample" → POST to "/" with fields {"Variable": <name>, "value": <val>}
|
|
# "legacy" → POST to "/" with fields {"variable": <name>, "value": <val>}
|
|
POST_MODE: str = "sample" # change to "legacy" if needed
|
|
|
|
POST_KEY_VARIABLE = "Variable" if POST_MODE == "sample" else "variable"
|
|
POST_KEY_VALUE = "value" # both modes use 'value' as in the sample
|
|
|
|
# Fallback defaults (extracted from your HTML)
|
|
DEFAULT_VARS: List[str] = ['ALARMS_ACTIVE', 'AMBIENT_TEMPERATURE', 'CHEM_BORON_DOSAGE_ACTUAL', 'CHEM_BORON_DOSAGE_ORDERED', 'CHEM_BORON_FILTER_ACTUAL', 'CHEM_BORON_FILTER_ORDERED', 'CHEM_BORON_PPM', 'CHEM_TRUCK_CONNECTED', 'CHEM_TRUCK_IN_ZONE', 'CHEMICAL_CLEANING_PUMP_DRY_STATUS', 'CHEMICAL_CLEANING_PUMP_OVERLOAD_STATUS', 'CHEMICAL_CLEANING_PUMP_STATUS', 'CHEMICAL_DOSING_PUMP_DRY_STATUS', 'CHEMICAL_DOSING_PUMP_OVERLOAD_STATUS', 'CHEMICAL_DOSING_PUMP_STATUS', 'CHEMICAL_FILTER_PUMP_DRY_STATUS', 'CHEMICAL_FILTER_PUMP_OVERLOAD_STATUS', 'CHEMICAL_FILTER_PUMP_STATUS', 'CONDENSER_CIRCULATION_PUMP_ACTIVE', 'CONDENSER_CIRCULATION_PUMP_ORDERED_SPEED', 'CONDENSER_CIRCULATION_PUMP_OVERLOAD_STATUS', 'CONDENSER_CIRCULATION_PUMP_SPEED', 'CONDENSER_CIRCULATION_PUMP_SWITCH', 'CONDENSER_CONDENSATE_FLOW_RATE', 'CONDENSER_COOLANT_EVAPORATED', 'CONDENSER_EXTRACTION_FLOW_RATE', 'CONDENSER_PRESSURE', 'CONDENSER_TEMPERATURE', 'CONDENSER_VACUUM', 'CONDENSER_VACUUM_PUMP_ACTIVE', 'CONDENSER_VACUUM_PUMP_MODE', 'CONDENSER_VACUUM_PUMP_POWER', 'CONDENSER_VACUUM_RELIEF_VALVE_OPENING', 'CONDENSER_VAPOR_VOLUME', 'CONDENSER_VOLUME', 'COOLANT_CORE_CIRCULATION_PUMP_0_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_0_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_0_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_0_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_1_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_1_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_CAPACITY', 'COOLANT_CORE_CIRCULATION_PUMP_2_DRY_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_OVERLOAD_STATUS', 'COOLANT_CORE_CIRCULATION_PUMP_2_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_STATUS', 'COOLANT_CORE_FLOW_IN', 'COOLANT_CORE_FLOW_ORDERED_SPEED', 'COOLANT_CORE_FLOW_OUT', 'COOLANT_CORE_FLOW_REACHED_SPEED', 'COOLANT_CORE_FLOW_SPEED', 'COOLANT_CORE_MAX_PRESSURE', 'COOLANT_CORE_PRESSURE', 'COOLANT_CORE_PRIMARY_LOOP_LEVEL', 'COOLANT_CORE_QUANTITY_CIRCULATION_PUMPS_PRESENT', 'COOLANT_CORE_QUANTITY_FREIGHT_PUMPS_PRESENT', 'COOLANT_CORE_QUANTITY_IN_VESSEL', 'COOLANT_CORE_STATE', 'COOLANT_CORE_VESSEL_TEMPERATURE', 'COOLANT_SEC_0_LIQUID_VOLUME', 'COOLANT_SEC_0_PRESSURE', 'COOLANT_SEC_0_TEMPERATURE', 'COOLANT_SEC_0_VOLUME', 'COOLANT_SEC_1_LIQUID_VOLUME', 'COOLANT_SEC_1_PRESSURE', 'COOLANT_SEC_1_TEMPERATURE', 'COOLANT_SEC_1_VOLUME', 'COOLANT_SEC_2_LIQUID_VOLUME', 'COOLANT_SEC_2_PRESSURE', 'COOLANT_SEC_2_TEMPERATURE', 'COOLANT_SEC_2_VOLUME', 'COOLANT_SEC_CIRCULATION_PUMP_0_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_0_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_0_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_1_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_1_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_CAPACITY', 'COOLANT_SEC_CIRCULATION_PUMP_2_DRY_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_OVERLOAD_STATUS', 'COOLANT_SEC_CIRCULATION_PUMP_2_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_STATUS', 'CORE_BAY_1_HATCH_OPEN', 'CORE_BAY_1_STATE', 'CORE_BAY_2_HATCH_OPEN', 'CORE_BAY_2_STATE', 'CORE_BAY_3_HATCH_OPEN', 'CORE_BAY_3_STATE', 'CORE_BAY_4_HATCH_OPEN', 'CORE_BAY_4_STATE', 'CORE_BAY_5_HATCH_OPEN', 'CORE_BAY_5_STATE', 'CORE_BAY_6_HATCH_OPEN', 'CORE_BAY_6_STATE', 'CORE_BAY_7_HATCH_OPEN', 'CORE_BAY_7_STATE', 'CORE_BAY_8_HATCH_OPEN', 'CORE_BAY_8_STATE', 'CORE_BAY_9_HATCH_OPEN', 'CORE_BAY_9_STATE', 'CORE_CRITICAL_MASS_REACHED', 'CORE_CRITICAL_MASS_REACHED_COUNTER', 'CORE_EXTERNAL_COOLANT_RESERVOIR_VOLUME', 'CORE_FACTOR', 'CORE_FACTOR_CHANGE', 'CORE_FUEL_1_FISSIONABLE', 'CORE_FUEL_1_POWER_FACTOR', 'CORE_FUEL_1_TEMPERATURE', 'CORE_FUEL_2_FISSIONABLE', 'CORE_FUEL_2_POWER_FACTOR', 'CORE_FUEL_2_TEMPERATURE', 'CORE_FUEL_3_FISSIONABLE', 'CORE_FUEL_3_POWER_FACTOR', 'CORE_FUEL_3_TEMPERATURE', 'CORE_FUEL_4_FISSIONABLE', 'CORE_FUEL_4_POWER_FACTOR', 'CORE_FUEL_4_TEMPERATURE', 'CORE_FUEL_5_FISSIONABLE', 'CORE_FUEL_5_POWER_FACTOR', 'CORE_FUEL_5_TEMPERATURE', 'CORE_FUEL_6_FISSIONABLE', 'CORE_FUEL_6_POWER_FACTOR', 'CORE_FUEL_6_TEMPERATURE', 'CORE_FUEL_7_FISSIONABLE', 'CORE_FUEL_7_POWER_FACTOR', 'CORE_FUEL_7_TEMPERATURE', 'CORE_FUEL_8_FISSIONABLE', 'CORE_FUEL_8_POWER_FACTOR', 'CORE_FUEL_8_TEMPERATURE', 'CORE_FUEL_9_FISSIONABLE', 'CORE_FUEL_9_POWER_FACTOR', 'CORE_FUEL_9_TEMPERATURE', 'CORE_FUEL_AVG_FISSIONABLE', 'CORE_FUEL_AVG_POWER_FACTOR', 'CORE_FUEL_AVG_TEMPERATURE', 'CORE_HIGH_STEAM_PRESENT', 'CORE_IMMINENT_FUSION', 'CORE_INTEGRITY', 'CORE_IODINE_CUMULATIVE', 'CORE_IODINE_GENERATION', 'CORE_OPERATION_MODE', 'CORE_POOL_COOLANT_TANK_VOLUME', 'CORE_POOL_PUMP', 'CORE_PRESSURE', 'CORE_PRESSURE_MAX', 'CORE_PRESSURE_OPERATIVE', 'CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME', 'CORE_READY_FOR_START', 'CORE_STATE', 'CORE_STATE_CRITICALITY', 'CORE_STEAM_PRESENT', 'CORE_TEMP', 'CORE_TEMP_MAX', 'CORE_TEMP_MIN', 'CORE_TEMP_OPERATIVE', 'CORE_TEMP_RESIDUAL', 'CORE_WEAR', 'CORE_XENON_CUMULATIVE', 'CORE_XENON_GENERATION', 'EMERGENCY_BATTERIES_MODE', 'EMERGENCY_BATTERIES_POWER_OUTPUT_KW', 'EMERGENCY_GENERATOR_1_FUEL', 'EMERGENCY_GENERATOR_1_MAINTENANCE_NEEDED', 'EMERGENCY_GENERATOR_1_MODE', 'EMERGENCY_GENERATOR_1_PRESSURIZER', 'EMERGENCY_GENERATOR_1_STATUS', 'EMERGENCY_GENERATOR_2_FUEL', 'EMERGENCY_GENERATOR_2_MAINTENANCE_NEEDED', 'EMERGENCY_GENERATOR_2_MODE', 'EMERGENCY_GENERATOR_2_PRESSURIZER', 'EMERGENCY_GENERATOR_2_STATUS', 'EMERGENCY_GENERATOR_POWER_OUTPUT_KW', 'FREIGHT_PUMP_CONDENSER_ACTIVE', 'FREIGHT_PUMP_CONDENSER_SWITCH', 'FREIGHT_PUMP_EXTERNAL_ACTIVE', 'FREIGHT_PUMP_EXTERNAL_SWITCH', 'FREIGHT_PUMP_FEEDWATER_ACTIVE', 'FREIGHT_PUMP_FEEDWATER_SWITCH', 'FREIGHT_PUMP_INTERNAL_ACTIVE', 'FREIGHT_PUMP_INTERNAL_SWITCH', 'FUN_IS_ENABLED', 'GAME_DIFFICULTY', 'GAME_SIM_SPEED', 'GAME_VERSION', 'GENERATOR_0_A', 'GENERATOR_0_BREAKER', 'GENERATOR_0_HERTZ', 'GENERATOR_0_KW', 'GENERATOR_0_V', 'GENERATOR_1_A', 'GENERATOR_1_BREAKER', 'GENERATOR_1_HERTZ', 'GENERATOR_1_KW', 'GENERATOR_1_V', 'GENERATOR_2_A', 'GENERATOR_2_BREAKER', 'GENERATOR_2_HERTZ', 'GENERATOR_2_KW', 'GENERATOR_2_V', 'INSTALLED_LOOPS_JSON', 'INVENTORY_HTML', 'MAINTENANCE_REPORT_HTML', 'MSCV_0_OPENING_ACTUAL', 'MSCV_1_OPENING_ACTUAL', 'MSCV_2_OPENING_ACTUAL', 'POWER_DEMAND_MW', 'POWER_FROM_EXTERNAL_KW', 'POWER_FROM_TURBINE_KW', 'POWER_MAX_THEORETICAL_FINAL_PLANT_OUTPUT_MW', 'POWER_MAX_THEORETICAL_PLANT_OUTPUT_MW', 'RES_ABSORPTION_CAPACITY_MW', 'RES_DIVERT_SURPLUS_FROM_MW', 'RES_EFFECTIVELY_DERIVED_ENERGY_MW', 'RESISTOR_BANK_01_SWITCH', 'RESISTOR_BANK_02_SWITCH', 'RESISTOR_BANK_03_SWITCH', 'RESISTOR_BANK_04_SWITCH', 'RESISTOR_BANKS_JSON', 'RESISTOR_BANKS_MAIN_SWITCH', 'ROD_BANK_POS_0_ACTUAL', 'ROD_BANK_POS_0_ORDERED', 'ROD_BANK_POS_1_ACTUAL', 'ROD_BANK_POS_1_ORDERED', 'ROD_BANK_POS_2_ACTUAL', 'ROD_BANK_POS_2_ORDERED', 'ROD_BANK_POS_3_ACTUAL', 'ROD_BANK_POS_3_ORDERED', 'ROD_BANK_POS_4_ACTUAL', 'ROD_BANK_POS_4_ORDERED', 'ROD_BANK_POS_5_ACTUAL', 'ROD_BANK_POS_5_ORDERED', 'ROD_BANK_POS_6_ACTUAL', 'ROD_BANK_POS_6_ORDERED', 'ROD_BANK_POS_7_ACTUAL', 'ROD_BANK_POS_7_ORDERED', 'ROD_BANK_POS_8_ACTUAL', 'ROD_BANK_POS_8_ORDERED', 'RODS_ALIGNED', 'RODS_DEFORMED', 'RODS_MAX_TEMPERATURE', 'RODS_MOVEMENT_SPEED', 'RODS_MOVEMENT_SPEED_DECREASED_HIGH_TEMPERATURE', 'RODS_POS_ACTUAL', 'RODS_POS_ORDERED', 'RODS_POS_REACHED', 'RODS_QUANTITY', 'RODS_STATUS', 'RODS_TEMPERATURE', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ACTUAL', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ORDERED', 'STEAM_EJECTOR_MOTIVE', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ACTUAL', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE_ORDERED', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ACTUAL', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE_ORDERED', 'STEAM_GEN_0_BOILING_POINT', 'STEAM_GEN_0_EVAPORATED', 'STEAM_GEN_0_INLET', 'STEAM_GEN_0_OUTLET', 'STEAM_GEN_0_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_0_STATUS', 'STEAM_GEN_0_VENT_SWITCH', 'STEAM_GEN_1_BOILING_POINT', 'STEAM_GEN_1_EVAPORATED', 'STEAM_GEN_1_INLET', 'STEAM_GEN_1_OUTLET', 'STEAM_GEN_1_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_1_STATUS', 'STEAM_GEN_1_VENT_SWITCH', 'STEAM_GEN_2_BOILING_POINT', 'STEAM_GEN_2_EVAPORATED', 'STEAM_GEN_2_INLET', 'STEAM_GEN_2_OUTLET', 'STEAM_GEN_2_RETURN_FLOW_PLUS_CONDENSED', 'STEAM_GEN_2_STATUS', 'STEAM_GEN_2_VENT_SWITCH', 'STEAM_TURBINE_0_BYPASS_ACTUAL', 'STEAM_TURBINE_0_INSTALLED', 'STEAM_TURBINE_0_PRESSURE', 'STEAM_TURBINE_0_RPM', 'STEAM_TURBINE_0_TEMPERATURE', 'STEAM_TURBINE_0_TORQUE', 'STEAM_TURBINE_1_BYPASS_ACTUAL', 'STEAM_TURBINE_1_INSTALLED', 'STEAM_TURBINE_1_PRESSURE', 'STEAM_TURBINE_1_RPM', 'STEAM_TURBINE_1_TEMPERATURE', 'STEAM_TURBINE_1_TORQUE', 'STEAM_TURBINE_2_BYPASS_ACTUAL', 'STEAM_TURBINE_2_INSTALLED', 'STEAM_TURBINE_2_PRESSURE', 'STEAM_TURBINE_2_RPM', 'STEAM_TURBINE_2_TEMPERATURE', 'STEAM_TURBINE_2_TORQUE', 'TIME', 'TIME_DAY', 'TIME_STAMP', 'VACUUM_RETENTION_TANK_PRESSURE', 'VACUUM_RETENTION_TANK_VOLUME', 'VALVE_M01_OPEN', 'VALVE_M02_OPEN', 'VALVE_M03_OPEN', 'VALVE_PANEL_JSON', 'WEATHER_FORECAST_JSON', 'WEBSERVER_BATCH_GET', 'WEBSERVER_LIST_VARIABLES', 'WEBSERVER_LIST_VARIABLES_JSON', 'WEBSERVER_VIEW_VARIABLES']
|
|
DEFAULT_FUNCTIONS: List[str] = ['CHEM_BORON_DOSAGE_ORDERED_RATE', 'CHEM_BORON_FILTER_ORDERED_SPEED', 'CONDENSER_CIRCULATION_PUMP_ORDERED_SPEED', 'CONDENSER_CIRCULATION_PUMP_SWITCH', 'CONDENSER_VACUUM_PUMP_MODE', 'CONDENSER_VACUUM_PUMP_START_STOP', 'COOLANT_CORE_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_CORE_CIRCULATION_PUMP_2_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_0_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_1_ORDERED_SPEED', 'COOLANT_SEC_CIRCULATION_PUMP_2_ORDERED_SPEED', 'CORE_BAY_1_FUEL_LOADING', 'CORE_BAY_1_HATCH', 'CORE_BAY_2_FUEL_LOADING', 'CORE_BAY_2_HATCH', 'CORE_BAY_3_FUEL_LOADING', 'CORE_BAY_3_HATCH', 'CORE_BAY_4_FUEL_LOADING', 'CORE_BAY_4_HATCH', 'CORE_BAY_5_FUEL_LOADING', 'CORE_BAY_5_HATCH', 'CORE_BAY_6_FUEL_LOADING', 'CORE_BAY_6_HATCH', 'CORE_BAY_7_FUEL_LOADING', 'CORE_BAY_7_HATCH', 'CORE_BAY_8_FUEL_LOADING', 'CORE_BAY_8_HATCH', 'CORE_BAY_9_FUEL_LOADING', 'CORE_BAY_9_HATCH', 'CORE_EMERGENCY_STOP', 'CORE_END_EMERGENCY_STOP', 'CORE_OPERATION_MODE', 'CORE_POOL_PUMP', 'CORE_SCRAM_BUTTON', 'EMERGENCY_BATTERIES_MODE', 'EMERGENCY_GENERATOR_1_MODE', 'EMERGENCY_GENERATOR_1_START_STOP', 'EMERGENCY_GENERATOR_2_MODE', 'EMERGENCY_GENERATOR_2_START_STOP', 'FREIGHT_PUMP_CONDENSER_SWITCH', 'FREIGHT_PUMP_EXTERNAL_SWITCH', 'FREIGHT_PUMP_FEEDWATER_SWITCH', 'FREIGHT_PUMP_INTERNAL_SWITCH', 'FUN_AO_SABOTAGE_ONCE', 'FUN_AO_SABOTAGE_TIME', 'FUN_BANK_ROBBERY', 'FUN_BREAKER_TRIP', 'FUN_DECREASE_INTEGRITY', 'FUN_FIRE_DRILL', 'FUN_IODINE_SPILL', 'FUN_OIL_SPILL', 'FUN_PUMP_JAM', 'FUN_REQUEST_ENABLE', 'FUN_SHOW_MESSAGE', 'FUN_TOGGLE_RANDOM_SWITCH', 'FUN_TRIGGER_AUDIT', 'FUN_WEATHER_CONTROL', 'FUN_XENON_SPILL', 'MSCV_0_OPENING_ORDERED', 'MSCV_1_OPENING_ORDERED', 'MSCV_2_OPENING_ORDERED', 'RESET_AO', 'RESISTOR_BANK_01_SWITCH', 'RESISTOR_BANK_02_SWITCH', 'RESISTOR_BANK_03_SWITCH', 'RESISTOR_BANK_04_SWITCH', 'RESISTOR_BANKS_MAIN_SWITCH', 'ROD_BANK_POS_0_ORDERED', 'ROD_BANK_POS_1_ORDERED', 'ROD_BANK_POS_2_ORDERED', 'ROD_BANK_POS_3_ORDERED', 'ROD_BANK_POS_4_ORDERED', 'ROD_BANK_POS_5_ORDERED', 'ROD_BANK_POS_6_ORDERED', 'ROD_BANK_POS_7_ORDERED', 'ROD_BANK_POS_8_ORDERED', 'RODS_ALL_POS_ORDERED', 'STEAM_EJECTOR_CONDENSER_RETURN_VALVE', 'STEAM_EJECTOR_OPERATIONAL_MOTIVE_VALVE', 'STEAM_EJECTOR_STARTUP_MOTIVE_VALVE', 'STEAM_GEN_0_VENT_SWITCH', 'STEAM_GEN_1_VENT_SWITCH', 'STEAM_GEN_2_VENT_SWITCH', 'STEAM_TURBINE_0_BYPASS_ORDERED', 'STEAM_TURBINE_1_BYPASS_ORDERED', 'STEAM_TURBINE_2_BYPASS_ORDERED', 'STEAM_TURBINE_TRIP', 'VALVE_CLOSE', 'VALVE_OFF', 'VALVE_OPEN']
|
|
|
|
# One global lock to serialize every HTTP call
|
|
_HTTP_LOCK = threading.Lock()
|
|
|
|
# =====================
|
|
# Helpers
|
|
# =====================
|
|
def build_base_url(host: str, port: int) -> str:
|
|
# Always end with "/" so POSTs go to root path
|
|
return f"http://{host}:{port}/"
|
|
|
|
def _request(req: urllib.request.Request) -> Tuple[int, str, Dict[str, str]]:
|
|
with _HTTP_LOCK:
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp:
|
|
status = resp.getcode()
|
|
body_bytes = resp.read()
|
|
try:
|
|
body = body_bytes.decode("utf-8", errors="replace")
|
|
except Exception:
|
|
body = body_bytes.decode("latin-1", errors="replace")
|
|
headers = {k.lower(): v for k, v in resp.getheaders()}
|
|
return status, body, headers
|
|
except urllib.error.HTTPError as e:
|
|
try:
|
|
body = e.read().decode("utf-8", errors="replace")
|
|
except Exception:
|
|
body = str(e)
|
|
return e.code, body, dict(e.headers or {})
|
|
except Exception as e:
|
|
return 0, str(e), {}
|
|
|
|
def http_get(base_url: str, params: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]:
|
|
url = base_url + "?" + urllib.parse.urlencode(params)
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Connection": "close"})
|
|
return _request(req)
|
|
|
|
def http_post(base_url: str, data: Dict[str, str]) -> Tuple[int, str, Dict[str, str]]:
|
|
url = base_url # always "/"
|
|
post_data = urllib.parse.urlencode(data).encode("utf-8")
|
|
req = urllib.request.Request(url, data=post_data, headers={"User-Agent": USER_AGENT, "Connection": "close"})
|
|
return _request(req)
|
|
|
|
def http_get_root(base_url: str) -> Tuple[int, str, Dict[str, str]]:
|
|
req = urllib.request.Request(base_url, headers={"User-Agent": USER_AGENT, "Connection": "close"})
|
|
return _request(req)
|
|
|
|
def coerce_preview(value: str, maxlen: int = 80) -> str:
|
|
v = value.strip().replace("\r", " ").replace("\n", " ")
|
|
return v if len(v) <= maxlen else (v[:maxlen - 1] + "…")
|
|
|
|
def parse_function_names_from_html_index(html_text: str) -> List[str]:
|
|
start = html_text.find("==== POST ====")
|
|
if start == -1:
|
|
return []
|
|
post_html = html_text[start:]
|
|
names = re.findall(r"<b>([A-Z0-9_]+)</b>", post_html)
|
|
seen = set(); uniq: List[str] = []
|
|
for n in names:
|
|
if n not in seen:
|
|
seen.add(n); uniq.append(n)
|
|
return uniq
|
|
|
|
def parse_variable_names_from_html_index(html_text: str) -> List[str]:
|
|
tokens: List[str] = []
|
|
for m in re.finditer(r'href\s*=\s*["\']([^"\']+)["\']', html_text, flags=re.I):
|
|
href = m.group(1)
|
|
parsed = urllib.parse.urlparse(href)
|
|
qs = urllib.parse.parse_qs(parsed.query)
|
|
for v in qs.get("variable", []) + qs.get("Variable", []):
|
|
tokens.append(v)
|
|
for m in re.finditer(r'(?:Variable|variable)\s*=\s*["\']?([A-Za-z0-9_.:-]+)', html_text, flags=re.I):
|
|
tokens.append(m.group(1))
|
|
seen = set(); uniq: List[str] = []
|
|
for v in tokens:
|
|
if v and v.upper() not in {"VARNAME", "VARIABLE", "NAME"} and v not in seen:
|
|
seen.add(v); uniq.append(v)
|
|
return uniq
|
|
|
|
# ---- Parse WEBSERVER_LIST_VARIABLES_JSON ----
|
|
def parse_weblist_names(body: str) -> Tuple[List[str], List[str]]:
|
|
"""Return (get_list, post_list). Keep discovered case for function names."""
|
|
try:
|
|
data = json.loads(body)
|
|
except Exception:
|
|
return [], []
|
|
get_names: List[str] = []
|
|
post_names: List[str] = []
|
|
|
|
if isinstance(data, dict):
|
|
for k, v in data.items():
|
|
key = str(k).strip().rstrip(":").upper()
|
|
if key in ("GET", "POST"):
|
|
names = extract_names_preserve_case(v)
|
|
if key == "GET":
|
|
get_names.extend(names)
|
|
else:
|
|
post_names.extend(names)
|
|
elif isinstance(data, list):
|
|
get_names.extend(extract_names_preserve_case(data))
|
|
|
|
def dedup(seq):
|
|
seen=set(); out=[]
|
|
for s in seq:
|
|
if s not in seen:
|
|
seen.add(s); out.append(s)
|
|
return out
|
|
return dedup(get_names), dedup(post_names)
|
|
|
|
def extract_names_preserve_case(value_obj) -> List[str]:
|
|
out: List[str] = []
|
|
def add(n):
|
|
if isinstance(n, str) and n and n not in out:
|
|
out.append(n)
|
|
if isinstance(value_obj, list):
|
|
for it in value_obj:
|
|
if isinstance(it, str):
|
|
add(it)
|
|
elif isinstance(it, dict):
|
|
for k in ("name","variable","var","id","func","function"):
|
|
if k in it and isinstance(it[k], str):
|
|
add(it[k])
|
|
for k in list(it.keys()):
|
|
if isinstance(k, str) and k.upper() == k:
|
|
add(k)
|
|
elif isinstance(value_obj, dict):
|
|
for k in value_obj.keys():
|
|
if isinstance(k, str):
|
|
add(k)
|
|
elif isinstance(value_obj, str):
|
|
for m in re.findall(r"[A-Za-z0-9_]+", value_obj):
|
|
add(m)
|
|
return out
|
|
|
|
# ---- Parse WEBSERVER_BATCH_GET ----
|
|
def parse_batch_values(body: str) -> Dict[str, str]:
|
|
try:
|
|
data = json.loads(body)
|
|
except Exception:
|
|
return {}
|
|
values: Dict[str, str] = {}
|
|
if isinstance(data, dict):
|
|
for k, v in data.items():
|
|
if not isinstance(k, str):
|
|
k = str(k)
|
|
try:
|
|
values[k] = v if isinstance(v, str) else json.dumps(v)
|
|
except Exception:
|
|
values[k] = str(v)
|
|
return values
|
|
|
|
# =====================
|
|
# Data model
|
|
# =====================
|
|
@dataclass
|
|
class Thresholds:
|
|
dead_low: Optional[float] = None
|
|
low: Optional[float] = None
|
|
high: Optional[float] = None
|
|
extreme_high: Optional[float] = None
|
|
# alarms (defaults: on for dead_low & extreme_high as requested)
|
|
alarm_dead_low: bool = True
|
|
alarm_low: bool = False
|
|
alarm_high: bool = False
|
|
alarm_extreme_high: bool = True
|
|
# function actions
|
|
action_dead_low: Optional[str] = None
|
|
value_dead_low: str = "1"
|
|
action_low: Optional[str] = None
|
|
value_low: str = "1"
|
|
action_high: Optional[str] = None
|
|
value_high: str = "1"
|
|
action_extreme_high: Optional[str] = None
|
|
value_extreme_high: str = "1"
|
|
# expression actions
|
|
expr_dead_low: Optional[str] = None
|
|
expr_low: Optional[str] = None
|
|
expr_high: Optional[str] = None
|
|
expr_extreme_high: Optional[str] = None
|
|
# expression target function (per state)
|
|
expr_target_dead_low: Optional[str] = None
|
|
expr_target_low: Optional[str] = None
|
|
expr_target_high: Optional[str] = None
|
|
expr_target_extreme_high: Optional[str] = None
|
|
|
|
@dataclass
|
|
class VarInfo:
|
|
# Canonical key is lower-case; display_name retains original case from discovery/selection
|
|
key: str
|
|
display_name: str
|
|
last_value: str = ""
|
|
last_updated: float = 0.0
|
|
last_status: int = 0
|
|
error: Optional[str] = None
|
|
thresholds: Thresholds = field(default_factory=Thresholds)
|
|
last_state: str = "UNKNOWN"
|
|
|
|
@dataclass
|
|
class ActionTask:
|
|
name: str
|
|
value: str
|
|
interval_s: int
|
|
enabled: bool = True
|
|
last_run: float = 0.0
|
|
next_run: float = 0.0
|
|
task_id: int = 0
|
|
|
|
# =====================
|
|
# Scheduler thread
|
|
# =====================
|
|
class ActionScheduler(threading.Thread):
|
|
def __init__(self, get_base_url_cb):
|
|
super().__init__(daemon=True)
|
|
self.get_base_url_cb = get_base_url_cb
|
|
self.tasks: Dict[int, ActionTask] = {}
|
|
self._lock = threading.Lock()
|
|
self._stop = threading.Event()
|
|
self._counter = 1
|
|
|
|
def add_task(self, task: ActionTask) -> int:
|
|
with self._lock:
|
|
tid = self._counter
|
|
self._counter += 1
|
|
task.task_id = tid
|
|
now = time.time()
|
|
task.next_run = now if task.interval_s == 0 else now + task.interval_s
|
|
self.tasks[tid] = task
|
|
return tid
|
|
|
|
def remove_task(self, task_id: int) -> None:
|
|
with self._lock:
|
|
self.tasks.pop(task_id, None)
|
|
|
|
def list_tasks(self) -> List[ActionTask]:
|
|
with self._lock:
|
|
return list(self.tasks.values())
|
|
|
|
def set_enabled(self, task_id: int, enabled: bool) -> None:
|
|
with self._lock:
|
|
t = self.tasks.get(task_id)
|
|
if t:
|
|
t.enabled = enabled
|
|
if enabled and t.interval_s > 0:
|
|
t.next_run = time.time() + t.interval_s
|
|
|
|
def run_once(self, name: str, value: str) -> Tuple[int, str, Dict[str, str]]:
|
|
base = self.get_base_url_cb()
|
|
# Sample-app compatible body
|
|
data = {POST_KEY_VARIABLE: name, POST_KEY_VALUE: value}
|
|
return http_post(base, data)
|
|
|
|
def run(self) -> None:
|
|
while not self._stop.is_set():
|
|
now = time.time()
|
|
to_run: List[ActionTask] = []
|
|
with self._lock:
|
|
for t in self.tasks.values():
|
|
if t.enabled and t.next_run <= now:
|
|
to_run.append(t)
|
|
for t in to_run:
|
|
try:
|
|
status, body, _ = self.run_once(t.name, t.value)
|
|
t.last_run = now
|
|
if t.interval_s > 0:
|
|
t.next_run = now + t.interval_s
|
|
else:
|
|
self.remove_task(t.task_id)
|
|
except Exception:
|
|
if t.interval_s > 0:
|
|
t.next_run = now + t.interval_s
|
|
else:
|
|
self.remove_task(t.task_id)
|
|
time.sleep(0.25)
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
|
|
# =====================
|
|
# Poller thread (uses BATCH_GET)
|
|
# =====================
|
|
class Poller(threading.Thread):
|
|
def __init__(
|
|
self,
|
|
host: str,
|
|
port: int,
|
|
variables_keys: List[str], # canonical lower-case keys
|
|
ui_queue: queue.Queue,
|
|
refresh_interval: int,
|
|
stop_event: threading.Event,
|
|
paused_event: threading.Event,
|
|
) -> None:
|
|
super().__init__(daemon=True)
|
|
self.host = host
|
|
self.port = port
|
|
self.variables_keys = variables_keys
|
|
self.ui_queue = ui_queue
|
|
self.refresh_interval = int(max(5, refresh_interval))
|
|
self.stop_event = stop_event
|
|
self.paused_event = paused_event
|
|
|
|
def run(self) -> None:
|
|
base_url = build_base_url(self.host, self.port)
|
|
while not self.stop_event.is_set():
|
|
cycle_start = time.time()
|
|
self.ui_queue.put(("cycle_start", datetime.now().strftime("%H:%M:%S")))
|
|
|
|
status, body, _ = http_get(base_url, {"variable": "WEBSERVER_BATCH_GET"})
|
|
if status == 200:
|
|
values_map = parse_batch_values(body)
|
|
if values_map:
|
|
# Build case-insensitive map
|
|
lower_map = {k.lower(): v for k, v in values_map.items()}
|
|
for key in list(self.variables_keys):
|
|
if key in lower_map:
|
|
self.ui_queue.put(("update", key, str(lower_map[key]), 200))
|
|
else:
|
|
self.ui_queue.put(("error", key, 206, "Not in BATCH_GET payload"))
|
|
else:
|
|
# fallback to per-variable (try exact casing then lowercase)
|
|
for key in list(self.variables_keys):
|
|
st, b, _h = http_get(base_url, {"variable": key})
|
|
if st == 200:
|
|
self.ui_queue.put(("update", key, b, st))
|
|
else:
|
|
self.ui_queue.put(("error", key, st, coerce_preview(b, 200)))
|
|
else:
|
|
# fallback to per-variable
|
|
for key in list(self.variables_keys):
|
|
st, b, _h = http_get(base_url, {"variable": key})
|
|
if st == 200:
|
|
self.ui_queue.put(("update", key, b, st))
|
|
else:
|
|
self.ui_queue.put(("error", key, st, coerce_preview(b, 200)))
|
|
|
|
# sleep until next cycle, unless paused/stopped
|
|
cycle_dt = time.time() - cycle_start
|
|
remaining = self.refresh_interval - cycle_dt
|
|
if remaining > 0:
|
|
for _ in range(int(remaining * 10)):
|
|
if self.stop_event.is_set():
|
|
break
|
|
while self.paused_event.is_set() and not self.stop_event.is_set():
|
|
time.sleep(0.1)
|
|
time.sleep(0.1)
|
|
|
|
self.ui_queue.put(("stopped", ""))
|
|
|
|
# =====================
|
|
# GUI
|
|
# =====================
|
|
class App(tk.Tk):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.title("Control Board Monitor — v1.0")
|
|
self.geometry("1280x860")
|
|
|
|
# Canonical registry: lower-case key -> VarInfo
|
|
self.vars: Dict[str, VarInfo] = {}
|
|
self.stop_event = threading.Event()
|
|
self.paused_event = threading.Event()
|
|
self.ui_queue: queue.Queue = queue.Queue()
|
|
self.poller: Optional[Poller] = None
|
|
self.variables_keys: List[str] = [] # list of lower-case keys in selection order
|
|
self.functions_list: List[str] = [] # preserve original casing
|
|
|
|
self.scheduler = ActionScheduler(lambda: build_base_url(self.host_var.get().strip(), int(self.port_var.get())))
|
|
self.scheduler.start()
|
|
|
|
controls = ttk.Frame(self, padding=10)
|
|
controls.pack(side=tk.TOP, fill=tk.X)
|
|
|
|
self.host_var = tk.StringVar(value=SERVER_HOST)
|
|
self.port_var = tk.IntVar(value=SERVER_PORT)
|
|
self.refresh_var = tk.IntVar(value=REFRESH_INTERVAL_S)
|
|
|
|
ttk.Label(controls, text="Host:").grid(row=0, column=0, sticky="w")
|
|
ttk.Entry(controls, textvariable=self.host_var, width=16).grid(row=0, column=1, padx=(0, 10))
|
|
ttk.Label(controls, text="Port:").grid(row=0, column=2, sticky="w")
|
|
ttk.Entry(controls, textvariable=self.port_var, width=8).grid(row=0, column=3, padx=(0, 10))
|
|
ttk.Label(controls, text="Refresh (s):").grid(row=0, column=4, sticky="w")
|
|
ttk.Entry(controls, textvariable=self.refresh_var, width=8).grid(row=0, column=5, padx=(0, 10))
|
|
|
|
self.status_lbl = ttk.Label(controls, text="Idle", foreground="#666")
|
|
self.status_lbl.grid(row=0, column=6, padx=(10, 0))
|
|
|
|
btns = ttk.Frame(controls)
|
|
btns.grid(row=0, column=7, padx=10, sticky="e")
|
|
ttk.Button(btns, text="Select Vars…", command=self.open_selector).grid(row=0, column=0, padx=4)
|
|
ttk.Button(btns, text="Reload (disc.)", command=self.reload_discovery).grid(row=0, column=1, padx=4)
|
|
self.start_btn = ttk.Button(btns, text="Start", command=self.start_polling)
|
|
self.start_btn.grid(row=0, column=2, padx=4)
|
|
self.pause_btn = ttk.Button(btns, text="Pause", command=self.toggle_pause, state="disabled")
|
|
self.pause_btn.grid(row=0, column=3, padx=4)
|
|
|
|
main = ttk.Panedwindow(self, orient=tk.VERTICAL)
|
|
main.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=(0,10))
|
|
|
|
search_frame = ttk.Frame(self, padding=(0, 6, 0, 6))
|
|
ttk.Label(search_frame, text="Filter:").pack(side=tk.LEFT)
|
|
self.filter_var = tk.StringVar(value="")
|
|
self.filter_var.trace_add("write", lambda *_: self.refresh_tree())
|
|
ttk.Entry(search_frame, textvariable=self.filter_var, width=50).pack(side=tk.LEFT, padx=8)
|
|
|
|
top_frame = ttk.Frame(main)
|
|
search_frame.pack(in_=top_frame, side=tk.TOP, fill=tk.X)
|
|
columns = ("value", "updated", "status")
|
|
self.tree = ttk.Treeview(top_frame, columns=columns, show="tree headings", height=20, selectmode="browse")
|
|
self.tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
|
|
|
self.tree.heading("#0", text="Variable")
|
|
self.tree.heading("value", text="Value")
|
|
self.tree.heading("updated", text="Updated")
|
|
self.tree.heading("status", text="HTTP")
|
|
|
|
self.tree.column("#0", width=540, anchor="w")
|
|
self.tree.column("value", width=480, anchor="w")
|
|
self.tree.column("updated", width=120, anchor="center")
|
|
self.tree.column("status", width=60, anchor="center")
|
|
|
|
style = ttk.Style(self)
|
|
try: style.theme_use("clam")
|
|
except Exception: pass
|
|
|
|
# Context menu
|
|
self.tree_menu = tk.Menu(self, tearoff=False)
|
|
self.tree_menu.add_command(label="Set thresholds, actions & alarms…", command=self.open_thresholds_dialog)
|
|
self.tree.bind("<Button-3>", self.on_tree_right_click)
|
|
|
|
bottom = ttk.Frame(main)
|
|
|
|
act_controls = ttk.Frame(bottom, padding=(0,6,0,6))
|
|
ttk.Label(act_controls, text="Function:").pack(side=tk.LEFT)
|
|
self.func_name_var = tk.StringVar(value="")
|
|
self.func_combo = ttk.Combobox(act_controls, textvariable=self.func_name_var, width=44, state="readonly")
|
|
self.func_combo.pack(side=tk.LEFT, padx=6)
|
|
ttk.Label(act_controls, text="Value:").pack(side=tk.LEFT)
|
|
self.func_value_var = tk.StringVar(value="1")
|
|
ttk.Entry(act_controls, textvariable=self.func_value_var, width=10).pack(side=tk.LEFT, padx=6)
|
|
ttk.Button(act_controls, text="Run Once", command=self.run_func_once).pack(side=tk.LEFT, padx=4)
|
|
ttk.Button(act_controls, text="Schedule…", command=self.add_schedule_dialog).pack(side=tk.LEFT, padx=4)
|
|
|
|
columns2 = ("func", "value", "mode", "interval", "next", "enabled")
|
|
self.actions_tree = ttk.Treeview(bottom, columns=columns2, show="headings", height=10, selectmode="browse")
|
|
for col, hdr, w in zip(columns2, ["Function", "Value", "Mode", "Interval(s)", "Next Run", "Enabled"], [280, 160, 100, 100, 180, 80]):
|
|
self.actions_tree.heading(col, text=hdr)
|
|
self.actions_tree.column(col, width=w, anchor="center")
|
|
|
|
self.actions_tree.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
|
act_controls.pack(side=tk.TOP, fill=tk.X)
|
|
|
|
self.actions_menu = tk.Menu(self, tearoff=False)
|
|
self.actions_menu.add_command(label="Toggle Enable/Disable", command=self.toggle_selected_task)
|
|
self.actions_menu.add_command(label="Run Now", command=self.run_selected_task_now)
|
|
self.actions_menu.add_command(label="Remove", command=self.remove_selected_task)
|
|
self.actions_tree.bind("<Button-3>", self.on_actions_right_click)
|
|
|
|
main.add(top_frame, weight=3)
|
|
main.add(bottom, weight=2)
|
|
|
|
footer = ttk.Frame(self, padding=10)
|
|
footer.pack(side=tk.BOTTOM, fill=tk.X)
|
|
self.cycle_lbl = ttk.Label(footer, text="Last cycle: —")
|
|
self.cycle_lbl.pack(side=tk.LEFT)
|
|
|
|
self.protocol("WM_DELETE_WINDOW", self.on_close)
|
|
|
|
self.functions_list = list(DEFAULT_FUNCTIONS)
|
|
self.func_combo["values"] = self.functions_list
|
|
|
|
# Stable IDs
|
|
self._group_ids: Dict[str, str] = {}
|
|
self._var_ids: Dict[str, str] = {}
|
|
|
|
# Color tag cache: color_hex -> tag_name
|
|
self._color_tags: Dict[str, str] = {}
|
|
|
|
# --- Discovery ---
|
|
def reload_discovery(self) -> None:
|
|
host = self.host_var.get().strip()
|
|
port = int(self.port_var.get())
|
|
base = build_base_url(host, port)
|
|
|
|
discovered_vars: List[str] = []
|
|
discovered_funcs: List[str] = []
|
|
msgs = []
|
|
|
|
# 1) Functions via WEBSERVER_LIST_VARIABLES_JSON (POST list)
|
|
s1, b1, _1 = http_get(base, {"variable": "WEBSERVER_LIST_VARIABLES_JSON"})
|
|
if s1 == 200 and b1:
|
|
_get_list, post_list = parse_weblist_names(b1)
|
|
if post_list:
|
|
discovered_funcs.extend(post_list)
|
|
else:
|
|
msgs.append(f"LIST_VARIABLES_JSON HTTP {s1}: {coerce_preview(b1,120)}")
|
|
|
|
# 2) Variables via WEBSERVER_BATCH_GET keys
|
|
s2, b2, _2 = http_get(base, {"variable": "WEBSERVER_BATCH_GET"})
|
|
if s2 == 200 and b2:
|
|
values_map = parse_batch_values(b2)
|
|
if values_map:
|
|
# Dedup preserving first seen, case-insensitive
|
|
seen=set()
|
|
for k in values_map.keys():
|
|
kl = k.lower()
|
|
if kl not in seen:
|
|
seen.add(kl); discovered_vars.append(k)
|
|
else:
|
|
msgs.append(f"BATCH_GET HTTP {s2}: {coerce_preview(b2,120)}")
|
|
|
|
# 3) Fallbacks
|
|
if not discovered_vars or not discovered_funcs:
|
|
s3, b3, _3 = http_get_root(base)
|
|
if s3 == 200 and b3:
|
|
if not discovered_vars:
|
|
discovered_vars = parse_variable_names_from_html_index(b3) or discovered_vars
|
|
if not discovered_funcs:
|
|
discovered_funcs = parse_function_names_from_html_index(b3) or discovered_funcs
|
|
else:
|
|
msgs.append(f"Main page HTTP {s3}: {coerce_preview(b3,120)}")
|
|
|
|
# 4) embedded defaults
|
|
used_defaults = False
|
|
if not discovered_vars:
|
|
discovered_vars = list(DEFAULT_VARS); used_defaults = True
|
|
if not discovered_funcs:
|
|
discovered_funcs = list(DEFAULT_FUNCTIONS)
|
|
|
|
# Apply
|
|
self.functions_list = discovered_funcs
|
|
self.func_combo["values"] = self.functions_list
|
|
self.show_selector_dialog(discovered_vars)
|
|
|
|
# Status
|
|
if used_defaults:
|
|
messagebox.showwarning(
|
|
"Discovery fallback",
|
|
"Could not fully discover via LIST_VARIABLES_JSON / BATCH_GET.\n"
|
|
"Loaded embedded defaults so you can proceed.\n\n" + ("\n".join(msgs[:6]) if msgs else ""))
|
|
self.status_lbl.configure(text=f"Loaded defaults ({len(discovered_vars)} vars, {len(discovered_funcs)} funcs)")
|
|
else:
|
|
self.status_lbl.configure(text=f"Discovered: vars {len(discovered_vars)} | funcs {len(discovered_funcs)}")
|
|
|
|
# --- Select Vars dialog ---
|
|
def open_selector(self) -> None:
|
|
# Use discovered list if we have it, else defaults or already loaded vars
|
|
union = set()
|
|
# include discovered func of vars in existing self.vars
|
|
union.update([vi.display_name for vi in self.vars.values()])
|
|
if not union:
|
|
union.update(DEFAULT_VARS)
|
|
self.show_selector_dialog(sorted(union))
|
|
|
|
def show_selector_dialog(self, discovered: List[str]) -> None:
|
|
# Case-insensitive dedup for display
|
|
seen=set(); disp=[]
|
|
for n in discovered:
|
|
kl = n.lower()
|
|
if kl not in seen:
|
|
seen.add(kl); disp.append(n)
|
|
|
|
dlg = tk.Toplevel(self)
|
|
dlg.title("Select Variables to Monitor")
|
|
dlg.geometry("600x540")
|
|
dlg.transient(self)
|
|
dlg.grab_set()
|
|
|
|
top = ttk.Frame(dlg, padding=8)
|
|
top.pack(side=tk.TOP, fill=tk.X)
|
|
ttk.Label(top, text="Filter:").pack(side=tk.LEFT)
|
|
filt_var = tk.StringVar(value="")
|
|
ttk.Entry(top, textvariable=filt_var, width=40).pack(side=tk.LEFT, padx=6)
|
|
sel_all = ttk.Button(top, text="Select All")
|
|
sel_none = ttk.Button(top, text="Clear")
|
|
sel_all.pack(side=tk.LEFT, padx=4); sel_none.pack(side=tk.LEFT, padx=4)
|
|
|
|
frame = ttk.Frame(dlg)
|
|
frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=8)
|
|
canvas = tk.Canvas(frame, borderwidth=0)
|
|
vsb = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview)
|
|
inner = ttk.Frame(canvas)
|
|
inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
|
canvas.create_window((0,0), window=inner, anchor="nw")
|
|
canvas.configure(yscrollcommand=vsb.set)
|
|
canvas.pack(side="left", fill="both", expand=True); vsb.pack(side="right", fill="y")
|
|
|
|
current_set = set(self.variables_keys) # already lower-case
|
|
rows: List[Tuple[str, str, tk.BooleanVar]] = [] # (display, key, var)
|
|
|
|
def rebuild():
|
|
for child in inner.winfo_children():
|
|
child.destroy()
|
|
rows.clear()
|
|
f = filt_var.get().strip().lower()
|
|
for name in disp:
|
|
if f and f not in name.lower():
|
|
continue
|
|
key = name.lower()
|
|
var = tk.BooleanVar(value=(key in current_set))
|
|
cb = ttk.Checkbutton(inner, text=name, variable=var)
|
|
cb.pack(anchor="w", pady=2, padx=4)
|
|
rows.append((name, key, var))
|
|
rebuild()
|
|
|
|
def on_filter(*_): rebuild()
|
|
filt_var.trace_add("write", on_filter)
|
|
sel_all.configure(command=lambda: [v.set(True) for _,_,v in rows])
|
|
sel_none.configure(command=lambda: [v.set(False) for _,_,v in rows])
|
|
|
|
bot = ttk.Frame(dlg, padding=8)
|
|
bot.pack(side=tk.BOTTOM, fill=tk.X)
|
|
def apply_and_close():
|
|
selected_keys = [key for _, key, var in rows if var.get()]
|
|
# preserve order: existing first, then new in displayed order
|
|
new_order = [k for k in self.variables_keys if k in selected_keys]
|
|
for _, key, var in rows:
|
|
if var.get() and key not in new_order:
|
|
new_order.append(key)
|
|
self.variables_keys = new_order
|
|
# merge VarInfo with preserved thresholds and display names
|
|
new_vars: Dict[str, VarInfo] = {}
|
|
for key in self.variables_keys:
|
|
if key in self.vars:
|
|
vi = self.vars[key]
|
|
else:
|
|
# Find display name from rows
|
|
disp_name = next((n for n, k, _ in rows if k == key), key)
|
|
vi = VarInfo(key=key, display_name=disp_name)
|
|
new_vars[key] = vi
|
|
self.vars = new_vars
|
|
self.refresh_tree()
|
|
dlg.destroy()
|
|
ttk.Button(bot, text="Apply", command=apply_and_close).pack(side=tk.RIGHT, padx=4)
|
|
ttk.Button(bot, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT, padx=4)
|
|
|
|
# --- Actions UI ---
|
|
def on_actions_right_click(self, event):
|
|
try:
|
|
iid = self.actions_tree.identify_row(event.y)
|
|
if iid:
|
|
self.actions_tree.selection_set(iid)
|
|
finally:
|
|
self.actions_menu.tk_popup(event.x_root, event.y_root)
|
|
|
|
def run_func_once(self):
|
|
fname = self.func_name_var.get().strip()
|
|
if not fname:
|
|
messagebox.showinfo("No function", "Choose a function first.")
|
|
return
|
|
val = self.func_value_var.get()
|
|
status, body, _ = self.scheduler.run_once(fname, val)
|
|
messagebox.showinfo("Run Once", f"POST: {fname}={val}\nHTTP {status}\n{coerce_preview(body, 300)}")
|
|
|
|
def add_schedule_dialog(self):
|
|
fname = self.func_name_var.get().strip()
|
|
if not fname:
|
|
messagebox.showinfo("No function", "Choose a function first.")
|
|
return
|
|
top = tk.Toplevel(self); top.title("Schedule Function"); top.geometry("360x170"); top.transient(self); top.grab_set()
|
|
ttk.Label(top, text=f"Function: {fname}").pack(pady=(10,4))
|
|
val_var = tk.StringVar(value=self.func_value_var.get())
|
|
ttk.Label(top, text="Value:").pack()
|
|
ttk.Entry(top, textvariable=val_var, width=16).pack()
|
|
int_var = tk.IntVar(value=30)
|
|
ttk.Label(top, text="Interval (seconds):").pack(pady=(6,0))
|
|
ttk.Entry(top, textvariable=int_var, width=10).pack()
|
|
|
|
def add_it():
|
|
task = ActionTask(name=fname, value=val_var.get(), interval_s=max(1, int_var.get()))
|
|
tid = self.scheduler.add_task(task)
|
|
self.refresh_actions_tree()
|
|
top.destroy()
|
|
ttk.Button(top, text="Add", command=add_it).pack(pady=10)
|
|
|
|
def toggle_selected_task(self):
|
|
sel = self.actions_tree.selection()
|
|
if not sel: return
|
|
tid = int(sel[0])
|
|
tasks = {t.task_id: t for t in self.scheduler.list_tasks()}
|
|
t = tasks.get(tid);
|
|
if not t: return
|
|
self.scheduler.set_enabled(tid, not t.enabled)
|
|
self.refresh_actions_tree()
|
|
|
|
def run_selected_task_now(self):
|
|
sel = self.actions_tree.selection()
|
|
if not sel: return
|
|
tid = int(sel[0])
|
|
tasks = {t.task_id: t for t in self.scheduler.list_tasks()}
|
|
t = tasks.get(tid)
|
|
if not t: return
|
|
status, body, _ = self.scheduler.run_once(t.name, t.value)
|
|
messagebox.showinfo("Run Now", f"POST: {t.name}={t.value}\nHTTP {status}\n{coerce_preview(body, 300)}")
|
|
|
|
def remove_selected_task(self):
|
|
sel = self.actions_tree.selection()
|
|
if not sel: return
|
|
tid = int(sel[0])
|
|
self.scheduler.remove_task(tid)
|
|
self.refresh_actions_tree()
|
|
|
|
def refresh_actions_tree(self):
|
|
self.actions_tree.delete(*self.actions_tree.get_children())
|
|
for t in self.scheduler.list_tasks():
|
|
mode = "Interval" if t.interval_s > 0 else "Once"
|
|
next_s = ("—" if t.interval_s == 0 else datetime.fromtimestamp(t.next_run).strftime("%H:%M:%S"))
|
|
self.actions_tree.insert("", "end", iid=str(t.task_id), values=(t.name, t.value, mode, t.interval_s, next_s, "Yes" if t.enabled else "No"))
|
|
|
|
# --- Poller control / rendering ---
|
|
def start_polling(self) -> None:
|
|
if hasattr(self, "poller") and self.poller and self.poller.is_alive():
|
|
messagebox.showinfo("Already running", "Polling is already active.")
|
|
return
|
|
if not self.variables_keys:
|
|
messagebox.showwarning("No variables", "Variable list is empty. Use Select Vars… first.")
|
|
return
|
|
self.stop_event.clear()
|
|
self.paused_event.clear()
|
|
self.poller = Poller(
|
|
host=self.host_var.get().strip(),
|
|
port=int(self.port_var.get()),
|
|
variables_keys=self.variables_keys,
|
|
ui_queue=self.ui_queue,
|
|
refresh_interval=int(self.refresh_var.get()),
|
|
stop_event=self.stop_event,
|
|
paused_event=self.paused_event,
|
|
)
|
|
self.poller.start()
|
|
self.start_btn.configure(state="disabled")
|
|
self.pause_btn.configure(state="normal", text="Pause")
|
|
self.status_lbl.configure(text="Running…")
|
|
self.after(100, self.drain_queue)
|
|
|
|
def toggle_pause(self) -> None:
|
|
if not (self.poller and self.poller.is_alive()):
|
|
return
|
|
if self.paused_event.is_set():
|
|
self.paused_event.clear()
|
|
self.pause_btn.configure(text="Pause")
|
|
self.status_lbl.configure(text="Running…")
|
|
else:
|
|
self.paused_event.set()
|
|
self.pause_btn.configure(text="Resume")
|
|
self.status_lbl.configure(text="Paused")
|
|
|
|
def drain_queue(self) -> None:
|
|
try:
|
|
while True:
|
|
item = self.ui_queue.get_nowait()
|
|
kind = item[0]
|
|
if kind == "update":
|
|
_, key, value, status = item
|
|
vi = self.vars.get(key)
|
|
if not vi:
|
|
vi = VarInfo(key=key, display_name=key)
|
|
vi.last_value = value
|
|
vi.last_updated = time.time()
|
|
vi.last_status = status
|
|
vi.error = None
|
|
self.vars[key] = vi
|
|
self.evaluate_thresholds(vi, value)
|
|
elif kind == "error":
|
|
_, key, status, msg = item
|
|
vi = self.vars.get(key) or VarInfo(key=key, display_name=key)
|
|
vi.last_status = status
|
|
vi.error = msg
|
|
vi.last_updated = time.time()
|
|
self.vars[key] = vi
|
|
elif kind == "cycle_start":
|
|
_, hhmmss = item
|
|
self.cycle_lbl.configure(text=f"Last cycle start: {hhmmss}")
|
|
elif kind == "stopped":
|
|
self.status_lbl.configure(text="Stopped")
|
|
self.ui_queue.task_done()
|
|
except queue.Empty:
|
|
pass
|
|
self.refresh_tree()
|
|
self.refresh_actions_tree()
|
|
if self.poller and self.poller.is_alive():
|
|
self.after(250, self.drain_queue)
|
|
|
|
# --- Threshold logic & rendering ---
|
|
def on_tree_right_click(self, event):
|
|
try:
|
|
iid = self.tree.identify_row(event.y)
|
|
if iid:
|
|
self.tree.selection_set(iid)
|
|
finally:
|
|
self.tree_menu.tk_popup(event.x_root, event.y_root)
|
|
|
|
def open_thresholds_dialog(self):
|
|
sel = self.tree.selection()
|
|
if not sel:
|
|
messagebox.showinfo("No selection", "Select a variable first.")
|
|
return
|
|
iid = sel[0]
|
|
if not iid.startswith("var:"):
|
|
messagebox.showinfo("Select variable", "Select a specific variable row (not a group).")
|
|
return
|
|
key = iid.split("var:",1)[1]
|
|
vi = self.vars.get(key)
|
|
if not vi:
|
|
messagebox.showinfo("Missing", "Selected variable is not available.")
|
|
return
|
|
|
|
dlg = tk.Toplevel(self)
|
|
dlg.title(f"Thresholds, actions & alarms — {vi.display_name}")
|
|
dlg.geometry("980x680")
|
|
dlg.transient(self); dlg.grab_set()
|
|
|
|
t = vi.thresholds
|
|
grid = ttk.Frame(dlg, padding=10)
|
|
grid.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
|
|
|
entries = {}
|
|
def add_row(r, label, keyname, default):
|
|
ttk.Label(grid, text=label).grid(row=r, column=0, sticky="w", pady=4)
|
|
sv = tk.StringVar(value="" if default is None else str(default))
|
|
e = ttk.Entry(grid, textvariable=sv, width=12)
|
|
e.grid(row=r, column=1, sticky="w", padx=6)
|
|
entries[keyname] = sv
|
|
|
|
add_row(0, "DEAD LOW", "dead_low", t.dead_low)
|
|
add_row(1, "LOW", "low", t.low)
|
|
add_row(2, "HIGH", "high", t.high)
|
|
add_row(3, "EXTREME HIGH", "extreme_high", t.extreme_high)
|
|
|
|
ttk.Label(grid, text="Alarm on enter:").grid(row=4, column=0, sticky="w", pady=(12,4))
|
|
alarm_vars = {}
|
|
for i, (lbl, keyname, val) in enumerate([("Dead Low", "alarm_dead_low", t.alarm_dead_low),
|
|
("Low", "alarm_low", t.alarm_low),
|
|
("High", "alarm_high", t.alarm_high),
|
|
("Extreme High", "alarm_extreme_high", t.alarm_extreme_high)]):
|
|
var = tk.BooleanVar(value=val)
|
|
ttk.Checkbutton(grid, text=lbl, variable=var).grid(row=5+i//2, column=i%2+0, sticky="w", padx=6, pady=2)
|
|
alarm_vars[keyname] = var
|
|
|
|
ttk.Label(grid, text="On enter: call function (optional)").grid(row=7, column=0, sticky="w", pady=(12,4))
|
|
rows = [("Dead Low", "action_dead_low", "value_dead_low"),
|
|
("Low", "action_low", "value_low"),
|
|
("High", "action_high", "value_high"),
|
|
("Extreme High", "action_extreme_high", "value_extreme_high")]
|
|
action_vars = {}
|
|
value_vars = {}
|
|
for i, (label, action_key, value_key) in enumerate(rows):
|
|
ttk.Label(grid, text=label).grid(row=8+i, column=0, sticky="w", pady=2)
|
|
s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=44, state="readonly")
|
|
s.set(getattr(t, action_key) or "")
|
|
s.grid(row=8+i, column=1, sticky="w", padx=6)
|
|
action_vars[action_key] = s
|
|
sv = tk.StringVar(value=getattr(t, value_key))
|
|
ttk.Entry(grid, textvariable=sv, width=12).grid(row=8+i, column=2, sticky="w", padx=6)
|
|
value_vars[value_key] = sv
|
|
|
|
ttk.Label(grid, text="On enter: evaluate expression/snippet (x = current value) and optionally POST to a function").grid(row=12, column=0, sticky="w", pady=(14,4), columnspan=3)
|
|
|
|
expr_vars = {
|
|
"expr_dead_low": tk.StringVar(value=t.expr_dead_low or ""),
|
|
"expr_low": tk.StringVar(value=t.expr_low or ""),
|
|
"expr_high": tk.StringVar(value=t.expr_high or ""),
|
|
"expr_extreme_high": tk.StringVar(value=t.expr_extreme_high or ""),
|
|
}
|
|
expr_target_vars = {
|
|
"expr_target_dead_low": tk.StringVar(value=t.expr_target_dead_low or ""),
|
|
"expr_target_low": tk.StringVar(value=t.expr_target_low or ""),
|
|
"expr_target_high": tk.StringVar(value=t.expr_target_high or ""),
|
|
"expr_target_extreme_high": tk.StringVar(value=t.expr_target_extreme_high or ""),
|
|
}
|
|
|
|
for i, (label, keyname, tkey) in enumerate([("Dead Low expr", "expr_dead_low", "expr_target_dead_low"),
|
|
("Low expr", "expr_low", "expr_target_low"),
|
|
("High expr", "expr_high", "expr_target_high"),
|
|
("Extreme High expr", "expr_extreme_high", "expr_target_extreme_high")]):
|
|
ttk.Label(grid, text=label).grid(row=13+i, column=0, sticky="w", pady=2)
|
|
ttk.Entry(grid, textvariable=expr_vars[keyname], width=64).grid(row=13+i, column=1, sticky="w", padx=6)
|
|
ttk.Label(grid, text="→ POST to function").grid(row=13+i, column=2, sticky="e")
|
|
s = ttk.Combobox(grid, values=self.functions_list or DEFAULT_FUNCTIONS, width=32, state="readonly")
|
|
s.set(expr_target_vars[tkey].get())
|
|
s.grid(row=13+i, column=3, sticky="w", padx=6)
|
|
def bind_setter(varkey=tkey, combo=s):
|
|
def _():
|
|
expr_target_vars[varkey].set(combo.get())
|
|
combo.bind("<<ComboboxSelected>>", lambda *_: _())
|
|
bind_setter()
|
|
|
|
def save_and_close():
|
|
def _f(v):
|
|
v = v.strip()
|
|
return float(v) if v else None
|
|
nt = Thresholds(
|
|
dead_low=_f(entries["dead_low"].get()),
|
|
low=_f(entries["low"].get()),
|
|
high=_f(entries["high"].get()),
|
|
extreme_high=_f(entries["extreme_high"].get()),
|
|
alarm_dead_low=alarm_vars["alarm_dead_low"].get(),
|
|
alarm_low=alarm_vars["alarm_low"].get(),
|
|
alarm_high=alarm_vars["alarm_high"].get(),
|
|
alarm_extreme_high=alarm_vars["alarm_extreme_high"].get(),
|
|
action_dead_low=action_vars["action_dead_low"].get() or None,
|
|
value_dead_low=value_vars["value_dead_low"].get(),
|
|
action_low=action_vars["action_low"].get() or None,
|
|
value_low=value_vars["value_low"].get(),
|
|
action_high=action_vars["action_high"].get() or None,
|
|
value_high=value_vars["value_high"].get(),
|
|
action_extreme_high=action_vars["action_extreme_high"].get() or None,
|
|
value_extreme_high=value_vars["value_extreme_high"].get(),
|
|
expr_dead_low=expr_vars["expr_dead_low"].get().strip() or None,
|
|
expr_low=expr_vars["expr_low"].get().strip() or None,
|
|
expr_high=expr_vars["expr_high"].get().strip() or None,
|
|
expr_extreme_high=expr_vars["expr_extreme_high"].get().strip() or None,
|
|
expr_target_dead_low=expr_target_vars["expr_target_dead_low"].get().strip() or None,
|
|
expr_target_low=expr_target_vars["expr_target_low"].get().strip() or None,
|
|
expr_target_high=expr_target_vars["expr_target_high"].get().strip() or None,
|
|
expr_target_extreme_high=expr_target_vars["expr_target_extreme_high"].get().strip() or None,
|
|
)
|
|
vi.thresholds = nt
|
|
self.vars[vi.key] = vi
|
|
dlg.destroy()
|
|
|
|
btns = ttk.Frame(dlg, padding=10)
|
|
btns.pack(side=tk.BOTTOM, fill=tk.X)
|
|
ttk.Button(btns, text="Save", command=save_and_close).pack(side=tk.RIGHT, padx=4)
|
|
ttk.Button(btns, text="Cancel", command=dlg.destroy).pack(side=tk.RIGHT, padx=4)
|
|
|
|
# --- State evaluation per spec + color gradients ---
|
|
def evaluate_thresholds(self, vi: VarInfo, value: str) -> None:
|
|
x = parse_first_float(value)
|
|
t = vi.thresholds
|
|
prev = vi.last_state
|
|
state = "UNKNOWN"
|
|
|
|
if x is None or (t.low is None and t.high is None and t.dead_low is None and t.extreme_high is None):
|
|
state = "OPERATING"
|
|
else:
|
|
# Define bounds with fallbacks so comparisons work
|
|
dl = t.dead_low if t.dead_low is not None else -float("inf")
|
|
lo = t.low if t.low is not None else -float("inf")
|
|
hi = t.high if t.high is not None else float("inf")
|
|
ex = t.extreme_high if t.extreme_high is not None else float("inf")
|
|
|
|
if x < dl:
|
|
state = "DEAD_LOW"
|
|
elif x < lo:
|
|
state = "LOW"
|
|
elif x < hi:
|
|
state = "OPERATING"
|
|
elif x < ex:
|
|
state = "HIGH"
|
|
else:
|
|
state = "EXTREME_HIGH"
|
|
|
|
if state != prev:
|
|
self.on_enter_state(vi, state, x)
|
|
vi.last_state = state
|
|
|
|
def on_enter_state(self, vi: VarInfo, state: str, x: Optional[float]) -> None:
|
|
t = vi.thresholds
|
|
# Alarms default enabled for DEAD_LOW & EXTREME_HIGH
|
|
alarm_map = {
|
|
"DEAD_LOW": t.alarm_dead_low,
|
|
"LOW": t.alarm_low,
|
|
"OPERATING": False,
|
|
"HIGH": t.alarm_high,
|
|
"EXTREME_HIGH": t.alarm_extreme_high,
|
|
}
|
|
if alarm_map.get(state):
|
|
try:
|
|
import winsound
|
|
freq = {"DEAD_LOW": 350, "LOW": 550, "HIGH": 800, "EXTREME_HIGH": 1000}.get(state, 600)
|
|
dur = 400 if state in ("DEAD_LOW", "EXTREME_HIGH") else 300
|
|
winsound.Beep(freq, dur)
|
|
except Exception:
|
|
try: self.bell()
|
|
except Exception: pass
|
|
|
|
# Expression (compute & optionally POST to chosen function)
|
|
expr_key = {
|
|
"DEAD_LOW": "expr_dead_low", "LOW": "expr_low",
|
|
"HIGH": "expr_high", "EXTREME_HIGH": "expr_extreme_high"
|
|
}.get(state)
|
|
target_key = {
|
|
"DEAD_LOW": "expr_target_dead_low", "LOW": "expr_target_low",
|
|
"HIGH": "expr_target_high", "EXTREME_HIGH": "expr_target_extreme_high"
|
|
}.get(state)
|
|
expr = getattr(t, expr_key) if expr_key else None
|
|
target_func = getattr(t, target_key) if target_key else None
|
|
if expr and x is not None:
|
|
try:
|
|
computed = eval_user_expression(expr, x)
|
|
if target_func:
|
|
base = build_base_url(self.host_var.get().strip(), int(self.port_var.get()))
|
|
data = {POST_KEY_VARIABLE: target_func, POST_KEY_VALUE: str(computed)}
|
|
http_post(base, data)
|
|
else:
|
|
self.status_lbl.configure(text=f"Expr computed for {vi.display_name} [{state}], no target function set")
|
|
except Exception as e:
|
|
messagebox.showwarning("Expression error", f"{vi.display_name}: expression failed on enter {state}: {e}")
|
|
|
|
# Function action
|
|
act_map = {
|
|
"DEAD_LOW": (t.action_dead_low, t.value_dead_low),
|
|
"LOW": (t.action_low, t.value_low),
|
|
"HIGH": (t.action_high, t.value_high),
|
|
"EXTREME_HIGH": (t.action_extreme_high, t.value_extreme_high),
|
|
}
|
|
act, val = act_map.get(state, (None, "1"))
|
|
if act:
|
|
self.scheduler.add_task(ActionTask(name=act, value=val, interval_s=0))
|
|
|
|
# --- Tree helpers & rendering ---
|
|
def _ensure_group(self, group: str) -> str:
|
|
iid = self._group_ids.get(group)
|
|
if iid and self.tree.exists(iid):
|
|
return iid
|
|
iid = f"grp:{group}"
|
|
self._group_ids[group] = iid
|
|
if not self.tree.exists(iid):
|
|
self.tree.insert("", "end", iid=iid, text=group, values=("", "", ""), open=True)
|
|
return iid
|
|
|
|
def _ensure_var(self, group_iid: str, key: str, display_name: str) -> str:
|
|
iid = self._var_ids.get(key)
|
|
if iid and self.tree.exists(iid):
|
|
return iid
|
|
iid = f"var:{key}"
|
|
self._var_ids[key] = iid
|
|
if not self.tree.exists(iid):
|
|
self.tree.insert(group_iid, "end", iid=iid, text=display_name, values=("", "", ""))
|
|
return iid
|
|
|
|
def _get_color_tag(self, hex_color: str) -> str:
|
|
tag = self._color_tags.get(hex_color)
|
|
if tag:
|
|
return tag
|
|
tag = f"col_{hex_color[1:]}"
|
|
self._color_tags[hex_color] = tag
|
|
try:
|
|
self.tree.tag_configure(tag, background=hex_color)
|
|
except Exception:
|
|
pass
|
|
return tag
|
|
|
|
def _state_color(self, vi: VarInfo, x: Optional[float]) -> str:
|
|
# Gradients based on distance to thresholds
|
|
t = vi.thresholds
|
|
# Defaults
|
|
if x is None:
|
|
return "#f5f7fb"
|
|
|
|
# helper to clamp int 0..255
|
|
def ci(v): return max(0, min(255, int(v)))
|
|
|
|
# Compute colors:
|
|
# OPERATING: green ~ #E6F5E6
|
|
# LOW zone: yellow from #FFF8CC near LOW to #FFE28A near DEAD_LOW
|
|
# DEAD_LOW: orange-ish #FFC266
|
|
# HIGH zone: red from #FFD6D6 near HIGH to #FF9E9E near EXTREME_HIGH
|
|
# EXTREME_HIGH: deep red #FF6B6B
|
|
if t.low is not None and t.high is not None and t.dead_low is not None and t.extreme_high is not None:
|
|
if x < t.dead_low:
|
|
return "#FFC266"
|
|
elif x < t.low:
|
|
# interpolate between LOW and DEAD_LOW
|
|
span = max(1e-9, t.low - t.dead_low)
|
|
frac = 1.0 - (x - t.dead_low) / span # 0 near low, 1 near dead_low
|
|
# from #FFF8CC to #FFE28A
|
|
r = 255
|
|
g = 248 - frac * (248 - 226)
|
|
b = 204 - frac * (204 - 138)
|
|
return f"#{ci(r):02X}{ci(g):02X}{ci(b):02X}"
|
|
elif x < t.high:
|
|
return "#E6F5E6"
|
|
elif x < t.extreme_high:
|
|
span = max(1e-9, t.extreme_high - t.high)
|
|
frac = (x - t.high) / span # 0 near high, 1 near extreme
|
|
# from #FFD6D6 to #FF9E9E
|
|
r = 255
|
|
g = 214 - frac * (214 - 158)
|
|
b = 214 - frac * (214 - 158)
|
|
return f"#{ci(r):02X}{ci(g):02X}{ci(b):02X}"
|
|
else:
|
|
return "#FF6B6B"
|
|
# Fallback simple mapping
|
|
return "#E6F5E6"
|
|
|
|
def refresh_tree(self) -> None:
|
|
filt = self.filter_var.get().strip().lower()
|
|
|
|
# Build groups from current vars (case-insensitive)
|
|
groups: Dict[str, List[VarInfo]] = {}
|
|
for key, vi in self.vars.items():
|
|
text = vi.display_name
|
|
if filt and filt not in text.lower() and (not vi.last_value or filt not in vi.last_value.lower()):
|
|
continue
|
|
prefix = text.split("_", 1)[0] if "_" in text else "MISC"
|
|
groups.setdefault(prefix, []).append(vi)
|
|
|
|
# Preserve current selection
|
|
sel = self.tree.selection()
|
|
sel_iid = sel[0] if sel else None
|
|
|
|
valid_var_iids = set()
|
|
valid_group_iids = set()
|
|
|
|
for g in sorted(groups.keys(), key=lambda s: s.lower()):
|
|
gid = self._ensure_group(g)
|
|
valid_group_iids.add(gid)
|
|
for vi in sorted(groups[g], key=lambda x: x.display_name.lower()):
|
|
vid = self._ensure_var(gid, vi.key, vi.display_name)
|
|
valid_var_iids.add(vid)
|
|
|
|
updated_str = "—"
|
|
if vi.last_updated:
|
|
updated_str = datetime.fromtimestamp(vi.last_updated).strftime("%H:%M:%S")
|
|
|
|
if vi.error:
|
|
value_preview = f"[ERR] {vi.error}"
|
|
else:
|
|
value_preview = coerce_preview(vi.last_value, 120)
|
|
|
|
# compute color tag
|
|
x = parse_first_float(vi.last_value)
|
|
color = self._state_color(vi, x)
|
|
tag = self._get_color_tag(color)
|
|
|
|
self.tree.item(vid, text=vi.display_name, values=(value_preview, updated_str, vi.last_status or "—"), tags=(tag,))
|
|
|
|
# Remove stale items
|
|
for key, vid in list(self._var_ids.items()):
|
|
if vid not in valid_var_iids and self.tree.exists(vid):
|
|
self.tree.delete(vid); self._var_ids.pop(key, None)
|
|
for g, gid in list(self._group_ids.items()):
|
|
if gid not in valid_group_iids and self.tree.exists(gid):
|
|
self.tree.delete(gid); self._group_ids.pop(g, None)
|
|
|
|
# Restore selection if possible
|
|
if sel_iid and self.tree.exists(sel_iid):
|
|
self.tree.selection_set(sel_iid)
|
|
|
|
def on_close(self) -> None:
|
|
try:
|
|
if self.poller and self.poller.is_alive():
|
|
self.stop_event.set()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.scheduler.stop()
|
|
except Exception:
|
|
pass
|
|
self.destroy()
|
|
|
|
# --- Utils ---
|
|
def parse_first_float(value: str) -> Optional[float]:
|
|
if value is None:
|
|
return None
|
|
m = re.search(r"[-+]?\d+(?:\.\d+)?", str(value).replace(",", "."))
|
|
if m:
|
|
try:
|
|
return float(m.group(0))
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
def eval_user_expression(expr: str, x: float):
|
|
"""Evaluate user expression/snippet with x available.
|
|
Allowed: math.*, min, max, abs, round, int, float, clamp (custom).
|
|
Returns the computed result.
|
|
"""
|
|
def clamp(v, lo, hi):
|
|
return max(lo, min(hi, v))
|
|
safe_globals = {"__builtins__": {}, "math": math, "min": min, "max": max, "abs": abs, "round": round, "int": int, "float": float, "clamp": clamp}
|
|
safe_locals = {"x": x}
|
|
# If it's multiline or contains statements, try exec and read 'result'
|
|
if "\n" in expr or ";" in expr:
|
|
code = compile(expr, "<expr>", "exec")
|
|
exec(code, safe_globals, safe_locals)
|
|
if "result" not in safe_locals:
|
|
raise ValueError("Snippet must assign to 'result', e.g., result = x * 1.1")
|
|
return safe_locals["result"]
|
|
else:
|
|
code = compile(expr, "<expr>", "eval")
|
|
return eval(code, safe_globals, safe_locals)
|
|
|
|
def main() -> None:
|
|
app = App()
|
|
app.mainloop()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|