1052 lines
31 KiB
Python
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")
|