Files
HAMeter/hameter/web/routes.py
2026-03-06 12:25:27 -05:00

1052 lines
31 KiB
Python

"""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/<int:meter_id>/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/<int:meter_id>", 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/<int:meter_id>", 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/<device_class>")
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/<int:meter_id>/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/<int:meter_id>/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")