"""Flask routes for HAMeter web UI.""" import datetime import json import logging import threading from dataclasses import asdict from flask import ( Blueprint, Response, current_app, jsonify, redirect, render_template, request, url_for, ) from hameter.config import ( VALID_PROTOCOLS, GeneralConfig, HaMeterConfig, MeterConfig, MqttConfig, RateComponent, config_to_dict, get_meter_defaults, save_config, validate_meter_config, validate_mqtt_config, validate_rate_component, ) from hameter.cost_state import save_cost_state from hameter.state import AppState, PipelineStatus logger = logging.getLogger(__name__) bp = Blueprint("main", __name__, static_folder="static") def _state() -> AppState: return current_app.config["APP_STATE"] # ------------------------------------------------------------------ # # Page routes # ------------------------------------------------------------------ # @bp.route("/") def index(): state = _state() if state.status == PipelineStatus.UNCONFIGURED: return redirect(url_for("main.setup_page")) return redirect(url_for("main.dashboard_page")) @bp.route("/setup") def setup_page(): state = _state() if state.config_ready.is_set(): return redirect(url_for("main.dashboard_page")) return render_template("setup.html", protocols=sorted(VALID_PROTOCOLS)) @bp.route("/dashboard") def dashboard_page(): state = _state() if state.status == PipelineStatus.UNCONFIGURED: return redirect(url_for("main.setup_page")) config = state.config meters = config.meters if config else [] return render_template("dashboard.html", meters=meters) @bp.route("/config/mqtt") def mqtt_page(): state = _state() config = state.config mqtt = config.mqtt if config else MqttConfig(host="") return render_template("mqtt.html", mqtt=mqtt) @bp.route("/config/meters") def meters_page(): state = _state() config = state.config meters = config.meters if config else [] return render_template("meters.html", meters=meters) @bp.route("/config/meters/add") def meter_add_page(): # Pre-fill from query params (e.g. coming from discovery). prefill_id = request.args.get("id", "") prefill_protocol = request.args.get("protocol", "") return render_template( "meter_form.html", meter=None, editing=False, protocols=sorted(VALID_PROTOCOLS), prefill_id=prefill_id, prefill_protocol=prefill_protocol, ) @bp.route("/config/meters//edit") def meter_edit_page(meter_id): state = _state() config = state.config meter = None if config: for m in config.meters: if m.id == meter_id: meter = m break if meter is None: return redirect(url_for("main.meters_page")) return render_template( "meter_form.html", meter=meter, editing=True, protocols=sorted(VALID_PROTOCOLS), ) @bp.route("/config/general") def general_page(): state = _state() config = state.config general = config.general if config else GeneralConfig() return render_template("general.html", general=general) @bp.route("/discovery") def discovery_page(): state = _state() config = state.config configured_ids = [] if config: configured_ids = [m.id for m in config.meters] return render_template( "discovery.html", configured_ids=configured_ids, protocols=sorted(VALID_PROTOCOLS), ) @bp.route("/calibration") def calibration_page(): state = _state() config = state.config meters = config.meters if config else [] readings = state.get_last_readings() return render_template("calibration.html", meters=meters, readings=readings) @bp.route("/logs") def logs_page(): return render_template("logs.html") # ------------------------------------------------------------------ # # API routes # ------------------------------------------------------------------ # @bp.route("/api/status") def api_status(): state = _state() config = state.config return jsonify({ "status": state.status.value, "message": state.status_message, "meters_configured": len(config.meters) if config else 0, "config_ready": state.config_ready.is_set(), }) @bp.route("/api/readings") def api_readings(): return jsonify(_readings_dict(_state())) @bp.route("/api/setup", methods=["POST"]) def api_setup(): state = _state() data = request.get_json() if not data: return jsonify({"error": "No data provided"}), 400 # Validate MQTT mqtt_data = data.get("mqtt", {}) ok, err = validate_mqtt_config(mqtt_data) if not ok: return jsonify({"error": err}), 400 # Build meters (optional during setup) meters = [] meter_data = data.get("meter") if meter_data and meter_data.get("id"): ok, err = validate_meter_config(meter_data) if not ok: return jsonify({"error": err}), 400 device_class = meter_data.get("device_class", "") defaults = get_meter_defaults(device_class) meters.append(MeterConfig( id=int(meter_data["id"]), protocol=meter_data["protocol"].lower(), name=meter_data.get("name", "Meter 1"), unit_of_measurement=meter_data.get("unit", "") or defaults.get("unit", ""), icon=meter_data.get("icon", "") or defaults.get("icon", "mdi:gauge"), device_class=device_class, state_class=meter_data.get("state_class", "total_increasing"), multiplier=float(meter_data.get("multiplier", 1.0)), )) config = HaMeterConfig( general=GeneralConfig(), mqtt=MqttConfig( host=mqtt_data["host"].strip(), port=int(mqtt_data.get("port", 1883)), user=mqtt_data.get("user", ""), password=mqtt_data.get("password", ""), base_topic=mqtt_data.get("base_topic", "hameter"), ), meters=meters, ) try: save_config(config) except Exception as e: return jsonify({"error": f"Failed to save config: {e}"}), 500 state.set_config(config) state.config_ready.set() state.set_status(PipelineStatus.STOPPED, "Config saved, starting pipeline") return jsonify({"ok": True}) @bp.route("/api/config/mqtt", methods=["GET", "POST"]) def api_config_mqtt(): state = _state() config = state.config if request.method == "GET": if not config: return jsonify({"error": "No config loaded"}), 404 mqtt = config.mqtt return jsonify({ "host": mqtt.host, "port": mqtt.port, "user": mqtt.user, "password": "***" if mqtt.password else "", "base_topic": mqtt.base_topic, "ha_autodiscovery": mqtt.ha_autodiscovery, "ha_autodiscovery_topic": mqtt.ha_autodiscovery_topic, "client_id": mqtt.client_id, }) data = request.get_json() if not data: return jsonify({"error": "No data provided"}), 400 ok, err = validate_mqtt_config(data) if not ok: return jsonify({"error": err}), 400 if not config: return jsonify({"error": "No config loaded"}), 404 # Preserve existing password if masked password = data.get("password", "") if password == "***": password = config.mqtt.password new_mqtt = MqttConfig( host=data["host"].strip(), port=int(data.get("port", 1883)), user=data.get("user", ""), password=password, base_topic=data.get("base_topic", "hameter"), ha_autodiscovery=data.get("ha_autodiscovery", True), ha_autodiscovery_topic=data.get("ha_autodiscovery_topic", "homeassistant"), client_id=data.get("client_id", "hameter"), ) new_config = HaMeterConfig( general=config.general, mqtt=new_mqtt, meters=config.meters, ) try: save_config(new_config) except Exception as e: return jsonify({"error": f"Failed to save: {e}"}), 500 state.set_config(new_config) return jsonify({"ok": True, "restart_required": True}) @bp.route("/api/config/mqtt/test", methods=["POST"]) def api_mqtt_test(): data = request.get_json() if not data or not data.get("host"): return jsonify({"ok": False, "message": "Host is required"}), 400 try: import paho.mqtt.client as mqtt_lib from paho.mqtt.enums import CallbackAPIVersion except ImportError: return jsonify({"ok": False, "message": "MQTT library not available"}) connected = threading.Event() error_msg = "" def on_connect(client, userdata, flags, rc, properties=None): nonlocal error_msg if rc == 0: connected.set() else: error_msg = f"Connection refused (code {rc})" connected.set() client = mqtt_lib.Client( callback_api_version=CallbackAPIVersion.VERSION2, client_id="hameter-test", ) if data.get("user"): client.username_pw_set(data["user"], data.get("password", "")) client.on_connect = on_connect loop_started = False try: client.connect(data["host"], int(data.get("port", 1883))) client.loop_start() loop_started = True connected.wait(timeout=5) if error_msg: return jsonify({"ok": False, "message": error_msg}) if not connected.is_set(): return jsonify({"ok": False, "message": "Connection timed out (5s)"}) return jsonify({"ok": True, "message": "Connected successfully"}) except Exception as e: return jsonify({"ok": False, "message": str(e)}) finally: if loop_started: client.loop_stop() try: client.disconnect() except Exception: pass @bp.route("/api/config/meters", methods=["GET", "POST"]) def api_config_meters(): state = _state() config = state.config if request.method == "GET": if not config: return jsonify([]) return jsonify([ { "id": m.id, "protocol": m.protocol, "name": m.name, "unit_of_measurement": m.unit_of_measurement, "icon": m.icon, "device_class": m.device_class, "state_class": m.state_class, "multiplier": m.multiplier, "cost_factors": [ {"name": cf.name, "rate": cf.rate, "type": cf.type} for cf in m.cost_factors ], } for m in config.meters ]) # POST: add a new meter data = request.get_json() if not data: return jsonify({"error": "No data provided"}), 400 ok, err = validate_meter_config(data) if not ok: return jsonify({"error": err}), 400 if not config: return jsonify({"error": "No config loaded. Complete setup first."}), 404 # Check for duplicate ID meter_id = int(data["id"]) for m in config.meters: if m.id == meter_id: return jsonify({"error": f"Meter ID {meter_id} already exists"}), 409 device_class = data.get("device_class", "") defaults = get_meter_defaults(device_class) cost_factors, cf_err = _parse_cost_factors(data.get("cost_factors", [])) if cf_err: return jsonify({"error": cf_err}), 400 new_meter = MeterConfig( id=meter_id, protocol=data["protocol"].lower(), name=data["name"].strip(), unit_of_measurement=data.get("unit_of_measurement", "") or defaults.get("unit", ""), icon=data.get("icon", "") or defaults.get("icon", "mdi:gauge"), device_class=device_class, state_class=data.get("state_class", "total_increasing"), multiplier=float(data.get("multiplier", 1.0)), cost_factors=cost_factors, ) new_meters = list(config.meters) + [new_meter] new_config = HaMeterConfig( general=config.general, mqtt=config.mqtt, meters=new_meters, ) try: save_config(new_config) except Exception as e: return jsonify({"error": f"Failed to save: {e}"}), 500 state.set_config(new_config) return jsonify({"ok": True, "restart_required": True}) @bp.route("/api/config/meters/", methods=["PUT", "DELETE"]) def api_config_meter(meter_id): state = _state() config = state.config if not config: return jsonify({"error": "No config loaded"}), 404 if request.method == "DELETE": new_meters = [m for m in config.meters if m.id != meter_id] if len(new_meters) == len(config.meters): return jsonify({"error": f"Meter {meter_id} not found"}), 404 new_config = HaMeterConfig( general=config.general, mqtt=config.mqtt, meters=new_meters, ) try: save_config(new_config) except Exception as e: return jsonify({"error": f"Failed to save: {e}"}), 500 state.set_config(new_config) state.remove_cost_state(meter_id) _persist_cost_states(state) return jsonify({"ok": True, "restart_required": True}) # PUT: update meter data = request.get_json() if not data: return jsonify({"error": "No data provided"}), 400 # For update, use the URL meter_id data["id"] = meter_id ok, err = validate_meter_config(data) if not ok: return jsonify({"error": err}), 400 device_class = data.get("device_class", "") defaults = get_meter_defaults(device_class) cost_factors, cf_err = _parse_cost_factors(data.get("cost_factors", [])) if cf_err: return jsonify({"error": cf_err}), 400 updated = MeterConfig( id=meter_id, protocol=data["protocol"].lower(), name=data["name"].strip(), unit_of_measurement=data.get("unit_of_measurement", "") or defaults.get("unit", ""), icon=data.get("icon", "") or defaults.get("icon", "mdi:gauge"), device_class=device_class, state_class=data.get("state_class", "total_increasing"), multiplier=float(data.get("multiplier", 1.0)), cost_factors=cost_factors, ) new_meters = [] found = False for m in config.meters: if m.id == meter_id: new_meters.append(updated) found = True else: new_meters.append(m) if not found: return jsonify({"error": f"Meter {meter_id} not found"}), 404 new_config = HaMeterConfig( general=config.general, mqtt=config.mqtt, meters=new_meters, ) try: save_config(new_config) except Exception as e: return jsonify({"error": f"Failed to save: {e}"}), 500 state.set_config(new_config) if not cost_factors: state.remove_cost_state(meter_id) _persist_cost_states(state) return jsonify({"ok": True, "restart_required": True}) @bp.route("/api/config/general", methods=["GET", "POST"]) def api_config_general(): state = _state() config = state.config if request.method == "GET": if not config: return jsonify({"error": "No config loaded"}), 404 g = config.general return jsonify({ "sleep_for": g.sleep_for, "device_id": g.device_id, "rtl_tcp_host": g.rtl_tcp_host, "rtl_tcp_port": g.rtl_tcp_port, "log_level": g.log_level, "rtlamr_extra_args": " ".join(g.rtlamr_extra_args), }) data = request.get_json() if not data: return jsonify({"error": "No data provided"}), 400 if not config: return jsonify({"error": "No config loaded"}), 404 extra_args_str = data.get("rtlamr_extra_args", "") extra_args = extra_args_str.split() if isinstance(extra_args_str, str) and extra_args_str.strip() else [] try: rtl_tcp_port = int(data.get("rtl_tcp_port", 1234)) except (ValueError, TypeError): return jsonify({"error": "rtl_tcp_port must be a number"}), 400 if not (1 <= rtl_tcp_port <= 65535): return jsonify({"error": f"rtl_tcp_port must be 1-65535, got {rtl_tcp_port}"}), 400 log_level = str(data.get("log_level", "INFO")).upper() if log_level not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"): return jsonify({"error": f"Invalid log level: {log_level}"}), 400 device_id = str(data.get("device_id", "0")) try: if int(device_id) < 0: raise ValueError except (ValueError, TypeError): return jsonify({"error": f"device_id must be a non-negative integer, got '{device_id}'"}), 400 new_general = GeneralConfig( sleep_for=int(data.get("sleep_for", 0)), device_id=device_id, rtl_tcp_host=data.get("rtl_tcp_host", "127.0.0.1"), rtl_tcp_port=rtl_tcp_port, log_level=log_level, rtlamr_extra_args=extra_args, ) new_config = HaMeterConfig( general=new_general, mqtt=config.mqtt, meters=config.meters, ) try: save_config(new_config) except Exception as e: return jsonify({"error": f"Failed to save: {e}"}), 500 state.set_config(new_config) return jsonify({"ok": True, "restart_required": True}) @bp.route("/api/pipeline/restart", methods=["POST"]) def api_pipeline_restart(): state = _state() state.restart_requested.set() return jsonify({"ok": True}) @bp.route("/api/discovery/start", methods=["POST"]) def api_discovery_start(): state = _state() data = request.get_json() or {} try: duration = int(data.get("duration", 120)) except (ValueError, TypeError): return jsonify({"error": "Duration must be a number"}), 400 duration = max(10, min(duration, 600)) state.discovery_duration = duration state.clear_discovery_results() state.stop_discovery.clear() state.discovery_requested.set() return jsonify({"ok": True}) @bp.route("/api/discovery/stop", methods=["POST"]) def api_discovery_stop(): state = _state() state.stop_discovery.set() state.discovery_requested.clear() return jsonify({"ok": True}) @bp.route("/api/discovery/results") def api_discovery_results(): state = _state() results = state.get_discovery_results() config = state.config configured_ids = set() if config: configured_ids = {m.id for m in config.meters} out = [] for mid, info in sorted(results.items(), key=lambda x: -x[1]["count"]): out.append({ "meter_id": mid, "protocol": info["protocol"], "count": info["count"], "last_consumption": info["last_consumption"], "first_seen": info.get("first_seen", ""), "last_seen": info.get("last_seen", ""), "already_configured": mid in configured_ids, }) return jsonify(out) @bp.route("/api/discovery/add/", methods=["POST"]) def api_discovery_add(meter_id): state = _state() config = state.config if not config: return jsonify({"error": "No config loaded"}), 404 # Check not already configured for m in config.meters: if m.id == meter_id: return jsonify({"error": f"Meter {meter_id} already configured"}), 409 # Get discovery info results = state.get_discovery_results() info = results.get(meter_id) data = request.get_json() or {} protocol = data.get("protocol", info["protocol"] if info else "scm").lower() if protocol not in VALID_PROTOCOLS: return jsonify({ "error": f"Invalid protocol: {protocol}. " f"Valid: {', '.join(sorted(VALID_PROTOCOLS))}" }), 400 name = data.get("name", f"Meter {meter_id}") device_class = data.get("device_class", "") defaults = get_meter_defaults(device_class) cost_factors, cf_err = _parse_cost_factors(data.get("cost_factors", [])) if cf_err: return jsonify({"error": cf_err}), 400 new_meter = MeterConfig( id=meter_id, protocol=protocol, name=name, unit_of_measurement=data.get("unit_of_measurement", "") or defaults.get("unit", ""), icon=data.get("icon", "") or defaults.get("icon", "mdi:gauge"), device_class=device_class, state_class=data.get("state_class", "total_increasing"), multiplier=float(data.get("multiplier", 1.0)), cost_factors=cost_factors, ) new_meters = list(config.meters) + [new_meter] new_config = HaMeterConfig( general=config.general, mqtt=config.mqtt, meters=new_meters, ) try: save_config(new_config) except Exception as e: return jsonify({"error": f"Failed to save: {e}"}), 500 state.set_config(new_config) return jsonify({"ok": True, "restart_required": True}) @bp.route("/api/calibration/calculate", methods=["POST"]) def api_calibration_calculate(): data = request.get_json() if not data: return jsonify({"error": "No data provided"}), 400 try: raw = float(data["raw_reading"]) physical = float(data["physical_reading"]) except (KeyError, ValueError, TypeError) as e: return jsonify({"error": f"Invalid input: {e}"}), 400 if raw == 0: return jsonify({"error": "Raw reading cannot be zero"}), 400 multiplier = round(physical / raw, 6) return jsonify({ "multiplier": multiplier, "preview": round(raw * multiplier, 4), }) @bp.route("/api/calibration/apply", methods=["POST"]) def api_calibration_apply(): state = _state() config = state.config if not config: return jsonify({"error": "No config loaded"}), 404 data = request.get_json() if not data: return jsonify({"error": "No data provided"}), 400 try: meter_id = int(data["meter_id"]) multiplier = float(data["multiplier"]) except (KeyError, ValueError, TypeError) as e: return jsonify({"error": f"Invalid input: {e}"}), 400 new_meters = [] found = False for m in config.meters: if m.id == meter_id: new_meters.append(MeterConfig( id=m.id, protocol=m.protocol, name=m.name, unit_of_measurement=m.unit_of_measurement, icon=m.icon, device_class=m.device_class, state_class=m.state_class, multiplier=multiplier, cost_factors=m.cost_factors, )) found = True else: new_meters.append(m) if not found: return jsonify({"error": f"Meter {meter_id} not found"}), 404 new_config = HaMeterConfig( general=config.general, mqtt=config.mqtt, meters=new_meters, ) try: save_config(new_config) except Exception as e: return jsonify({"error": f"Failed to save: {e}"}), 500 state.set_config(new_config) state.clear_readings(meter_id) return jsonify({"ok": True, "restart_required": True}) @bp.route("/api/readings/clear", methods=["POST"]) def api_readings_clear(): state = _state() data = request.get_json() or {} meter_id = data.get("meter_id") if meter_id is not None: try: meter_id = int(meter_id) except (ValueError, TypeError): return jsonify({"error": "meter_id must be a number"}), 400 state.clear_readings(meter_id) return jsonify({"ok": True}) @bp.route("/api/logs") def api_logs(): state = _state() count = request.args.get("count", 200, type=int) logs = state.get_recent_logs(count) return jsonify(logs) @bp.route("/api/events") def api_events(): """SSE endpoint for live updates.""" state = _state() event = state.subscribe_sse() def stream(): try: # Send initial state yield _sse_format("status", { "status": state.status.value, "message": state.status_message, }) yield _sse_format("readings", _readings_dict(state)) yield _sse_format("costs", _costs_dict(state)) while True: # Wait for notification or timeout (keepalive every 15s) triggered = event.wait(timeout=15) if triggered: event.clear() try: # Send current state yield _sse_format("status", { "status": state.status.value, "message": state.status_message, }) yield _sse_format("readings", _readings_dict(state)) yield _sse_format("costs", _costs_dict(state)) # Send discovery if active if state.status == PipelineStatus.DISCOVERY: results = state.get_discovery_results() yield _sse_format("discovery", { str(k): v for k, v in results.items() }) # Send recent logs logs = state.get_recent_logs(10) if logs: yield _sse_format("logs", logs) except Exception: logger.exception("Error building SSE payload") else: # Keepalive yield ": keepalive\n\n" except GeneratorExit: pass finally: state.unsubscribe_sse(event) return Response( stream(), mimetype="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) @bp.route("/api/config/export") def api_config_export(): state = _state() config = state.config if not config: return jsonify({"error": "No config loaded"}), 404 data = config_to_dict(config) # Mask password in export if data.get("mqtt", {}).get("password"): data["mqtt"]["password"] = "***" return jsonify(data) @bp.route("/api/config/import", methods=["POST"]) def api_config_import(): state = _state() data = request.get_json() if not data: return jsonify({"error": "No data provided"}), 400 try: from hameter.config import _build_config_from_dict config = _build_config_from_dict(data) save_config(config) state.set_config(config) if not state.config_ready.is_set(): state.config_ready.set() return jsonify({"ok": True, "restart_required": True}) except Exception as e: return jsonify({"error": str(e)}), 400 @bp.route("/api/meter_defaults/") def api_meter_defaults(device_class): defaults = get_meter_defaults(device_class) return jsonify(defaults) @bp.route("/api/costs") def api_costs(): """Return cost state for all meters.""" state = _state() cost_states = state.get_cost_states() result = {} for mid, cs in cost_states.items(): result[str(mid)] = { "cumulative_cost": cs.cumulative_cost, "last_calibrated_reading": cs.last_calibrated_reading, "billing_period_start": cs.billing_period_start, "last_updated": cs.last_updated, "fixed_charges_applied": cs.fixed_charges_applied, } return jsonify(result) @bp.route("/api/costs//reset", methods=["POST"]) def api_cost_reset(meter_id): """Reset billing period for a meter.""" state = _state() timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat() state.reset_cost_state(meter_id, timestamp) # Persist to disk. _persist_cost_states(state) return jsonify({"ok": True, "billing_period_start": timestamp}) @bp.route("/api/costs//add-fixed", methods=["POST"]) def api_cost_add_fixed(meter_id): """Add fixed charges to a meter's cumulative cost.""" state = _state() config = state.config if not config: return jsonify({"error": "No config loaded"}), 404 # Find the meter config. meter_cfg = None for m in config.meters: if m.id == meter_id: meter_cfg = m break if meter_cfg is None: return jsonify({"error": f"Meter {meter_id} not found"}), 404 # Sum up fixed-type rate components. fixed_total = sum(cf.rate for cf in meter_cfg.cost_factors if cf.type == "fixed") if fixed_total == 0: return jsonify({"error": "No fixed charges configured for this meter"}), 400 timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat() state.add_fixed_charges(meter_id, fixed_total, timestamp) # Persist to disk. _persist_cost_states(state) cs = state.get_cost_state(meter_id) return jsonify({ "ok": True, "fixed_added": fixed_total, "cumulative_cost": cs.cumulative_cost if cs else 0.0, }) # ------------------------------------------------------------------ # # Helpers # ------------------------------------------------------------------ # def _sse_format(event_type: str, data) -> str: """Format a Server-Sent Event.""" return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" def _readings_dict(state: AppState) -> dict: """Build a JSON-safe dict of current readings with cost info.""" readings = state.get_last_readings() counts = state.get_reading_counts() cost_states = state.get_cost_states() result = {} for mid, reading in readings.items(): entry = { "meter_id": reading.meter_id, "protocol": reading.protocol, "raw_consumption": reading.raw_consumption, "calibrated_consumption": reading.calibrated_consumption, "timestamp": reading.timestamp, "count": counts.get(mid, 0), } cs = cost_states.get(mid) if cs: entry["cumulative_cost"] = cs.cumulative_cost entry["billing_period_start"] = cs.billing_period_start entry["fixed_charges_applied"] = cs.fixed_charges_applied result[str(mid)] = entry return result def _costs_dict(state: AppState) -> dict: """Build a JSON-safe dict of current cost states.""" cost_states = state.get_cost_states() result = {} for mid, cs in cost_states.items(): result[str(mid)] = { "cumulative_cost": cs.cumulative_cost, "billing_period_start": cs.billing_period_start, "fixed_charges_applied": cs.fixed_charges_applied, "last_updated": cs.last_updated, } return result def _parse_cost_factors(raw: list) -> tuple[list[RateComponent], str]: """Parse and validate cost_factors from request JSON. Returns (list_of_RateComponent, error_string). error_string is empty on success. """ if not raw: return [], "" factors = [] for i, item in enumerate(raw): ok, err = validate_rate_component(item) if not ok: return [], f"cost_factors[{i}]: {err}" factors.append(RateComponent( name=item["name"].strip(), rate=float(item["rate"]), type=item.get("type", "per_unit"), )) return factors, "" def _persist_cost_states(state: AppState): """Persist current cost states to disk.""" cost_states = state.get_cost_states() serialized = {str(mid): asdict(cs) for mid, cs in cost_states.items()} try: save_cost_state(serialized) except Exception: logger.exception("Failed to persist cost state")