From 744d4d0e07060991a9298dbf1d61bb06198633ba Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 23 Oct 2026 20:11:40 +0200 Subject: [PATCH 1/8] simpleAPI: add Python HTTP backend alongside PHP --- data/config/apache/000-default.conf | 10 +- data/config/apache/apache-openwb-ssl.conf | 10 +- data/config/apache/localhost.conf | 10 +- data/config/simpleAPI_mqtt_config.json | 16 +- runs/setup_apache2.sh | 6 +- simpleAPI/regression_check.py | 297 ++++++ simpleAPI/simpleAPI_mqtt.py | 577 ++++++++---- simpleAPI/src/authenticator.py | 119 +++ simpleAPI/src/http_server.py | 178 ++++ simpleAPI/src/parameter_handler.py | 1010 +++++++++++++++++++++ 10 files changed, 2036 insertions(+), 197 deletions(-) create mode 100644 simpleAPI/regression_check.py create mode 100644 simpleAPI/src/authenticator.py create mode 100644 simpleAPI/src/http_server.py create mode 100644 simpleAPI/src/parameter_handler.py diff --git a/data/config/apache/000-default.conf b/data/config/apache/000-default.conf index 4a16b41bec..e79502090c 100644 --- a/data/config/apache/000-default.conf +++ b/data/config/apache/000-default.conf @@ -1,4 +1,4 @@ -# openwb-version:6 +# openwb-version:7 # The ServerName directive sets the request scheme, hostname and port that # the server uses to identify itself. This is used when creating @@ -40,6 +40,14 @@ # Proxy WebSocket and MQTT connections to Mosquitto # ToDo: remove /mqtt target once all clients use /ws ProxyPassMatch "^/(ws|mqtt)(/|$)" "ws://127.0.0.1:9003/" + + # Proxy simpleAPI requests to the Python HTTP server in simpleAPI_mqtt.py + # (port from data/config/simpleAPI_mqtt_config.json's "http" section). + # Matched/proxied here before the request ever resolves to the on-disk + # simpleAPI/simpleapi.php, so the old PHP file stays unreferenced for + # this path while remaining in place for now. + ProxyPassMatch "^/openWB/simpleAPI(/|$)" "http://127.0.0.1:8099/" + ProxyPassReverse "/openWB/simpleAPI/" "http://127.0.0.1:8099/" # vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/data/config/apache/apache-openwb-ssl.conf b/data/config/apache/apache-openwb-ssl.conf index ddd95d2a1f..08cf64fce2 100644 --- a/data/config/apache/apache-openwb-ssl.conf +++ b/data/config/apache/apache-openwb-ssl.conf @@ -1,4 +1,4 @@ -# openwb-version:8 +# openwb-version:9 ServerAdmin webmaster@localhost @@ -143,6 +143,14 @@ # ToDo: remove /mqtt target once all clients use /ws ProxyPassMatch "^/(ws|mqtt)(/|$)" "ws://127.0.0.1:9003/" + # Proxy simpleAPI requests to the Python HTTP server in simpleAPI_mqtt.py + # (port from data/config/simpleAPI_mqtt_config.json's "http" section). + # Matched/proxied here before the request ever resolves to the on-disk + # simpleAPI/simpleapi.php, so the old PHP file stays unreferenced for + # this path while remaining in place for now. + ProxyPassMatch "^/openWB/simpleAPI(/|$)" "http://127.0.0.1:8099/" + ProxyPassReverse "/openWB/simpleAPI/" "http://127.0.0.1:8099/" + diff --git a/data/config/apache/localhost.conf b/data/config/apache/localhost.conf index 4d809119de..b8df8686ee 100644 --- a/data/config/apache/localhost.conf +++ b/data/config/apache/localhost.conf @@ -1,4 +1,4 @@ -# openwb-version:2 +# openwb-version:3 Listen 127.0.0.1:81 @@ -42,6 +42,14 @@ Listen 127.0.0.1:81 # Proxy WebSocket and MQTT connections to Mosquitto # ToDo: remove /mqtt target once all clients use /ws ProxyPassMatch "^/(ws|mqtt)(/|$)" "ws://127.0.0.1:9003/" + + # Proxy simpleAPI requests to the Python HTTP server in simpleAPI_mqtt.py + # (port from data/config/simpleAPI_mqtt_config.json's "http" section). + # Matched/proxied here before the request ever resolves to the on-disk + # simpleAPI/simpleapi.php, so the old PHP file stays unreferenced for + # this path while remaining in place for now. + ProxyPassMatch "^/openWB/simpleAPI(/|$)" "http://127.0.0.1:8099/" + ProxyPassReverse "/openWB/simpleAPI/" "http://127.0.0.1:8099/" # vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/data/config/simpleAPI_mqtt_config.json b/data/config/simpleAPI_mqtt_config.json index 5010dc36f7..b694d8be03 100644 --- a/data/config/simpleAPI_mqtt_config.json +++ b/data/config/simpleAPI_mqtt_config.json @@ -8,5 +8,19 @@ "qos": 0, "retain": true, "reconnect_delay": 10, - "log_level": "INFO" + "log_level": "INFO", + "http": { + "host": "127.0.0.1", + "port": 8099 + }, + "api": { + "cors_enabled": true + }, + "auth": { + "enabled": false, + "require_https": false, + "tokens": [], + "users": {} + }, + "debug": false } diff --git a/runs/setup_apache2.sh b/runs/setup_apache2.sh index d2f84bd4b6..a3560b7acb 100755 --- a/runs/setup_apache2.sh +++ b/runs/setup_apache2.sh @@ -74,6 +74,10 @@ echo "checking required apache modules..." enableModule headers enableModule ssl enableModule proxy_wstunnel +# needed for the simpleAPI ProxyPass(Match) to the local Python HTTP server +# (previously only enabled for the Pro+ branch below, which proxies to a +# remote Pro+ device over the same module) +enableModule proxy_http # default site (http and https) echo "apache default site..." @@ -138,12 +142,10 @@ if lsusb | grep -Eq 'RTL8153|AX88179'; then echo "second network for pro plus detected" # enable pro+ specific configurations enableSite apache-proplus - enableModule proxy_http else echo "no second network for pro plus detected" # disable all pro+ specific configurations disableSite apache-proplus - disableModule proxy_http fi # restart apache if required diff --git a/simpleAPI/regression_check.py b/simpleAPI/regression_check.py new file mode 100644 index 0000000000..e52c128faa --- /dev/null +++ b/simpleAPI/regression_check.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""simpleAPI regression harness: diffs the old PHP endpoint against the new +Python endpoint for every parameter in the whitelist, so the HTTP-contract +port can be verified parameter-by-parameter against a real device instead of +by inspection. + +Parameter names are imported directly from src/parameter_handler.py (the +single source of truth for the whitelist) rather than hand-copied here, so +the two can't drift apart silently. + +Typical usage, before Apache's ProxyPass for /openWB/simpleAPI/ is deployed +(hits the old PHP file through Apache as today, and the new daemon directly +on its local port): + + python3 simpleAPI/regression_check.py \\ + --old-base-url "https:///openWB/simpleAPI/simpleapi.php" \\ + --new-base-url "http://127.0.0.1:8099/" + +Once the ProxyPass is live, the public URL serves the NEW backend for both +"old" and "new" - at that point this script can only confirm the new backend +answers consistently, not provide an old-vs-new diff anymore. Do the real +old-vs-new comparison beforehand (e.g. via an SSH tunnel to the new backend's +local port, or by temporarily pointing --old-base-url at a second device still +running the PHP version). + +Write parameters are skipped by default - see --include-writes. +""" +import argparse +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import requests + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from src.parameter_handler import ( # noqa: E402 + READ_PARAMETER_ORDER, + WRITE_PARAMETER_ORDER, + is_chargepoint_parameter, + is_io_parameter, +) + +# Fields whose name marks them as point-in-time event/publish data rather than +# live telemetry - these are expected to legitimately differ between two +# separate HTTP requests (even back-to-back, even against the same backend) +# and are excluded from the diff. Everything else (power, voltages, soc, +# fault_state, ...) is compared exactly, since both endpoints read the same +# retained MQTT state and should agree. +TIMESTAMP_FIELD_NAMES = {'timestamp', 'time'} +TIMESTAMP_FIELD_SUFFIX = '_timestamp' + +# Conservative values for write parameters under --include-writes: chosen to +# be a no-op-ish or easily-reversible state change, not a disruptive one +# (e.g. chargepoint_lock=false rather than true, bat_power_reserve=0 rather +# than some nonzero reservation). 'vehicle' and 'set_io_output' are left out +# deliberately - see --vehicle-id/--io-output-name. +DEFAULT_WRITE_VALUES = { + 'set_chargemode': 'pv', + 'chargecurrent': '6', + 'minimal_pv_soc': '50', + 'minimal_permanent_current': '6', + 'max_price_eco': '10', + 'chargepoint_lock': 'false', + 'bat_mode': 'bat_mode', + 'bat_power_reserve': '0', + 'instant_charging_limit': 'none', + 'instant_charging_amount': '10', + 'instant_charging_soc': '80', + 'pv_charging_limit': 'none', + 'pv_charging_amount': '10', + 'pv_charging_soc': '80', + 'manual_soc': '50', +} + + +def is_timestamp_field(key: str) -> bool: + return key in TIMESTAMP_FIELD_NAMES or key.endswith(TIMESTAMP_FIELD_SUFFIX) + + +def deep_diff(old: Any, new: Any, path: str, float_tolerance: float, diffs: List[str]) -> None: + if isinstance(old, dict) and isinstance(new, dict): + for key in sorted(set(old) | set(new)): + if is_timestamp_field(key): + continue + if key not in old: + diffs.append(f"{path}.{key}: missing in OLD, NEW={new[key]!r}") + elif key not in new: + diffs.append(f"{path}.{key}: OLD={old[key]!r}, missing in NEW") + else: + deep_diff(old[key], new[key], f"{path}.{key}", float_tolerance, diffs) + return + + if isinstance(old, list) and isinstance(new, list): + if len(old) != len(new): + diffs.append(f"{path}: length OLD={len(old)} NEW={len(new)}") + for i, (o, n) in enumerate(zip(old, new)): + deep_diff(o, n, f"{path}[{i}]", float_tolerance, diffs) + return + + is_old_num = isinstance(old, (int, float)) and not isinstance(old, bool) + is_new_num = isinstance(new, (int, float)) and not isinstance(new, bool) + if is_old_num and is_new_num: + if abs(float(old) - float(new)) > float_tolerance: + diffs.append(f"{path}: OLD={old!r} NEW={new!r}") + return + + if old != new: + diffs.append(f"{path}: OLD={old!r} NEW={new!r}") + + +def fetch(base_url: str, query: Dict[str, Any], timeout: float) -> Tuple[Optional[int], Any, Optional[str]]: + try: + resp = requests.get(base_url, params=query, timeout=timeout) + except requests.RequestException as exc: + return None, None, str(exc) + try: + return resp.status_code, resp.json(), None + except ValueError: + return resp.status_code, resp.text, None + + +def compare(old_base: str, new_base: str, query: Dict[str, Any], path: str, + float_tolerance: float, timeout: float) -> Tuple[Optional[bool], List[str]]: + old_status, old_body, old_err = fetch(old_base, query, timeout) + new_status, new_body, new_err = fetch(new_base, query, timeout) + + if old_err or new_err: + return False, [f"request failed: OLD error={old_err!r} NEW error={new_err!r}"] + if old_status != new_status: + return False, [f"HTTP status: OLD={old_status} NEW={new_status}"] + + diffs: List[str] = [] + deep_diff(old_body, new_body, path, float_tolerance, diffs) + return (len(diffs) == 0), diffs + + +def selected(param: str, only: Optional[List[str]], skip: List[str]) -> bool: + if skip and param in skip: + return False + if only: + return param in only + return True + + +def build_common_params(args: argparse.Namespace) -> Dict[str, Any]: + params: Dict[str, Any] = {} + for item in args.param: + key, _, value = item.partition('=') + params[key] = value + if args.token: + params['token'] = args.token + if args.username is not None: + params['username'] = args.username + if args.password is not None: + params['password'] = args.password + return params + + +def check_read_param(param: str, args: argparse.Namespace, common: Dict[str, Any]) -> Tuple[Optional[bool], List[str]]: + query = {**common, param: args.id} + return compare(args.old_base_url, args.new_base_url, query, param, args.float_tolerance, args.timeout) + + +def check_list_components(args: argparse.Namespace, common: Dict[str, Any]) -> Tuple[Optional[bool], List[str]]: + query = {**common, 'list_components': 'true'} + return compare(args.old_base_url, args.new_base_url, query, 'list_components', args.float_tolerance, args.timeout) + + +def check_write_param(param: str, args: argparse.Namespace, common: Dict[str, Any]) -> Tuple[Optional[bool], List[str]]: + if param == 'vehicle': + if not args.vehicle_id: + return None, ["skipped: pass --vehicle-id to exercise this parameter"] + value: Any = args.vehicle_id + elif param == 'set_io_output': + if not args.io_output_name: + return None, ["skipped: pass --io-output-name (and --io-output-value) to exercise this parameter"] + value = args.io_output_value + else: + value = DEFAULT_WRITE_VALUES[param] + + query = {**common, param: value} + if is_chargepoint_parameter(param): + query['chargepoint_nr'] = args.chargepoint_nr + if is_io_parameter(param): + query['io_nr'] = args.io_nr + if param == 'set_io_output': + query['io_output'] = args.io_output_name + + return compare(args.old_base_url, args.new_base_url, query, param, args.float_tolerance, args.timeout) + + +def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + '--old-base-url', required=True, + help="old PHP simpleAPI endpoint, e.g. https:///openWB/simpleAPI/simpleapi.php") + parser.add_argument( + '--new-base-url', required=True, + help="new Python endpoint, e.g. http://127.0.0.1:8099/ " + "(or the public URL once Apache's ProxyPass is live)") + parser.add_argument( + '--id', default='auto', + help="device id used for every read parameter (default: 'auto', resolves to the lowest known id " + "of the matching component type on each side)") + parser.add_argument('--token', help="bare 'token' auth parameter sent with every request") + parser.add_argument('--username') + parser.add_argument('--password') + parser.add_argument( + '--param', action='append', default=[], metavar='KEY=VALUE', + help="extra query parameter sent with every request (repeatable), e.g. --param debug=true") + parser.add_argument('--only', nargs='+', metavar='PARAM', help="only check these exact parameter names") + parser.add_argument('--skip', nargs='+', metavar='PARAM', default=[], help="skip these parameter names") + parser.add_argument( + '--include-writes', action='store_true', + help="also exercise write parameters - performs REAL write operations against whatever device " + "--old-base-url/--new-base-url point at") + parser.add_argument('--chargepoint-nr', default='auto', help="chargepoint_nr for write-parameter checks") + parser.add_argument('--io-nr', default='auto', help="io_nr for write-parameter checks") + parser.add_argument( + '--io-output-name', + help="io_output name required to exercise set_io_output under --include-writes; omitted -> " + "set_io_output is skipped even with --include-writes") + parser.add_argument('--io-output-value', default='0', help="value to write for set_io_output (default: 0)") + parser.add_argument( + '--vehicle-id', + help="vehicle id required to exercise the 'vehicle' write parameter under --include-writes; " + "omitted -> 'vehicle' is skipped even with --include-writes") + parser.add_argument( + '--float-tolerance', type=float, default=0.0, + help="allowed absolute difference for numeric leaves, to absorb live power/voltage jitter between " + "the two near-simultaneous requests (default: 0.0, exact match)") + parser.add_argument('--timeout', type=float, default=10.0, help="per-request HTTP timeout in seconds") + parser.add_argument('--max-diff-lines', type=int, default=20, help="diff lines printed per failing parameter") + return parser.parse_args(argv) + + +def main(argv: Optional[List[str]] = None) -> int: + args = parse_args(argv) + common = build_common_params(args) + + read_params = [p for p in READ_PARAMETER_ORDER if selected(p, args.only, args.skip)] + write_params = [p for p in WRITE_PARAMETER_ORDER if selected(p, args.only, args.skip)] + + print("simpleAPI regression check") + print(f" OLD (PHP): {args.old_base_url}") + print(f" NEW (Python): {args.new_base_url}") + print(f" read parameters: {len(read_params)}") + + if args.include_writes: + print() + print("WARNING: --include-writes performs REAL write operations (chargemode, chargecurrent,") + print("bat_mode, lock state, io outputs, ...) against whatever device both base URLs point at.") + print("Only run this against a non-production test device. Starting in 3 seconds...") + print(f" write parameters: {len(write_params)}") + time.sleep(3) + print() + + results: List[Tuple[str, Optional[bool], List[str]]] = [] + + if selected('list_components', args.only, args.skip): + ok, diffs = check_list_components(args, common) + results.append(('list_components', ok, diffs)) + + for param in read_params: + ok, diffs = check_read_param(param, args, common) + results.append((param, ok, diffs)) + + if args.include_writes: + for param in write_params: + ok, diffs = check_write_param(param, args, common) + results.append((param, ok, diffs)) + + name_width = max((len(name) for name, _, _ in results), default=10) + 2 + print(f"{'PARAMETER':<{name_width}}RESULT") + print('-' * (name_width + 6)) + for name, ok, diffs in results: + status = 'PASS' if ok is True else 'SKIP' if ok is None else 'FAIL' + print(f"{name:<{name_width}}{status}") + if ok is False: + for line in diffs[:args.max_diff_lines]: + print(f" {line}") + if len(diffs) > args.max_diff_lines: + print(f" ... {len(diffs) - args.max_diff_lines} more diff line(s)") + + passed = sum(1 for _, ok, _ in results if ok is True) + failed = sum(1 for _, ok, _ in results if ok is False) + skipped = sum(1 for _, ok, _ in results if ok is None) + print() + print(f"{passed} passed, {failed} failed, {skipped} skipped (of {len(results)} checked)") + + return 1 if failed else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/simpleAPI/simpleAPI_mqtt.py b/simpleAPI/simpleAPI_mqtt.py index 821b3a3b60..366c7c6883 100755 --- a/simpleAPI/simpleAPI_mqtt.py +++ b/simpleAPI/simpleAPI_mqtt.py @@ -14,11 +14,14 @@ import sys import ssl import math -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Set, Tuple from pathlib import Path import paho.mqtt.client as mqtt import re +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from src.http_server import build_http_server # noqa: E402 + FORMAT_STR_SHORT = '%(asctime)s - %(message)s' RAMDISK_PATH = str(Path(__file__).resolve().parents[1]) + '/ramdisk/' @@ -44,9 +47,23 @@ def __init__(self, config_file: str): # Cache for tracking lowest IDs per component type self.lowest_ids: Dict[str, int] = {} + # Cache of every known ID per component type (used for list_components + # and as the basis for lowest_ids) + self.all_ids: Dict[str, Set[int]] = {} + # Cache for storing current charge_template configurations self.charge_template_cache: Dict[str, Dict[str, Any]] = {} + # Raw per-topic cache (parsed payloads) backing the simpleAPI HTTP read + # parameters - distinct from value_cache, which holds the already + # transformed/simplified simpleAPI output topic tree, not the + # original openWB topics the HTTP read handlers need to mirror. + self.raw_value_cache: Dict[str, Any] = {} + + # Raw JSON text of openWB/graph/lastlivevaluesJson, served 1:1 by + # get_lastlivevaluesjson + self.last_live_values_json: Optional[str] = None + # Track per-ID component values for efficient total topic aggregation self.total_metrics = { 'pv': {'exported', 'monthly_exported', 'yearly_exported', 'imported', 'power'}, @@ -67,6 +84,8 @@ def __init__(self, config_file: str): self.client.on_message = self._on_message self.client.on_disconnect = self._on_disconnect + self.http_server = build_http_server(self, self.config) + def _load_config_file(self, config_file: str): """Load configuration from JSON file.""" try: @@ -95,6 +114,8 @@ def _load_config_file(self, config_file: str): numeric_level = getattr(logging, log_level.upper(), logging.INFO) log.setLevel(numeric_level) + self.config = config + log.info(f"Loaded configuration from {config_file}") except json.JSONDecodeError as e: log.error(f"Invalid JSON in config file {config_file}: {e}") @@ -117,12 +138,14 @@ def _on_connect(self, client, userdata, flags, rc): client.subscribe("openWB/chargepoint/#", qos=0) client.subscribe("openWB/counter/#", qos=0) client.subscribe("openWB/consumer/#", qos=0) + client.subscribe("openWB/io/#", qos=0) + client.subscribe("openWB/graph/lastlivevaluesJson", qos=0) # Subscribe to simpleAPI set topics for write operations client.subscribe("openWB/simpleAPI/set/#", qos=0) log.info( - "Subscribed to openWB component topics (bat, pv, chargepoint, counter, consumer) " + "Subscribed to openWB component topics (bat, pv, chargepoint, counter, consumer, io) " "and simpleAPI set topics") else: log.error(f"Failed to connect to MQTT broker. Return code: {rc}") @@ -174,6 +197,10 @@ def _on_message(self, client, userdata, msg): if '/simpleAPI/' in topic: return + if topic == 'openWB/graph/lastlivevaluesJson': + self.last_live_values_json = payload + return + # Cache charge_template configurations for later use if '/set/charge_template' in topic: self._cache_charge_template(topic, payload) @@ -181,6 +208,9 @@ def _on_message(self, client, userdata, msg): # Parse payload once so we can reuse it for both total aggregation and normal transformation parsed_payload = self._parse_payload(payload) + # Raw per-topic cache backing the simpleAPI HTTP read parameters + self.raw_value_cache[topic] = parsed_payload + # Update aggregated total topics from component ID metrics self._update_total_topics(topic, parsed_payload) @@ -416,19 +446,23 @@ def _transform_chargepoint_topic(self, simple_base: str) -> Optional[str]: return simple_base def _track_component_ids(self, topic: str): - """Track component IDs to determine lowest IDs.""" - # Pattern to match topics with numeric IDs - pattern = r'openWB/(\w+)/(\d+)/' - match = re.match(pattern, topic) - + """Track component IDs to determine lowest/all known IDs per type.""" + match = re.match(r'openWB/(\w+)/(\d+)/', topic) if match: - component_type = match.group(1) - component_id = int(match.group(2)) + self._register_component_id(match.group(1), int(match.group(2))) + return - if component_type not in self.lowest_ids: - self.lowest_ids[component_type] = component_id - else: - self.lowest_ids[component_type] = min(self.lowest_ids[component_type], component_id) + # io IDs sit one level deeper (openWB/io/states//...) + io_match = re.match(r'openWB/io/states/(\d+)/', topic) + if io_match: + self._register_component_id('io', int(io_match.group(1))) + + def _register_component_id(self, component_type: str, component_id: int): + self.all_ids.setdefault(component_type, set()).add(component_id) + if component_type not in self.lowest_ids: + self.lowest_ids[component_type] = component_id + else: + self.lowest_ids[component_type] = min(self.lowest_ids[component_type], component_id) def _expand_value_to_topics(self, base_topic: str, value: Any, result: Dict[str, Any]): """Expand complex values (JSON, tuples) into individual topics.""" @@ -640,34 +674,34 @@ def _handle_write_operation(self, topic: str, payload: str): # Check for instant_charging_limit operations first (can be with or without chargepoint ID) if 'instant_charging_limit_soc' in topic_remainder: - success = self._handle_instant_charging_limit_soc_operation(payload) + success, _ = self._handle_instant_charging_limit_soc_operation(payload) if success: self._clear_set_topic(topic) return elif 'instant_charging_limit_amount' in topic_remainder: - success = self._handle_instant_charging_limit_amount_operation(payload) + success, _ = self._handle_instant_charging_limit_amount_operation(payload) if success: self._clear_set_topic(topic) return elif 'instant_charging_limit' in topic_remainder: - success = self._handle_instant_charging_limit_operation(payload) + success, _ = self._handle_instant_charging_limit_operation(payload) if success: self._clear_set_topic(topic) return # Check for pv_charging_limit operations first (can be with or without chargepoint ID) if 'pv_charging_limit_soc' in topic_remainder: - success = self._handle_pv_charging_limit_soc_operation(payload) + success, _ = self._handle_pv_charging_limit_soc_operation(payload) if success: self._clear_set_topic(topic) return elif 'pv_charging_limit_amount' in topic_remainder: - success = self._handle_pv_charging_limit_amount_operation(payload) + success, _ = self._handle_pv_charging_limit_amount_operation(payload) if success: self._clear_set_topic(topic) return elif 'pv_charging_limit' in topic_remainder: - success = self._handle_pv_charging_limit_operation(payload) + success, _ = self._handle_pv_charging_limit_operation(payload) if success: self._clear_set_topic(topic) return @@ -685,11 +719,11 @@ def _handle_write_operation(self, topic: str, payload: str): if success: self._clear_set_topic(topic) elif operation == 'bat_mode': - success = self._handle_bat_mode_operation(payload) + success, _ = self._handle_bat_mode_operation(payload) if success: self._clear_set_topic(topic) elif operation == 'bat_power_reserve': - success = self._handle_bat_power_reserve_operation(payload) + success, _ = self._handle_bat_power_reserve_operation(payload) if success: self._clear_set_topic(topic) else: @@ -728,22 +762,22 @@ def _handle_chargepoint_operation(self, topic_parts: list, payload: str) -> bool log.debug(f"Charge point operation: ID={chargepoint_id}, parameter={parameter}, value={payload}") if parameter == 'chargemode': - self._set_chargemode(chargepoint_id, payload) + success, _ = self._set_chargemode(chargepoint_id, payload) elif parameter == 'chargecurrent': - self._set_chargecurrent(chargepoint_id, payload) + success, _ = self._set_chargecurrent(chargepoint_id, payload) elif parameter == 'minimal_pv_soc': - self._set_minimal_pv_soc(chargepoint_id, payload) + success, _ = self._set_minimal_pv_soc(chargepoint_id, payload) elif parameter == 'minimal_permanent_current': - self._set_minimal_permanent_current(chargepoint_id, payload) + success, _ = self._set_minimal_permanent_current(chargepoint_id, payload) elif parameter == 'max_price_eco': - self._set_max_price_eco(chargepoint_id, payload) + success, _ = self._set_max_price_eco(chargepoint_id, payload) elif parameter == 'chargepoint_lock': - self._set_chargepoint_lock(chargepoint_id, payload) + success, _ = self._set_chargepoint_lock(chargepoint_id, payload) else: log.error(f"Unknown chargepoint parameter: {parameter}") return False - return True + return success def _get_charge_template(self, chargepoint_id: str) -> Optional[Dict[str, Any]]: """Get charge_template for a chargepoint, either from cache or request it.""" @@ -753,7 +787,7 @@ def _get_charge_template(self, chargepoint_id: str) -> Optional[Dict[str, Any]]: log.warning(f"No cached charge_template for chargepoint {chargepoint_id}") return None - def _set_chargemode(self, chargepoint_id: str, mode: str): + def _set_chargemode(self, chargepoint_id: str, mode: str) -> Tuple[bool, str]: """Set chargemode for a chargepoint.""" # Mapping from simple values to internal values mode_mapping = { @@ -770,15 +804,17 @@ def _set_chargemode(self, chargepoint_id: str, mode: str): } if mode not in mode_mapping: - log.error(f"Invalid chargemode: {mode}") - return + message = f"Invalid chargemode: {mode}" + log.error(message) + return False, message internal_mode = mode_mapping[mode] charge_template = self._get_charge_template(chargepoint_id) if charge_template is None: - log.error(f"No charge_template available for chargepoint {chargepoint_id}") - return + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message # Modify the chargemode.selected value charge_template['chargemode']['selected'] = internal_mode @@ -787,105 +823,125 @@ def _set_chargemode(self, chargepoint_id: str, mode: str): target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) log.info(f"Set chargemode to {mode} ({internal_mode}) for chargepoint {chargepoint_id}") + return True, f"Chargemode set to {mode} ({internal_mode})" - def _set_chargecurrent(self, chargepoint_id: str, current: str): + def _set_chargecurrent(self, chargepoint_id: str, current: str) -> Tuple[bool, str]: """Set charge current for instant charging.""" try: current_value = int(current) - charge_template = self._get_charge_template(chargepoint_id) - - if charge_template is None: - return + except ValueError: + message = f"Invalid current value: {current}" + log.error(message) + return False, message - charge_template['chargemode']['instant_charging']['current'] = current_value + charge_template = self._get_charge_template(chargepoint_id) + if charge_template is None: + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) - log.info(f"Set charge current to {current_value}A for chargepoint {chargepoint_id}") + charge_template['chargemode']['instant_charging']['current'] = current_value - except ValueError: - log.error(f"Invalid current value: {current}") + target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" + self._publish_json(target_topic, charge_template) + log.info(f"Set charge current to {current_value}A for chargepoint {chargepoint_id}") + return True, f"Chargecurrent set to {current_value}A for chargepoint {chargepoint_id}" - def _set_minimal_pv_soc(self, chargepoint_id: str, soc: str): + def _set_minimal_pv_soc(self, chargepoint_id: str, soc: str) -> Tuple[bool, str]: """Set minimal EV SoC for PV charging.""" try: soc_value = int(soc) - charge_template = self._get_charge_template(chargepoint_id) - - if charge_template is None: - return + except ValueError: + message = f"Invalid SoC value: {soc}" + log.error(message) + return False, message - charge_template['chargemode']['pv_charging']['min_soc'] = soc_value + charge_template = self._get_charge_template(chargepoint_id) + if charge_template is None: + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) - log.info(f"Set minimal PV SoC to {soc_value}% for chargepoint {chargepoint_id}") + charge_template['chargemode']['pv_charging']['min_soc'] = soc_value - except ValueError: - log.error(f"Invalid SoC value: {soc}") + target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" + self._publish_json(target_topic, charge_template) + log.info(f"Set minimal PV SoC to {soc_value}% for chargepoint {chargepoint_id}") + return True, f"Minimal PV SoC set to {soc_value}% for chargepoint {chargepoint_id}" - def _set_minimal_permanent_current(self, chargepoint_id: str, current: str): + def _set_minimal_permanent_current(self, chargepoint_id: str, current: str) -> Tuple[bool, str]: """Set minimal permanent current for PV charging.""" try: current_value = int(current) - charge_template = self._get_charge_template(chargepoint_id) - - if charge_template is None: - return + except ValueError: + message = f"Invalid current value: {current}" + log.error(message) + return False, message - charge_template['chargemode']['pv_charging']['min_current'] = current_value + charge_template = self._get_charge_template(chargepoint_id) + if charge_template is None: + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) - log.info(f"Set minimal permanent current to {current_value}A for chargepoint {chargepoint_id}") + charge_template['chargemode']['pv_charging']['min_current'] = current_value - except ValueError: - log.error(f"Invalid current value: {current}") + target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" + self._publish_json(target_topic, charge_template) + log.info(f"Set minimal permanent current to {current_value}A for chargepoint {chargepoint_id}") + return True, f"Minimal permanent current set to {current_value}A for chargepoint {chargepoint_id}" - def _set_max_price_eco(self, chargepoint_id: str, price: str): + def _set_max_price_eco(self, chargepoint_id: str, price: str) -> Tuple[bool, str]: """Set maximum price for ECO charging.""" try: price_value = float(price) / 100000 - charge_template = self._get_charge_template(chargepoint_id) - - if charge_template is None: - return + except ValueError: + message = f"Invalid price value: {price}" + log.error(message) + return False, message - charge_template['chargemode']['eco_charging']['max_price'] = price_value + charge_template = self._get_charge_template(chargepoint_id) + if charge_template is None: + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) - log.info(f"Set max ECO price to {price_value} for chargepoint {chargepoint_id}") + charge_template['chargemode']['eco_charging']['max_price'] = price_value - except ValueError: - log.error(f"Invalid price value: {price}") + target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" + self._publish_json(target_topic, charge_template) + log.info(f"Set max ECO price to {price_value} for chargepoint {chargepoint_id}") + return True, f"Max price eco set to {price_value} for chargepoint {chargepoint_id}" - def _set_chargepoint_lock(self, chargepoint_id: str, lock_state: str): + def _set_chargepoint_lock(self, chargepoint_id: str, lock_state: str) -> Tuple[bool, str]: """Set chargepoint lock state.""" try: - lock_value = lock_state.lower() in ('true', '1', 'yes', 'on') + lock_value = str(lock_state).lower() in ('true', '1', 'yes', 'on') target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/manual_lock" self.client.publish(target_topic, str(lock_value).lower(), qos=0, retain=True) log.info(f"Set chargepoint {chargepoint_id} lock to {lock_value}") - + return True, f"Chargepoint lock set to {lock_value} for chargepoint {chargepoint_id}" except Exception as e: - log.error(f"Error setting chargepoint lock: {e}") + message = f"Error setting chargepoint lock: {e}" + log.error(message) + return False, message - def _handle_bat_mode_operation(self, payload: str) -> bool: + def _handle_bat_mode_operation(self, payload: str) -> Tuple[bool, str]: """Handle battery mode operation.""" valid_modes = ['min_soc_bat_mode', 'ev_mode', 'bat_mode'] if payload not in valid_modes: - log.error(f"Invalid bat_mode: {payload}. Valid values: {valid_modes}") - return False + message = f"Invalid bat_mode: {payload}. Valid values: {valid_modes}" + log.error(message) + return False, message target_topic = "openWB/set/general/chargemode_config/bat/mode" self.client.publish(target_topic, payload, qos=0, retain=True) log.info(f"Set bat_mode to {payload}") - return True + return True, f"Bat mode set to {payload}" - def _handle_bat_power_reserve_operation(self, payload: str) -> bool: + def _handle_bat_power_reserve_operation(self, payload: str) -> Tuple[bool, str]: """Handle battery power reserve operation. Sets the power (in Watts) that openWB reserves for the battery @@ -897,215 +953,233 @@ def _handle_bat_power_reserve_operation(self, payload: str) -> bool: try: watts = int(payload) except ValueError: - log.error(f"Invalid bat_power_reserve value: {payload!r}. Must be an integer (Watts).") - return False + message = f"Invalid bat_power_reserve value: {payload!r}. Must be an integer (Watts)." + log.error(message) + return False, message if watts < 0: - log.error(f"Invalid bat_power_reserve value: {watts}. Must be >= 0.") - return False + message = f"Invalid bat_power_reserve value: {watts}. Must be >= 0." + log.error(message) + return False, message target_topic = "openWB/set/general/chargemode_config/bat/power_reserve" self.client.publish(target_topic, watts, qos=0, retain=True) log.info(f"Set bat_power_reserve to {watts}W") - return True + return True, f"bat_power_reserve set to {watts}W" - def _handle_instant_charging_limit_operation(self, payload: str) -> bool: + def _resolve_chargepoint_id(self, chargepoint_id: Optional[str]) -> Optional[str]: + """Use the given chargepoint ID, or fall back to the lowest known one.""" + if chargepoint_id is not None: + return str(chargepoint_id) + if 'chargepoint' in self.lowest_ids: + return str(self.lowest_ids['chargepoint']) + return None + + def _handle_instant_charging_limit_operation( + self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: """Handle instant charging limit type operation.""" valid_limits = ['none', 'soc', 'amount'] if payload not in valid_limits: - log.error(f"Invalid instant_charging_limit: {payload}. Valid values: {valid_limits}") - return False + message = f"Invalid instant_charging_limit: {payload}. Valid values: {valid_limits}" + log.error(message) + return False, message - # Get chargepoint ID (use lowest if not specified) - if 'chargepoint' in self.lowest_ids: - chargepoint_id = str(self.lowest_ids['chargepoint']) - else: - log.error("No chargepoint ID found for instant_charging_limit operation") - return False + chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) + if chargepoint_id is None: + message = "No chargepoint ID found for instant_charging_limit operation" + log.error(message) + return False, message charge_template = self._get_charge_template(chargepoint_id) if charge_template is None: - log.error(f"No charge_template available for chargepoint {chargepoint_id}") - return False + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - # Modify the instant_charging.limit.selected value charge_template['chargemode']['instant_charging']['limit']['selected'] = payload - # Publish the modified template target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) log.info(f"Set instant_charging_limit to {payload} for chargepoint {chargepoint_id}") - return True + return True, f"Instant charging limit set to {payload} for chargepoint {chargepoint_id}" - def _handle_instant_charging_limit_soc_operation(self, payload: str) -> bool: + def _handle_instant_charging_limit_soc_operation( + self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: """Handle instant charging limit SoC operation.""" try: soc_value = int(payload) if soc_value < 0 or soc_value > 100: - log.error(f"Invalid SoC value: {soc_value}. Must be between 0 and 100") - return False + message = f"Invalid SoC value: {soc_value}. Must be between 0 and 100" + log.error(message) + return False, message except ValueError: - log.error(f"Invalid SoC value: {payload}. Must be an integer") - return False + message = f"Invalid SoC value: {payload}. Must be an integer" + log.error(message) + return False, message - # Get chargepoint ID (use lowest if not specified) - if 'chargepoint' in self.lowest_ids: - chargepoint_id = str(self.lowest_ids['chargepoint']) - else: - log.error("No chargepoint ID found for instant_charging_limit_soc operation") - return False + chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) + if chargepoint_id is None: + message = "No chargepoint ID found for instant_charging_limit_soc operation" + log.error(message) + return False, message charge_template = self._get_charge_template(chargepoint_id) if charge_template is None: - log.error(f"No charge_template available for chargepoint {chargepoint_id}") - return False + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - # Modify the instant_charging.limit.soc value charge_template['chargemode']['instant_charging']['limit']['soc'] = soc_value - # Publish the modified template target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) log.info(f"Set instant_charging_limit_soc to {soc_value}% for chargepoint {chargepoint_id}") - return True + return True, f"Instant charging SoC set to {soc_value}% for chargepoint {chargepoint_id}" - def _handle_instant_charging_limit_amount_operation(self, payload: str) -> bool: + def _handle_instant_charging_limit_amount_operation( + self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: """Handle instant charging limit amount operation.""" try: amount_value = int(payload) if amount_value < 1 or amount_value > 50: - log.error(f"Invalid amount value: {amount_value}. Must be between 1 and 50") - return False - - # Convert to internal value (multiply by 1000) + message = f"Invalid amount value: {amount_value}. Must be between 1 and 50" + log.error(message) + return False, message internal_amount = amount_value * 1000 except ValueError: - log.error(f"Invalid amount value: {payload}. Must be an integer") - return False + message = f"Invalid amount value: {payload}. Must be an integer" + log.error(message) + return False, message - # Get chargepoint ID (use lowest if not specified) - if 'chargepoint' in self.lowest_ids: - chargepoint_id = str(self.lowest_ids['chargepoint']) - else: - log.error("No chargepoint ID found for instant_charging_limit_amount operation") - return False + chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) + if chargepoint_id is None: + message = "No chargepoint ID found for instant_charging_limit_amount operation" + log.error(message) + return False, message charge_template = self._get_charge_template(chargepoint_id) if charge_template is None: - log.error(f"No charge_template available for chargepoint {chargepoint_id}") - return False + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - # Modify the instant_charging.limit.amount value charge_template['chargemode']['instant_charging']['limit']['amount'] = internal_amount - # Publish the modified template target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) log.info( f"Set instant_charging_limit_amount to {amount_value} kWh " f"({internal_amount} Wh) for chargepoint {chargepoint_id}" ) - return True + message = ( + f"Instant charging amount set to {amount_value}kWh ({internal_amount}Wh) " + f"for chargepoint {chargepoint_id}" + ) + return True, message - def _handle_pv_charging_limit_operation(self, payload: str) -> bool: + def _handle_pv_charging_limit_operation( + self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: """Handle PV charging limit type operation.""" valid_limits = ['none', 'soc', 'amount'] if payload not in valid_limits: - log.error(f"Invalid pv_charging_limit: {payload}. Valid values: {valid_limits}") - return False + message = f"Invalid pv_charging_limit: {payload}. Valid values: {valid_limits}" + log.error(message) + return False, message - # Get chargepoint ID (use lowest if not specified) - if 'chargepoint' in self.lowest_ids: - chargepoint_id = str(self.lowest_ids['chargepoint']) - else: - log.error("No chargepoint ID found for pv_charging_limit operation") - return False + chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) + if chargepoint_id is None: + message = "No chargepoint ID found for pv_charging_limit operation" + log.error(message) + return False, message charge_template = self._get_charge_template(chargepoint_id) if charge_template is None: - log.error(f"No charge_template available for chargepoint {chargepoint_id}") - return False + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - # Modify the pv_charging.limit.selected value charge_template['chargemode']['pv_charging']['limit']['selected'] = payload - # Publish the modified template target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) log.info(f"Set pv_charging_limit to {payload} for chargepoint {chargepoint_id}") - return True + return True, f"PV charging limit set to {payload} for chargepoint {chargepoint_id}" - def _handle_pv_charging_limit_soc_operation(self, payload: str) -> bool: + def _handle_pv_charging_limit_soc_operation( + self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: """Handle PV charging limit SoC operation.""" try: soc_value = int(payload) if soc_value < 0 or soc_value > 100: - log.error(f"Invalid SoC value: {soc_value}. Must be between 0 and 100") - return False + message = f"Invalid SoC value: {soc_value}. Must be between 0 and 100" + log.error(message) + return False, message except ValueError: - log.error(f"Invalid SoC value: {payload}. Must be an integer") - return False + message = f"Invalid SoC value: {payload}. Must be an integer" + log.error(message) + return False, message - # Get chargepoint ID (use lowest if not specified) - if 'chargepoint' in self.lowest_ids: - chargepoint_id = str(self.lowest_ids['chargepoint']) - else: - log.error("No chargepoint ID found for pv_charging_limit_soc operation") - return False + chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) + if chargepoint_id is None: + message = "No chargepoint ID found for pv_charging_limit_soc operation" + log.error(message) + return False, message charge_template = self._get_charge_template(chargepoint_id) if charge_template is None: - log.error(f"No charge_template available for chargepoint {chargepoint_id}") - return False + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - # Modify the pv_charging.limit.soc value charge_template['chargemode']['pv_charging']['limit']['soc'] = soc_value - # Publish the modified template target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) log.info(f"Set pv_charging_limit_soc to {soc_value}% for chargepoint {chargepoint_id}") - return True + return True, f"PV charging SoC set to {soc_value}% for chargepoint {chargepoint_id}" - def _handle_pv_charging_limit_amount_operation(self, payload: str) -> bool: + def _handle_pv_charging_limit_amount_operation( + self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: """Handle PV charging limit amount operation.""" try: amount_value = int(payload) if amount_value < 1 or amount_value > 50: - log.error(f"Invalid amount value: {amount_value}. Must be between 1 and 50") - return False - - # Convert to internal value (multiply by 1000) + message = f"Invalid amount value: {amount_value}. Must be between 1 and 50" + log.error(message) + return False, message internal_amount = amount_value * 1000 except ValueError: - log.error(f"Invalid amount value: {payload}. Must be an integer") - return False + message = f"Invalid amount value: {payload}. Must be an integer" + log.error(message) + return False, message - # Get chargepoint ID (use lowest if not specified) - if 'chargepoint' in self.lowest_ids: - chargepoint_id = str(self.lowest_ids['chargepoint']) - else: - log.error("No chargepoint ID found for pv_charging_limit_amount operation") - return False + chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) + if chargepoint_id is None: + message = "No chargepoint ID found for pv_charging_limit_amount operation" + log.error(message) + return False, message charge_template = self._get_charge_template(chargepoint_id) if charge_template is None: - log.error(f"No charge_template available for chargepoint {chargepoint_id}") - return False + message = f"No charge_template available for chargepoint {chargepoint_id}" + log.error(message) + return False, message - # Modify the pv_charging.limit.amount value charge_template['chargemode']['pv_charging']['limit']['amount'] = internal_amount - # Publish the modified template target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) log.info( f"Set pv_charging_limit_amount to {amount_value} kWh " f"({internal_amount} Wh) for chargepoint {chargepoint_id}" ) - return True + message = ( + f"PV charging amount set to {amount_value}kWh ({internal_amount}Wh) " + f"for chargepoint {chargepoint_id}" + ) + return True, message def _publish_json(self, topic: str, data: Dict[str, Any]): """Publish JSON data to a topic.""" @@ -1126,6 +1200,121 @@ def _publish_revision(self): except Exception as e: log.error(f"Failed to publish revision: {e}") + def _set_vehicle(self, chargepoint_id: str, value: str) -> Tuple[bool, str]: + """Assign a vehicle to a chargepoint.""" + try: + vehicle_id = int(value) + except ValueError: + message = f"Invalid vehicle ID: {value}" + log.error(message) + return False, message + + if vehicle_id < 0: + message = "Vehicle ID must be >= 0" + return False, message + + topic = f"openWB/set/chargepoint/{chargepoint_id}/config/ev" + self.client.publish(topic, str(vehicle_id), qos=0, retain=True) + log.info(f"Assigned vehicle {vehicle_id} to chargepoint {chargepoint_id}") + return True, f"Vehicle {vehicle_id} assigned to chargepoint {chargepoint_id}" + + def _set_manual_soc(self, chargepoint_id: str, value: str) -> Tuple[bool, str]: + """Set the manually entered SoC for the vehicle assigned to a chargepoint.""" + try: + soc = int(value) + if soc < 0 or soc > 100: + message = f"Manual SoC must be between 0 and 100, got {soc}" + return False, message + except ValueError: + message = f"Invalid SoC value: {value}" + log.error(message) + return False, message + + config = self.raw_value_cache.get(f"openWB/chargepoint/{chargepoint_id}/config") + vehicle_id = config.get('ev') if isinstance(config, dict) else None + if vehicle_id is None: + message = f"No vehicle assigned to chargepoint {chargepoint_id}" + log.error(message) + return False, message + + topic = f"openWB/set/vehicle/{vehicle_id}/soc_module/calculated_soc_state/manual_soc" + self.client.publish(topic, str(soc), qos=0, retain=True) + log.info(f"Set manual SoC to {soc}% for vehicle {vehicle_id} (chargepoint {chargepoint_id})") + return True, f"Manual SoC set to {soc}% for vehicle {vehicle_id} (chargepoint {chargepoint_id})" + + def _get_io_output_map(self, io_id: str, output_type: str) -> Dict[str, Any]: + value = self.raw_value_cache.get(f"openWB/io/states/{io_id}/get/{output_type}") + return value if isinstance(value, dict) else {} + + @staticmethod + def _normalize_io_output_type(output_type: Optional[str]) -> Optional[str]: + if output_type is None or str(output_type).strip() == '': + return None + value = str(output_type).strip().lower() + if value in ('digital', 'digital_output'): + return 'digital_output' + if value in ('analog', 'analog_output'): + return 'analog_output' + return None + + @staticmethod + def _parse_io_output_value(value: Any): + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return float(value) + normalized = str(value).strip().lower() + if normalized in ('true', 'on', 'yes'): + return True + if normalized in ('false', 'off', 'no'): + return False + try: + return float(normalized) + except ValueError: + return value + + def _set_io_output( + self, io_id: Optional[str], value: str, output_name: Optional[str], + output_type: Optional[str] = None) -> Tuple[bool, str]: + """Manually set a digital or analog IO output.""" + if io_id is None or str(io_id) == '': + return False, "Missing io_nr" + if output_name is None or str(output_name).strip() == '': + return False, "Missing io_output" + + output_name = str(output_name).strip() + normalized_type = self._normalize_io_output_type(output_type) + parsed_value = self._parse_io_output_value(value) + + if normalized_type is None: + digital = self._get_io_output_map(io_id, 'digital_output') + analog = self._get_io_output_map(io_id, 'analog_output') + if output_name in digital: + normalized_type = 'digital_output' + elif output_name in analog: + normalized_type = 'analog_output' + elif isinstance(parsed_value, bool): + normalized_type = 'digital_output' + else: + normalized_type = 'analog_output' + + if normalized_type == 'digital_output': + if isinstance(parsed_value, bool): + payload = 'true' if parsed_value else 'false' + elif isinstance(parsed_value, (int, float)) and float(parsed_value) == 0.0: + payload = 'false' + elif isinstance(parsed_value, (int, float)) and float(parsed_value) == 1.0: + payload = 'true' + else: + return False, "Digital output expects boolean or 0/1" + else: + payload = '1' if parsed_value is True else '0' if parsed_value is False else str(parsed_value) + + topic = f"openWB/set/system/io/{io_id}/set/manual/{normalized_type}/{output_name}" + self.client.publish(topic, payload, qos=0, retain=True) + log.info(f"Set {normalized_type}/{output_name} to {payload} for io {io_id}") + return True, f"Set {normalized_type}/{output_name} to {payload} for io {io_id}" + def connect(self): """Establish connection to MQTT broker.""" try: @@ -1150,18 +1339,24 @@ def connect(self): return False def run(self): - """Start the daemon main loop.""" + """Start the MQTT client's own thread and run the HTTP server on the main thread.""" if not self.connect(): sys.exit(1) - log.info("SimpleMQTT API Daemon started") + self.client.loop_start() + log.info( + f"SimpleMQTT API Daemon started, HTTP server listening on " + f"{self.http_server.server_address[0]}:{self.http_server.server_address[1]}" + ) try: - self.client.loop_forever() + self.http_server.serve_forever() except KeyboardInterrupt: log.info("Received interrupt signal, shutting down...") + finally: + self.http_server.shutdown() + self.client.loop_stop() self.client.disconnect() - sys.exit(0) def main(): diff --git a/simpleAPI/src/authenticator.py b/simpleAPI/src/authenticator.py new file mode 100644 index 0000000000..5b6f050c09 --- /dev/null +++ b/simpleAPI/src/authenticator.py @@ -0,0 +1,119 @@ +"""Four-tier authentication for the simpleAPI HTTP server. + +Precedence: bearer token, local username/password, bare token parameter, +then a fallback that delegates to the MQTT broker itself - if the broker +accepts anonymous connections, simpleAPI is effectively open too. +""" +import hmac +import logging +import time +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional + +import paho.mqtt.client as mqtt +from passlib.hash import bcrypt + +log = logging.getLogger("simpleAPI") + + +@dataclass +class AuthConfig: + enabled: bool = False + require_https: bool = False + tokens: List[str] = field(default_factory=list) + users: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: Dict) -> "AuthConfig": + return cls( + enabled=data.get('enabled', False), + require_https=data.get('require_https', False), + tokens=list(data.get('tokens', [])), + users=dict(data.get('users', {})), + ) + + +def test_mqtt_credentials( + host: str, port: int, use_tls: bool, validate_cert: bool, + username: str, password: str, timeout: float = 3.0) -> bool: + """Probe the broker with a short-lived connection instead of shelling out to + mosquitto_sub. An empty username/password tests anonymous access.""" + outcome: Dict[str, Optional[int]] = {'rc': None} + + def on_connect(client, userdata, flags, rc): + outcome['rc'] = rc + + client = mqtt.Client() + client.on_connect = on_connect + if use_tls: + client.tls_set(cert_reqs=__import__('ssl').CERT_REQUIRED) + if not validate_cert: + client.tls_insecure_set(True) + if username: + client.username_pw_set(username, password) + + try: + client.connect(host, port, keepalive=max(int(timeout), 1)) + except Exception as exc: + log.debug(f"MQTT credential probe failed to connect: {exc}") + return False + + client.loop_start() + deadline = time.monotonic() + timeout + try: + while outcome['rc'] is None and time.monotonic() < deadline: + time.sleep(0.05) + finally: + client.loop_stop() + try: + client.disconnect() + except Exception: + pass + + return outcome['rc'] == 0 + + +class Authenticator: + def __init__(self, config: AuthConfig, mqtt_params: Dict, + credential_tester: Callable[..., bool] = test_mqtt_credentials): + self.config = config + self.mqtt_params = mqtt_params + self._credential_tester = credential_tester + + def authenticate(self, params: Dict[str, str], bearer_token: Optional[str], is_https: bool) -> bool: + if not self.config.enabled: + return True + + if bearer_token and self._validate_token(bearer_token): + return True + + username = params.get('username') + password = params.get('password') + if username is not None and password is not None: + if (is_https or not self.config.require_https) and self._validate_credentials(username, password): + return True + + token = params.get('token') + if token is not None: + if (is_https or not self.config.require_https) and self._validate_token(token): + return True + + return self._test_broker_anonymous() + + def _validate_token(self, token: str) -> bool: + return any(hmac.compare_digest(valid, token) for valid in self.config.tokens) + + def _validate_credentials(self, username: str, password: str) -> bool: + users = self.config.users + if users and username in users: + stored = users[username] + if stored.startswith('$'): + try: + return bcrypt.verify(password, stored) + except ValueError: + return False + return hmac.compare_digest(stored, password) + return self._credential_tester(username=username, password=password, **self.mqtt_params) + + def _test_broker_anonymous(self) -> bool: + return self._credential_tester(username='', password='', **self.mqtt_params) diff --git a/simpleAPI/src/http_server.py b/simpleAPI/src/http_server.py new file mode 100644 index 0000000000..58f18a408d --- /dev/null +++ b/simpleAPI/src/http_server.py @@ -0,0 +1,178 @@ +"""HTTP surface for simpleAPI: request parsing, auth, and the read/write/raw +response contract that third-party integrations (Home Assistant, node-red) +already rely on.""" +import json +import logging +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Dict +from urllib.parse import parse_qs, urlsplit + +from .authenticator import AuthConfig, Authenticator +from .parameter_handler import ( + ParameterHandler, + dispatch_read, + dispatch_write, + extract_read_params, + extract_write_params, + format_raw_output, + COMPLEX_PARAMETERS, +) + +log = logging.getLogger("simpleAPI") + + +def _php_stringify(value: Any) -> str: + """Approximates PHP's echo-time string conversion for raw-mode output + (bool True/False -> "1"/"", None -> "", arrays -> "Array").""" + if value is None: + return '' + if isinstance(value, bool): + return '1' if value else '' + if isinstance(value, (dict, list)): + return 'Array' + return str(value) + + +def _single_param_value(params: Dict[str, list]) -> Dict[str, str]: + return {k: (v[-1] if v else '') for k, v in params.items()} + + +class SimpleAPIRequestHandler(BaseHTTPRequestHandler): + server_version = "openWB-simpleAPI/1.0" + + def log_message(self, fmt, *args): + log.debug(f"{self.address_string()} - {fmt % args}") + + def _is_https(self) -> bool: + return self.headers.get('X-Forwarded-Proto', '').lower() == 'https' + + def _bearer_token(self): + auth_header = self.headers.get('Authorization') + if not auth_header: + return None + prefix = 'bearer ' + if auth_header.lower().startswith(prefix): + return auth_header[len(prefix):].strip() + return None + + def _send(self, status: int, payload: Any, raw: bool = False): + if raw: + body = payload if isinstance(payload, (bytes, bytearray)) else _php_stringify(payload).encode('utf-8') + else: + body = json.dumps(payload).encode('utf-8') + + self.send_response(status) + self.send_header('Content-Type', 'application/json') + if self.server.api_config.get('cors_enabled', False): + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self): + self._send(200, '', raw=True) + + def do_GET(self): + params = _single_param_value(parse_qs(urlsplit(self.path).query, keep_blank_values=True)) + self._handle(params) + + def do_POST(self): + length = int(self.headers.get('Content-Length') or 0) + body = self.rfile.read(length) if length else b'' + post_params = _single_param_value(parse_qs(body.decode('utf-8'), keep_blank_values=True)) + get_params = _single_param_value(parse_qs(urlsplit(self.path).query, keep_blank_values=True)) + merged = {**get_params, **post_params} + self._handle(merged) + + def _handle(self, params: Dict[str, str]): + debug = bool(self.server.config.get('debug', False)) or params.get('debug') == 'true' + try: + if not self.server.authenticator.authenticate(params, self._bearer_token(), self._is_https()): + self._send(401, {'success': False, 'message': 'Authentication failed'}) + return + + if 'list_components' in params: + self._send(200, self.server.parameter_handler.list_components()) + return + + write_params = extract_write_params(params) + if write_params: + result = dispatch_write(self.server.parameter_handler, write_params, params) + if params.get('raw') == 'true': + text = 'OK' if result['success'] else f"ERROR: {result.get('message', 'Unknown error')}" + self._send(200, text, raw=True) + else: + self._send(200, result) + return + + read_params = extract_read_params(params) + if read_params: + result, debug_info = dispatch_read(self.server.parameter_handler, read_params, params, debug) + if debug and debug_info: + result = {'debug_info': debug_info, **result} + + if params.get('raw') == 'true': + if len(read_params) > 1: + self._send(400, { + 'success': False, + 'message': 'Raw output (raw=true) is only allowed for single parameter requests', + 'error': 'Multiple parameters detected: ' + ', '.join(read_params.keys()), + }) + return + param_name = next(iter(read_params)) + if param_name in COMPLEX_PARAMETERS: + self._send(400, { + 'success': False, + 'message': 'Raw output (raw=true) is not supported for complex parameters', + 'error': f"Parameter '{param_name}' returns multiple values. " + "Use specific single-value parameters instead.", + 'suggestion': 'Try parameters like get_counter_power, get_counter_voltage_p1, etc.', + }) + return + self._send(200, format_raw_output(result, param_name), raw=True) + else: + if len(read_params) == 1 and next(iter(read_params)) == 'get_lastlivevaluesjson': + self._send(200, format_raw_output(result, 'get_lastlivevaluesjson'), raw=True) + else: + self._send(200, result) + return + + self._send(400, {'success': False, 'message': 'No valid parameters provided'}) + except Exception as exc: + log.exception("Unhandled error processing simpleAPI request") + self._send(500, { + 'success': False, + 'message': 'Internal server error', + 'error': str(exc) if debug else 'An error occurred', + }) + + +class SimpleAPIServer(ThreadingHTTPServer): + def __init__(self, server_address, daemon, parameter_handler: ParameterHandler, + authenticator: Authenticator, config: Dict): + super().__init__(server_address, SimpleAPIRequestHandler) + self.daemon_ref = daemon + self.parameter_handler = parameter_handler + self.authenticator = authenticator + self.config = config + self.api_config = config.get('api', {}) + + +def build_http_server(daemon, config: Dict) -> SimpleAPIServer: + http_config = config.get('http', {}) + host = http_config.get('host', '127.0.0.1') + port = http_config.get('port', 8099) + + auth_config = AuthConfig.from_dict(config.get('auth', {})) + mqtt_params = { + 'host': config['host'], + 'port': config['port'], + 'use_tls': config['use_tls'], + 'validate_cert': config.get('validate_cert', False), + } + authenticator = Authenticator(auth_config, mqtt_params) + parameter_handler = ParameterHandler(daemon) + + return SimpleAPIServer((host, port), daemon, parameter_handler, authenticator, config) diff --git a/simpleAPI/src/parameter_handler.py b/simpleAPI/src/parameter_handler.py new file mode 100644 index 0000000000..86f88d2bc8 --- /dev/null +++ b/simpleAPI/src/parameter_handler.py @@ -0,0 +1,1010 @@ +"""Read/write parameter dispatch for the simpleAPI HTTP server. + +Mirrors the parameter whitelist, auto-ID resolution and response-shape rules +of the HTTP dispatch layer, and reads every value straight out of the running +daemon's caches - no MQTT round-trip is made per request. +""" +from typing import Any, Callable, Dict, List, Optional, Tuple + +# Write parameters, in the order they are evaluated when several are present +# in one request (the first one in this order determines the response's +# top-level success message and target-id fields). +WRITE_PARAMETER_ORDER: List[str] = [ + 'set_chargemode', + 'chargecurrent', + 'minimal_pv_soc', + 'minimal_permanent_current', + 'max_price_eco', + 'chargepoint_lock', + 'bat_mode', + 'bat_power_reserve', + 'instant_charging_limit', + 'instant_charging_amount', + 'instant_charging_soc', + 'pv_charging_limit', + 'pv_charging_amount', + 'pv_charging_soc', + 'vehicle', + 'manual_soc', + 'set_io_output', +] + +# bat_mode/bat_power_reserve are global settings, not chargepoint-scoped - they +# deliberately stay out of this set so they skip auto-ID resolution and leave +# chargepoint_nr null in the write response, instead of failing when no +# chargepoint is known yet. +CHARGEPOINT_WRITE_PARAMS = { + 'set_chargemode', 'chargecurrent', 'minimal_pv_soc', 'minimal_permanent_current', + 'max_price_eco', 'chargepoint_lock', + 'instant_charging_limit', 'instant_charging_amount', 'instant_charging_soc', + 'pv_charging_limit', 'pv_charging_amount', 'pv_charging_soc', 'vehicle', 'manual_soc', +} + +# Full read whitelist (what counts as a recognized read parameter). Only the +# chargepoint entries and get_lastlivevaluesjson have a handler in this phase; +# the rest (counter/battery/pv/consumer/io) are recognized but return nothing +# until a later phase adds their handlers. +READ_PARAMETER_ORDER: List[str] = [ + 'get_chargepoint_all', + 'get_chargepoint_voltage_p1', 'get_chargepoint_voltage_p2', 'get_chargepoint_voltage_p3', + 'get_chargepoint_voltages', + 'get_chargepoint_current_p1', 'get_chargepoint_current_p2', 'get_chargepoint_current_p3', + 'get_chargepoint_currents', + 'get_chargepoint_power', 'get_chargepoint_powers', + 'get_chargepoint_imported', 'get_chargepoint_exported', + 'get_chargepoint_daily_imported', 'get_chargepoint_daily_exported', + 'get_chargepoint_monthly_exported', 'get_chargepoint_yearly_exported', + 'get_chargepoint_frequency', 'get_chargepoint_rfid', 'get_chargepoint_rfid_timestamp', + 'get_chargepoint_evse_current', + 'get_chargepoint_power_factors', + 'get_chargepoint_power_factor_p1', 'get_chargepoint_power_factor_p2', 'get_chargepoint_power_factor_p3', + 'get_chargepoint_config_name', 'get_chargepoint_connected_vehicle_name', + 'get_chargepoint_charge_template_name', 'get_chargepoint_charge_template_min_current', + 'get_chargepoint_instant_charging_current', 'get_chargepoint_pv_charging_min_current', + 'get_chargepoint_soc', 'get_chargepoint_state_str', 'get_chargepoint_fault_str', + 'get_chargepoint_fault_state', 'get_chargepoint_phases_in_use', 'get_chargepoint_plug_state', + 'get_chargepoint_charge_state', 'get_chargepoint_chargemode', + 'get_counter', + 'get_counter_voltage_p1', 'get_counter_voltage_p2', 'get_counter_voltage_p3', 'get_counter_voltages', + 'get_counter_current_p1', 'get_counter_current_p2', 'get_counter_current_p3', 'get_counter_currents', + 'get_counter_power', 'get_counter_powers', 'get_counter_power_factors', + 'get_counter_imported', 'get_counter_exported', + 'get_counter_daily_imported', 'get_counter_daily_exported', + 'get_counter_frequency', 'get_counter_fault_str', 'get_counter_fault_state', + 'battery', 'get_battery', + 'get_battery_power', 'get_battery_soc', 'get_battery_currents', + 'get_battery_imported', 'get_battery_exported', + 'get_battery_daily_imported', 'get_battery_daily_exported', + 'get_battery_fault_str', 'get_battery_fault_state', 'get_battery_power_limit_controllable', + 'pv', 'get_pv', + 'get_pv_power', 'get_pv_currents', 'get_pv_imported', 'get_pv_exported', + 'get_pv_daily_exported', 'get_pv_monthly_exported', 'get_pv_yearly_exported', + 'get_pv_fault_str', 'get_pv_fault_state', + 'consumer', 'get_consumer', 'get_consumer_all', + 'get_consumer_power', 'get_consumer_currents', 'get_consumer_voltages', 'get_consumer_powers', + 'get_consumer_imported', 'get_consumer_exported', 'get_consumer_daily_imported', + 'get_consumer_phases_in_use', 'get_consumer_charge_state', 'get_consumer_state_str', + 'get_consumer_fault_str', 'get_consumer_fault_state', 'get_consumer_usage_type', + 'get_io_output_all', 'get_io_output', + 'get_lastlivevaluesjson', +] + +COMPLEX_PARAMETERS = { + 'get_chargepoint_all', 'get_counter', 'battery', 'get_battery', 'pv', 'get_pv', + 'get_chargepoint_voltages', 'get_chargepoint_currents', 'get_chargepoint_powers', + 'get_chargepoint_power_factors', + 'get_counter_voltages', 'get_counter_currents', 'get_counter_powers', 'get_counter_power_factors', + 'get_battery_currents', 'get_pv_currents', 'get_io_output_all', + 'get_consumer_all', 'get_consumer', 'consumer', + 'get_consumer_currents', 'get_consumer_voltages', 'get_consumer_powers', +} + +TYPE_PUBLIC_NAMES = {'bat': 'battery'} +SCAN_TYPES = ['chargepoint', 'bat', 'pv', 'counter', 'consumer', 'io'] + + +def is_chargepoint_parameter(param: str) -> bool: + return param in CHARGEPOINT_WRITE_PARAMS or 'chargepoint' in param + + +def is_io_parameter(param: str) -> bool: + return param == 'set_io_output' or param.startswith('get_io_') + + +def get_type_from_parameter(param: str) -> str: + if 'chargepoint' in param: + return 'chargepoint' + if 'io' in param: + return 'io' + if 'battery' in param: + return 'bat' + if 'pv' in param: + return 'pv' + if 'counter' in param: + return 'counter' + if 'consumer' in param: + return 'consumer' + return 'chargepoint' + + +def get_success_message(param: str, value: Any, chargepoint_id: Any) -> str: + if param == 'set_chargemode': + return f"Chargemode for chargepoint {chargepoint_id} set to {value}." + if param == 'chargecurrent': + return f"Chargecurrent for chargepoint {chargepoint_id} set to {value}A" + if param == 'instant_charging_limit': + return f"Instant charging limit for chargepoint {chargepoint_id} set to {value}." + if param == 'instant_charging_amount': + return f"Instant charging amount for chargepoint {chargepoint_id} set to {value}kWh." + if param == 'instant_charging_soc': + return f"Instant charging SoC for chargepoint {chargepoint_id} set to {value}%." + if param == 'pv_charging_limit': + return f"PV charging limit for chargepoint {chargepoint_id} set to {value}." + if param == 'pv_charging_amount': + return f"PV charging amount for chargepoint {chargepoint_id} set to {value}kWh." + if param == 'pv_charging_soc': + return f"PV charging SoC for chargepoint {chargepoint_id} set to {value}%." + if param == 'vehicle': + return f"Vehicle {value} assigned to chargepoint {chargepoint_id}." + if param == 'manual_soc': + return f"Manual SoC set to {value}% for chargepoint {chargepoint_id}." + if param == 'set_io_output': + return f"IO output set to {value} for io {chargepoint_id}." + return f"Parameter {param} set to {value}." + + +def format_raw_output(data: Any, param_name: Optional[str] = None) -> Any: + if isinstance(data, dict) and data: + first_key = next(iter(data)) + first_value = data[first_key] + if param_name == 'get_lastlivevaluesjson' and isinstance(first_value, str): + return first_value + if isinstance(first_value, str): + return first_value + if isinstance(first_value, dict) and len(first_value) == 1: + return next(iter(first_value.values())) + return first_value + return data + + +def _as_float(value: Any, default: float = 0.0) -> float: + try: + return float(default) if value is None else float(value) + except (TypeError, ValueError): + return float(default) + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(default) if value is None else int(value) + except (TypeError, ValueError): + return int(default) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ('true', '1', 'yes', 'on') + return bool(value) + + +def _as_str(value: Any, default: Any = '') -> Any: + if value is None: + return default + return value if isinstance(value, str) else str(value) + + +def _phases(value: Any) -> List[float]: + if not isinstance(value, (list, tuple)): + return [0.0, 0.0, 0.0] + return [_as_float(value[i]) if i < len(value) else 0.0 for i in range(3)] + + +def _is_total(id_: Any) -> bool: + return isinstance(id_, str) and id_.strip().lower() == 'total' + + +def _raw(daemon: Any, topic: str) -> Any: + return daemon.raw_value_cache.get(topic) + + +def _scalar(id_: Any, field: str, value: Any) -> Dict[str, Any]: + return {f"chargepoint_{id_}": {field: value}} + + +def _get_chargepoint_all(daemon: Any, id_: Any) -> Dict[str, Any]: + prefix = f"openWB/chargepoint/{id_}/get/" + + template = daemon.charge_template_cache.get(str(id_)) or {} + chargemode_cfg = template.get('chargemode', {}) or {} + chargemode = chargemode_cfg.get('selected', 'stop') + instant_charging = chargemode_cfg.get('instant_charging', {}) or {} + pv_charging = chargemode_cfg.get('pv_charging', {}) or {} + eco_charging = chargemode_cfg.get('eco_charging', {}) or {} + scheduled_charging = chargemode_cfg.get('scheduled_charging', {}) or {} + instant_limit = instant_charging.get('limit', {}) or {} + pv_limit = pv_charging.get('limit', {}) or {} + + instant_charging_current = _as_float(instant_charging.get('current', 0)) + pv_charging_min_current = _as_float(pv_charging.get('min_current', 0)) + + if chargemode == 'instant_charging': + min_current = instant_charging_current + elif chargemode in ('pv_charging', 'eco_charging'): + min_current = pv_charging_min_current + elif chargemode == 'scheduled_charging': + min_current = _as_float(scheduled_charging.get('current', 0)) + else: + min_current = 0.0 + + connected_vehicle_soc = _raw(daemon, prefix + 'connected_vehicle/soc') or {} + connected_vehicle_info = _raw(daemon, prefix + 'connected_vehicle/info') or {} + config = _raw(daemon, f"openWB/chargepoint/{id_}/config") or {} + + return { + f"chargepoint_{id_}": { + 'power': _as_float(_raw(daemon, prefix + 'power')), + 'voltages': _phases(_raw(daemon, prefix + 'voltages')), + 'currents': _phases(_raw(daemon, prefix + 'currents')), + 'powers': _phases(_raw(daemon, prefix + 'powers')), + 'state_str': _as_str(_raw(daemon, prefix + 'state_str'), 'Unbekannt'), + 'fault_str': _as_str(_raw(daemon, prefix + 'fault_str'), 'Kein Fehler'), + 'fault_state': _as_int(_raw(daemon, prefix + 'fault_state')), + 'imported': _as_float(_raw(daemon, prefix + 'imported')), + 'exported': _as_float(_raw(daemon, prefix + 'exported')), + 'daily_imported': _as_float(_raw(daemon, prefix + 'daily_imported')), + 'daily_exported': _as_float(_raw(daemon, prefix + 'daily_exported')), + 'phases_in_use': _as_int(_raw(daemon, prefix + 'phases_in_use'), 1), + 'plug_state': _as_bool(_raw(daemon, prefix + 'plug_state')), + 'charge_state': _as_bool(_raw(daemon, prefix + 'charge_state')), + 'pro_soc': _as_float(_raw(daemon, prefix + 'soc')), + 'soc_timestamp': _raw(daemon, prefix + 'soc_timestamp'), + 'vehicle_id': _raw(daemon, prefix + 'vehicle_id'), + 'evse_current': _as_float(_raw(daemon, prefix + 'evse_current')), + 'frequency': _as_float(_raw(daemon, prefix + 'frequency')), + 'power_factors': _phases(_raw(daemon, prefix + 'power_factors')), + 'rfid': _as_str(_raw(daemon, prefix + 'rfid'), None), + 'rfid_timestamp': _raw(daemon, prefix + 'rfid_timestamp'), + 'config_name': config.get('name'), + 'connected_vehicle_name': connected_vehicle_info.get('name'), + 'charge_template_name': template.get('name'), + 'min_current': min_current, + 'instant_charging_current': instant_charging_current, + 'pv_charging_min_current': pv_charging_min_current, + 'instant_charging_limit': instant_limit.get('selected', 'none'), + 'instant_charging_amount': _as_int(instant_limit.get('amount', 0)), + 'instant_charging_soc': _as_int(instant_limit.get('soc', 0)), + 'pv_charging_limit': pv_limit.get('selected', 'none'), + 'pv_charging_amount': _as_int(pv_limit.get('amount', 0)), + 'pv_charging_soc': _as_int(pv_limit.get('soc', 0)), + 'max_price_eco': _as_float(eco_charging.get('max_price', 0)) * 100000, + 'soc': _as_float(connected_vehicle_soc.get('soc', 0)), + 'range_charged': _as_float(connected_vehicle_soc.get('range_charged', 0)), + 'chargemode': chargemode, + 'manual_lock': _as_bool(_raw(daemon, f"openWB/chargepoint/{id_}/set/manual_lock")), + } + } + + +def _get_chargepoint_voltage(daemon, id_, phase): + return _scalar(id_, f'voltage_p{phase}', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/voltages"))[phase - 1]) + + +def _get_chargepoint_voltages(daemon, id_): + return _scalar(id_, 'voltages', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/voltages"))) + + +def _get_chargepoint_current(daemon, id_, phase): + return _scalar(id_, f'current_p{phase}', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/currents"))[phase - 1]) + + +def _get_chargepoint_currents(daemon, id_): + return _scalar(id_, 'currents', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/currents"))) + + +def _get_chargepoint_power(daemon, id_): + if _is_total(id_): + return {'chargepoint_total': {'power': daemon.total_sums['chargepoint']['power']}} + return _scalar(id_, 'power', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/power"))) + + +def _get_chargepoint_powers(daemon, id_): + return _scalar(id_, 'powers', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/powers"))) + + +def _get_chargepoint_imported(daemon, id_): + if _is_total(id_): + return {'chargepoint_total': {'imported': daemon.total_sums['chargepoint']['imported']}} + return _scalar(id_, 'imported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/imported"))) + + +def _get_chargepoint_exported(daemon, id_): + if _is_total(id_): + return {'chargepoint_total': {'exported': daemon.total_sums['chargepoint']['exported']}} + return _scalar(id_, 'exported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/exported"))) + + +def _get_chargepoint_daily_imported(daemon, id_): + return _scalar(id_, 'daily_imported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/daily_imported"))) + + +def _get_chargepoint_daily_exported(daemon, id_): + return _scalar(id_, 'daily_exported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/daily_exported"))) + + +def _get_chargepoint_monthly_exported(daemon, id_): + if _is_total(id_): + return {'chargepoint_total': {'monthly_exported': daemon.total_sums['chargepoint']['monthly_exported']}} + return _scalar(id_, 'monthly_exported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/monthly_exported"))) + + +def _get_chargepoint_yearly_exported(daemon, id_): + if _is_total(id_): + return {'chargepoint_total': {'yearly_exported': daemon.total_sums['chargepoint']['yearly_exported']}} + return _scalar(id_, 'yearly_exported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/yearly_exported"))) + + +def _get_chargepoint_frequency(daemon, id_): + return _scalar(id_, 'frequency', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/frequency"))) + + +def _get_chargepoint_rfid(daemon, id_): + return _scalar(id_, 'rfid', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/rfid"), None)) + + +def _get_chargepoint_rfid_timestamp(daemon, id_): + return _scalar(id_, 'rfid_timestamp', _as_int(_raw(daemon, f"openWB/chargepoint/{id_}/get/rfid_timestamp"))) + + +def _get_chargepoint_evse_current(daemon, id_): + return _scalar(id_, 'evse_current', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/evse_current"))) + + +def _get_chargepoint_power_factors(daemon, id_): + return _scalar(id_, 'power_factors', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/power_factors"))) + + +def _get_chargepoint_power_factor(daemon, id_, phase): + factors = _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/power_factors")) + return _scalar(id_, f'power_factor_p{phase}', factors[phase - 1]) + + +def _get_chargepoint_config_name(daemon, id_): + config = _raw(daemon, f"openWB/chargepoint/{id_}/config") or {} + return _scalar(id_, 'config_name', _as_str(config.get('name'), '')) + + +def _get_chargepoint_connected_vehicle_name(daemon, id_): + info = _raw(daemon, f"openWB/chargepoint/{id_}/get/connected_vehicle/info") or {} + return _scalar(id_, 'connected_vehicle_name', _as_str(info.get('name'), '')) + + +def _get_chargepoint_charge_template_name(daemon, id_): + template = daemon.charge_template_cache.get(str(id_)) or {} + return _scalar(id_, 'charge_template_name', _as_str(template.get('name'), '')) + + +def _get_chargepoint_charge_template_min_current(daemon, id_): + template = daemon.charge_template_cache.get(str(id_)) or {} + min_current = template.get('chargemode', {}).get('pv_charging', {}).get('min_current', 0) + return _scalar(id_, 'charge_template_min_current', _as_float(min_current)) + + +def _get_chargepoint_instant_charging_current(daemon, id_): + template = daemon.charge_template_cache.get(str(id_)) or {} + current = template.get('chargemode', {}).get('instant_charging', {}).get('current', 0) + return _scalar(id_, 'instant_charging_current', _as_float(current)) + + +def _get_chargepoint_pv_charging_min_current(daemon, id_): + template = daemon.charge_template_cache.get(str(id_)) or {} + min_current = template.get('chargemode', {}).get('pv_charging', {}).get('min_current', 0) + return _scalar(id_, 'pv_charging_min_current', _as_float(min_current)) + + +def _get_chargepoint_soc(daemon, id_): + return _scalar(id_, 'soc', _as_int(_raw(daemon, f"openWB/chargepoint/{id_}/get/soc"))) + + +def _get_chargepoint_state_str(daemon, id_): + return _scalar(id_, 'state_str', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/state_str"), '')) + + +def _get_chargepoint_fault_str(daemon, id_): + return _scalar(id_, 'fault_str', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/fault_str"), '')) + + +def _get_chargepoint_fault_state(daemon, id_): + return _scalar(id_, 'fault_state', _as_int(_raw(daemon, f"openWB/chargepoint/{id_}/get/fault_state"))) + + +def _get_chargepoint_phases_in_use(daemon, id_): + return _scalar(id_, 'phases_in_use', _as_int(_raw(daemon, f"openWB/chargepoint/{id_}/get/phases_in_use"))) + + +def _get_chargepoint_plug_state(daemon, id_): + return _scalar(id_, 'plug_state', _as_bool(_raw(daemon, f"openWB/chargepoint/{id_}/get/plug_state"))) + + +def _get_chargepoint_charge_state(daemon, id_): + return _scalar(id_, 'charge_state', _as_bool(_raw(daemon, f"openWB/chargepoint/{id_}/get/charge_state"))) + + +def _get_chargepoint_chargemode(daemon, id_): + return _scalar(id_, 'chargemode', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/chargemode"), '')) + + +def _comp_scalar(component: str, id_: Any, field: str, value: Any) -> Dict[str, Any]: + return {f"{component}_{id_}": {field: value}} + + +def _get_counter(daemon, id_): + prefix = f"openWB/counter/{id_}/get/" + return { + f"counter_{id_}": { + 'power': _as_float(_raw(daemon, prefix + 'power')), + 'voltages': _phases(_raw(daemon, prefix + 'voltages')), + 'currents': _phases(_raw(daemon, prefix + 'currents')), + 'powers': _phases(_raw(daemon, prefix + 'powers')), + 'power_factors': _phases(_raw(daemon, prefix + 'power_factors')), + 'frequency': _as_float(_raw(daemon, prefix + 'frequency'), 50.0), + 'exported': _as_float(_raw(daemon, prefix + 'exported')), + 'daily_exported': _as_float(_raw(daemon, prefix + 'daily_exported')), + 'imported': _as_float(_raw(daemon, prefix + 'imported')), + 'daily_imported': _as_float(_raw(daemon, prefix + 'daily_imported')), + 'fault_str': _as_str(_raw(daemon, prefix + 'fault_str'), 'Kein Fehler'), + 'fault_state': _as_int(_raw(daemon, prefix + 'fault_state')), + } + } + + +def _get_counter_voltage(daemon, id_, phase): + return _comp_scalar( + 'counter', id_, f'voltage_p{phase}', _phases(_raw(daemon, f"openWB/counter/{id_}/get/voltages"))[phase - 1]) + + +def _get_counter_voltages(daemon, id_): + return _comp_scalar('counter', id_, 'voltages', _phases(_raw(daemon, f"openWB/counter/{id_}/get/voltages"))) + + +def _get_counter_current(daemon, id_, phase): + return _comp_scalar( + 'counter', id_, f'current_p{phase}', _phases(_raw(daemon, f"openWB/counter/{id_}/get/currents"))[phase - 1]) + + +def _get_counter_currents(daemon, id_): + return _comp_scalar('counter', id_, 'currents', _phases(_raw(daemon, f"openWB/counter/{id_}/get/currents"))) + + +def _get_counter_power(daemon, id_): + return _comp_scalar('counter', id_, 'power', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/power"))) + + +def _get_counter_powers(daemon, id_): + return _comp_scalar('counter', id_, 'powers', _phases(_raw(daemon, f"openWB/counter/{id_}/get/powers"))) + + +def _get_counter_power_factors(daemon, id_): + return _comp_scalar( + 'counter', id_, 'power_factors', _phases(_raw(daemon, f"openWB/counter/{id_}/get/power_factors"))) + + +def _get_counter_imported(daemon, id_): + return _comp_scalar('counter', id_, 'imported', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/imported"))) + + +def _get_counter_exported(daemon, id_): + return _comp_scalar('counter', id_, 'exported', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/exported"))) + + +def _get_counter_daily_imported(daemon, id_): + return _comp_scalar( + 'counter', id_, 'daily_imported', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/daily_imported"))) + + +def _get_counter_daily_exported(daemon, id_): + return _comp_scalar( + 'counter', id_, 'daily_exported', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/daily_exported"))) + + +def _get_counter_frequency(daemon, id_): + return _comp_scalar( + 'counter', id_, 'frequency', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/frequency"), 50.0)) + + +def _get_counter_fault_str(daemon, id_): + return _comp_scalar( + 'counter', id_, 'fault_str', _as_str(_raw(daemon, f"openWB/counter/{id_}/get/fault_str"), 'Kein Fehler')) + + +def _get_counter_fault_state(daemon, id_): + return _comp_scalar('counter', id_, 'fault_state', _as_int(_raw(daemon, f"openWB/counter/{id_}/get/fault_state"))) + + +def _get_battery(daemon, id_): + prefix = f"openWB/bat/{id_}/get/" + return { + f"battery_{id_}": { + 'power': _as_float(_raw(daemon, prefix + 'power')), + 'soc': _as_int(_raw(daemon, prefix + 'soc')), + 'currents': _phases(_raw(daemon, prefix + 'currents')), + 'imported': _as_float(_raw(daemon, prefix + 'imported')), + 'exported': _as_float(_raw(daemon, prefix + 'exported')), + 'daily_imported': _as_float(_raw(daemon, prefix + 'daily_imported')), + 'daily_exported': _as_float(_raw(daemon, prefix + 'daily_exported')), + 'fault_str': _as_str(_raw(daemon, prefix + 'fault_str'), 'Kein Fehler'), + 'fault_state': _as_int(_raw(daemon, prefix + 'fault_state')), + 'power_limit_controllable': _as_bool(_raw(daemon, prefix + 'power_limit_controllable')), + } + } + + +def _get_battery_power(daemon, id_): + return _comp_scalar('battery', id_, 'power', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/power"))) + + +def _get_battery_soc(daemon, id_): + return _comp_scalar('battery', id_, 'soc', _as_int(_raw(daemon, f"openWB/bat/{id_}/get/soc"))) + + +def _get_battery_currents(daemon, id_): + return _comp_scalar('battery', id_, 'currents', _phases(_raw(daemon, f"openWB/bat/{id_}/get/currents"))) + + +def _get_battery_imported(daemon, id_): + return _comp_scalar('battery', id_, 'imported', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/imported"))) + + +def _get_battery_exported(daemon, id_): + return _comp_scalar('battery', id_, 'exported', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/exported"))) + + +def _get_battery_daily_imported(daemon, id_): + return _comp_scalar( + 'battery', id_, 'daily_imported', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/daily_imported"))) + + +def _get_battery_daily_exported(daemon, id_): + return _comp_scalar( + 'battery', id_, 'daily_exported', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/daily_exported"))) + + +def _get_battery_fault_str(daemon, id_): + return _comp_scalar( + 'battery', id_, 'fault_str', _as_str(_raw(daemon, f"openWB/bat/{id_}/get/fault_str"), 'Kein Fehler')) + + +def _get_battery_fault_state(daemon, id_): + return _comp_scalar('battery', id_, 'fault_state', _as_int(_raw(daemon, f"openWB/bat/{id_}/get/fault_state"))) + + +def _get_battery_power_limit_controllable(daemon, id_): + return _comp_scalar( + 'battery', id_, 'power_limit_controllable', + _as_bool(_raw(daemon, f"openWB/bat/{id_}/get/power_limit_controllable"))) + + +def _get_pv(daemon, id_): + prefix = f"openWB/pv/{id_}/get/" + return { + f"pv_{id_}": { + 'power': _as_float(_raw(daemon, prefix + 'power')), + 'currents': _phases(_raw(daemon, prefix + 'currents')), + 'exported': _as_float(_raw(daemon, prefix + 'exported')), + 'daily_exported': _as_float(_raw(daemon, prefix + 'daily_exported')), + 'monthly_exported': _as_float(_raw(daemon, prefix + 'monthly_exported')), + 'yearly_exported': _as_float(_raw(daemon, prefix + 'yearly_exported')), + 'fault_str': _as_str(_raw(daemon, prefix + 'fault_str'), 'Kein Fehler'), + 'fault_state': _as_int(_raw(daemon, prefix + 'fault_state')), + } + } + + +def _get_pv_power(daemon, id_): + if _is_total(id_): + return {'pv_total': {'power': daemon.total_sums['pv']['power']}} + return _comp_scalar('pv', id_, 'power', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/power"))) + + +def _get_pv_currents(daemon, id_): + return _comp_scalar('pv', id_, 'currents', _phases(_raw(daemon, f"openWB/pv/{id_}/get/currents"))) + + +def _get_pv_imported(daemon, id_): + if _is_total(id_): + return {'pv_total': {'imported': daemon.total_sums['pv']['imported']}} + return _comp_scalar('pv', id_, 'imported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/imported"))) + + +def _get_pv_exported(daemon, id_): + if _is_total(id_): + return {'pv_total': {'exported': daemon.total_sums['pv']['exported']}} + return _comp_scalar('pv', id_, 'exported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/exported"))) + + +def _get_pv_daily_exported(daemon, id_): + return _comp_scalar('pv', id_, 'daily_exported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/daily_exported"))) + + +def _get_pv_monthly_exported(daemon, id_): + if _is_total(id_): + return {'pv_total': {'monthly_exported': daemon.total_sums['pv']['monthly_exported']}} + return _comp_scalar( + 'pv', id_, 'monthly_exported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/monthly_exported"))) + + +def _get_pv_yearly_exported(daemon, id_): + if _is_total(id_): + return {'pv_total': {'yearly_exported': daemon.total_sums['pv']['yearly_exported']}} + return _comp_scalar( + 'pv', id_, 'yearly_exported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/yearly_exported"))) + + +def _get_pv_fault_str(daemon, id_): + return _comp_scalar( + 'pv', id_, 'fault_str', _as_str(_raw(daemon, f"openWB/pv/{id_}/get/fault_str"), 'Kein Fehler')) + + +def _get_pv_fault_state(daemon, id_): + return _comp_scalar('pv', id_, 'fault_state', _as_int(_raw(daemon, f"openWB/pv/{id_}/get/fault_state"))) + + +def _get_consumer_all(daemon, id_): + prefix = f"openWB/consumer/{id_}/get/" + usage = _raw(daemon, f"openWB/consumer/{id_}/usage") + usage_type = usage.get('type') if isinstance(usage, dict) else None + return { + f"consumer_{id_}": { + 'usage_type': usage_type, + 'power': _as_float(_raw(daemon, prefix + 'power')), + 'currents': _phases(_raw(daemon, prefix + 'currents')), + 'voltages': _phases(_raw(daemon, prefix + 'voltages')), + 'powers': _phases(_raw(daemon, prefix + 'powers')), + 'imported': _as_float(_raw(daemon, prefix + 'imported')), + 'exported': _as_float(_raw(daemon, prefix + 'exported')), + 'daily_imported': _as_float(_raw(daemon, prefix + 'daily_imported')), + 'phases_in_use': _as_int(_raw(daemon, prefix + 'phases_in_use')), + 'charge_state': _as_bool(_raw(daemon, prefix + 'charge_state')), + 'state_str': _as_str(_raw(daemon, prefix + 'state_str'), None), + 'fault_str': _as_str(_raw(daemon, prefix + 'fault_str'), 'Kein Fehler'), + 'fault_state': _as_int(_raw(daemon, prefix + 'fault_state')), + } + } + + +def _get_consumer_power(daemon, id_): + return _comp_scalar('consumer', id_, 'power', _as_float(_raw(daemon, f"openWB/consumer/{id_}/get/power"))) + + +def _get_consumer_currents(daemon, id_): + return _comp_scalar('consumer', id_, 'currents', _phases(_raw(daemon, f"openWB/consumer/{id_}/get/currents"))) + + +def _get_consumer_voltages(daemon, id_): + return _comp_scalar('consumer', id_, 'voltages', _phases(_raw(daemon, f"openWB/consumer/{id_}/get/voltages"))) + + +def _get_consumer_powers(daemon, id_): + return _comp_scalar('consumer', id_, 'powers', _phases(_raw(daemon, f"openWB/consumer/{id_}/get/powers"))) + + +def _get_consumer_imported(daemon, id_): + return _comp_scalar('consumer', id_, 'imported', _as_float(_raw(daemon, f"openWB/consumer/{id_}/get/imported"))) + + +def _get_consumer_exported(daemon, id_): + return _comp_scalar('consumer', id_, 'exported', _as_float(_raw(daemon, f"openWB/consumer/{id_}/get/exported"))) + + +def _get_consumer_daily_imported(daemon, id_): + return _comp_scalar( + 'consumer', id_, 'daily_imported', _as_float(_raw(daemon, f"openWB/consumer/{id_}/get/daily_imported"))) + + +def _get_consumer_phases_in_use(daemon, id_): + return _comp_scalar( + 'consumer', id_, 'phases_in_use', _as_int(_raw(daemon, f"openWB/consumer/{id_}/get/phases_in_use"))) + + +def _get_consumer_charge_state(daemon, id_): + return _comp_scalar( + 'consumer', id_, 'charge_state', _as_bool(_raw(daemon, f"openWB/consumer/{id_}/get/charge_state"))) + + +def _get_consumer_state_str(daemon, id_): + return _comp_scalar( + 'consumer', id_, 'state_str', _as_str(_raw(daemon, f"openWB/consumer/{id_}/get/state_str"), None)) + + +def _get_consumer_fault_str(daemon, id_): + return _comp_scalar( + 'consumer', id_, 'fault_str', _as_str(_raw(daemon, f"openWB/consumer/{id_}/get/fault_str"), 'Kein Fehler')) + + +def _get_consumer_fault_state(daemon, id_): + return _comp_scalar( + 'consumer', id_, 'fault_state', _as_int(_raw(daemon, f"openWB/consumer/{id_}/get/fault_state"))) + + +def _get_consumer_usage_type(daemon, id_): + usage = _raw(daemon, f"openWB/consumer/{id_}/usage") + usage_type = usage.get('type') if isinstance(usage, dict) else None + return _comp_scalar('consumer', id_, 'usage_type', usage_type) + + +def _get_io_output_all(daemon, id_): + return { + f"io_{id_}": { + 'digital_output': daemon._get_io_output_map(id_, 'digital_output'), + 'analog_output': daemon._get_io_output_map(id_, 'analog_output'), + } + } + + +def _get_io_output(daemon, id_, options): + output_name = options.get('io_output') if options else None + if output_name is None or str(output_name).strip() == '': + return _get_io_output_all(daemon, id_) + + output_name = str(output_name).strip() + output_type = daemon._normalize_io_output_type(options.get('io_output_type')) + result = None + + if output_type == 'digital_output' or output_type is None: + digital = daemon._get_io_output_map(id_, 'digital_output') + if output_name in digital: + result = digital[output_name] + output_type = 'digital_output' + + if result is None and (output_type == 'analog_output' or output_type is None): + analog = daemon._get_io_output_map(id_, 'analog_output') + if output_name in analog: + result = analog[output_name] + output_type = 'analog_output' + + return {f"io_{id_}": {'output': output_name, 'output_type': output_type, 'value': result}} + + +def _get_last_live_values_json(daemon, id_=None): + return {'get_lastlivevaluesjson': daemon.last_live_values_json} + + +READ_DISPATCH: Dict[str, Callable[..., Dict[str, Any]]] = { + 'get_chargepoint_all': _get_chargepoint_all, + 'get_chargepoint_voltage_p1': lambda d, i: _get_chargepoint_voltage(d, i, 1), + 'get_chargepoint_voltage_p2': lambda d, i: _get_chargepoint_voltage(d, i, 2), + 'get_chargepoint_voltage_p3': lambda d, i: _get_chargepoint_voltage(d, i, 3), + 'get_chargepoint_voltages': _get_chargepoint_voltages, + 'get_chargepoint_current_p1': lambda d, i: _get_chargepoint_current(d, i, 1), + 'get_chargepoint_current_p2': lambda d, i: _get_chargepoint_current(d, i, 2), + 'get_chargepoint_current_p3': lambda d, i: _get_chargepoint_current(d, i, 3), + 'get_chargepoint_currents': _get_chargepoint_currents, + 'get_chargepoint_power': _get_chargepoint_power, + 'get_chargepoint_powers': _get_chargepoint_powers, + 'get_chargepoint_imported': _get_chargepoint_imported, + 'get_chargepoint_exported': _get_chargepoint_exported, + 'get_chargepoint_daily_imported': _get_chargepoint_daily_imported, + 'get_chargepoint_daily_exported': _get_chargepoint_daily_exported, + 'get_chargepoint_monthly_exported': _get_chargepoint_monthly_exported, + 'get_chargepoint_yearly_exported': _get_chargepoint_yearly_exported, + 'get_chargepoint_frequency': _get_chargepoint_frequency, + 'get_chargepoint_rfid': _get_chargepoint_rfid, + 'get_chargepoint_rfid_timestamp': _get_chargepoint_rfid_timestamp, + 'get_chargepoint_evse_current': _get_chargepoint_evse_current, + 'get_chargepoint_power_factors': _get_chargepoint_power_factors, + 'get_chargepoint_power_factor_p1': lambda d, i: _get_chargepoint_power_factor(d, i, 1), + 'get_chargepoint_power_factor_p2': lambda d, i: _get_chargepoint_power_factor(d, i, 2), + 'get_chargepoint_power_factor_p3': lambda d, i: _get_chargepoint_power_factor(d, i, 3), + 'get_chargepoint_config_name': _get_chargepoint_config_name, + 'get_chargepoint_connected_vehicle_name': _get_chargepoint_connected_vehicle_name, + 'get_chargepoint_charge_template_name': _get_chargepoint_charge_template_name, + 'get_chargepoint_charge_template_min_current': _get_chargepoint_charge_template_min_current, + 'get_chargepoint_instant_charging_current': _get_chargepoint_instant_charging_current, + 'get_chargepoint_pv_charging_min_current': _get_chargepoint_pv_charging_min_current, + 'get_chargepoint_soc': _get_chargepoint_soc, + 'get_chargepoint_state_str': _get_chargepoint_state_str, + 'get_chargepoint_fault_str': _get_chargepoint_fault_str, + 'get_chargepoint_fault_state': _get_chargepoint_fault_state, + 'get_chargepoint_phases_in_use': _get_chargepoint_phases_in_use, + 'get_chargepoint_plug_state': _get_chargepoint_plug_state, + 'get_chargepoint_charge_state': _get_chargepoint_charge_state, + 'get_chargepoint_chargemode': _get_chargepoint_chargemode, + 'get_counter': _get_counter, + 'get_counter_voltage_p1': lambda d, i: _get_counter_voltage(d, i, 1), + 'get_counter_voltage_p2': lambda d, i: _get_counter_voltage(d, i, 2), + 'get_counter_voltage_p3': lambda d, i: _get_counter_voltage(d, i, 3), + 'get_counter_voltages': _get_counter_voltages, + 'get_counter_current_p1': lambda d, i: _get_counter_current(d, i, 1), + 'get_counter_current_p2': lambda d, i: _get_counter_current(d, i, 2), + 'get_counter_current_p3': lambda d, i: _get_counter_current(d, i, 3), + 'get_counter_currents': _get_counter_currents, + 'get_counter_power': _get_counter_power, + 'get_counter_powers': _get_counter_powers, + 'get_counter_power_factors': _get_counter_power_factors, + 'get_counter_imported': _get_counter_imported, + 'get_counter_exported': _get_counter_exported, + 'get_counter_daily_imported': _get_counter_daily_imported, + 'get_counter_daily_exported': _get_counter_daily_exported, + 'get_counter_frequency': _get_counter_frequency, + 'get_counter_fault_str': _get_counter_fault_str, + 'get_counter_fault_state': _get_counter_fault_state, + 'battery': _get_battery, + 'get_battery': _get_battery, + 'get_battery_power': _get_battery_power, + 'get_battery_soc': _get_battery_soc, + 'get_battery_currents': _get_battery_currents, + 'get_battery_imported': _get_battery_imported, + 'get_battery_exported': _get_battery_exported, + 'get_battery_daily_imported': _get_battery_daily_imported, + 'get_battery_daily_exported': _get_battery_daily_exported, + 'get_battery_fault_str': _get_battery_fault_str, + 'get_battery_fault_state': _get_battery_fault_state, + 'get_battery_power_limit_controllable': _get_battery_power_limit_controllable, + 'pv': _get_pv, + 'get_pv': _get_pv, + 'get_pv_power': _get_pv_power, + 'get_pv_currents': _get_pv_currents, + 'get_pv_imported': _get_pv_imported, + 'get_pv_exported': _get_pv_exported, + 'get_pv_daily_exported': _get_pv_daily_exported, + 'get_pv_monthly_exported': _get_pv_monthly_exported, + 'get_pv_yearly_exported': _get_pv_yearly_exported, + 'get_pv_fault_str': _get_pv_fault_str, + 'get_pv_fault_state': _get_pv_fault_state, + 'consumer': _get_consumer_all, + 'get_consumer': _get_consumer_all, + 'get_consumer_all': _get_consumer_all, + 'get_consumer_power': _get_consumer_power, + 'get_consumer_currents': _get_consumer_currents, + 'get_consumer_voltages': _get_consumer_voltages, + 'get_consumer_powers': _get_consumer_powers, + 'get_consumer_imported': _get_consumer_imported, + 'get_consumer_exported': _get_consumer_exported, + 'get_consumer_daily_imported': _get_consumer_daily_imported, + 'get_consumer_phases_in_use': _get_consumer_phases_in_use, + 'get_consumer_charge_state': _get_consumer_charge_state, + 'get_consumer_state_str': _get_consumer_state_str, + 'get_consumer_fault_str': _get_consumer_fault_str, + 'get_consumer_fault_state': _get_consumer_fault_state, + 'get_consumer_usage_type': _get_consumer_usage_type, + 'get_io_output_all': _get_io_output_all, + 'get_io_output': _get_io_output, + 'get_lastlivevaluesjson': _get_last_live_values_json, +} + +# Read handlers that need the request's io_output/io_output_type options in +# addition to (daemon, id) - every other read handler ignores options entirely. +READ_PARAMS_WITH_OPTIONS = {'get_io_output'} + +WRITE_DISPATCH: Dict[str, Callable[..., Tuple[bool, str]]] = { + 'set_chargemode': lambda d, v, i, o: d._set_chargemode(i, v), + 'chargecurrent': lambda d, v, i, o: d._set_chargecurrent(i, v), + 'minimal_pv_soc': lambda d, v, i, o: d._set_minimal_pv_soc(i, v), + 'minimal_permanent_current': lambda d, v, i, o: d._set_minimal_permanent_current(i, v), + 'max_price_eco': lambda d, v, i, o: d._set_max_price_eco(i, v), + 'chargepoint_lock': lambda d, v, i, o: d._set_chargepoint_lock(i, v), + 'bat_mode': lambda d, v, i, o: d._handle_bat_mode_operation(v), + 'bat_power_reserve': lambda d, v, i, o: d._handle_bat_power_reserve_operation(v), + 'instant_charging_limit': lambda d, v, i, o: d._handle_instant_charging_limit_operation(v, i), + 'instant_charging_amount': lambda d, v, i, o: d._handle_instant_charging_limit_amount_operation(v, i), + 'instant_charging_soc': lambda d, v, i, o: d._handle_instant_charging_limit_soc_operation(v, i), + 'pv_charging_limit': lambda d, v, i, o: d._handle_pv_charging_limit_operation(v, i), + 'pv_charging_amount': lambda d, v, i, o: d._handle_pv_charging_limit_amount_operation(v, i), + 'pv_charging_soc': lambda d, v, i, o: d._handle_pv_charging_limit_soc_operation(v, i), + 'vehicle': lambda d, v, i, o: d._set_vehicle(i, v), + 'manual_soc': lambda d, v, i, o: d._set_manual_soc(i, v), + 'set_io_output': lambda d, v, i, o: d._set_io_output(i, v, o.get('io_output'), o.get('io_output_type')), +} + + +def list_components(daemon: Any) -> Dict[str, List[int]]: + return { + TYPE_PUBLIC_NAMES.get(type_, type_): sorted(daemon.all_ids.get(type_, set())) + for type_ in SCAN_TYPES + } + + +class ParameterHandler: + def __init__(self, daemon: Any): + self.daemon = daemon + + def read_parameter( + self, param: str, id_: Any, options: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + handler = READ_DISPATCH.get(param) + if handler is None: + return None + if param in READ_PARAMS_WITH_OPTIONS: + return handler(self.daemon, id_, options or {}) + return handler(self.daemon, id_) + + def write_parameter( + self, param: str, value: Any, target_id: Any = None, + options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + handler = WRITE_DISPATCH.get(param) + if handler is None: + return {'success': False, 'message': 'Unknown write parameter'} + try: + success, message = handler(self.daemon, value, target_id, options or {}) + return {'success': success, 'message': message} + except Exception as exc: + return {'success': False, 'message': str(exc)} + + def list_components(self) -> Dict[str, List[int]]: + return list_components(self.daemon) + + +def extract_write_params(all_params: Dict[str, Any]) -> Dict[str, Any]: + return {k: all_params[k] for k in WRITE_PARAMETER_ORDER if k in all_params} + + +def extract_read_params(all_params: Dict[str, Any]) -> Dict[str, Any]: + return {k: all_params[k] for k in READ_PARAMETER_ORDER if k in all_params} + + +def dispatch_write(ph: ParameterHandler, write_params: Dict[str, Any], all_params: Dict[str, Any]) -> Dict[str, Any]: + daemon = ph.daemon + first_target_id = None + + for param, value in write_params.items(): + target_id = None + id_type = None + if is_chargepoint_parameter(param): + target_id = all_params.get('chargepoint_nr') + id_type = 'chargepoint' + elif is_io_parameter(param): + target_id = all_params.get('io_nr') + id_type = 'io' + + if id_type is not None and target_id in (None, '', 'auto'): + resolved = daemon.lowest_ids.get(id_type) + if resolved is None: + return {'success': False, 'message': f"No {id_type} devices available for auto-ID"} + target_id = resolved + + options = {'io_output': all_params.get('io_output'), 'io_output_type': all_params.get('io_output_type')} + result = ph.write_parameter(param, value, target_id, options) + + if first_target_id is None: + first_target_id = target_id + if not result['success']: + return result + + first_param = next(iter(write_params)) + first_value = write_params[first_param] + return { + 'success': True, + 'message': get_success_message(first_param, first_value, first_target_id), + 'data': { + 'chargepoint_nr': first_target_id if is_chargepoint_parameter(first_param) else None, + 'io_nr': first_target_id if is_io_parameter(first_param) else None, + first_param: first_value, + }, + } + + +def dispatch_read( + ph: ParameterHandler, read_params: Dict[str, Any], all_params: Dict[str, Any], + debug: bool) -> Tuple[Dict[str, Any], List[str]]: + result: Dict[str, Any] = {} + debug_info: List[str] = [] + + for param, id_ in read_params.items(): + resolved_id = id_ + if id_ in ('auto', ''): + type_ = get_type_from_parameter(param) + resolved_id = ph.daemon.lowest_ids.get(type_) + if resolved_id is None: + if debug: + debug_info.append(f"Auto-ID failed for {param}: no {type_} devices known") + if 'chargepoint' in param: + resolved_id = 0 + else: + continue + + options = {'io_output': all_params.get('io_output'), 'io_output_type': all_params.get('io_output_type')} + data = ph.read_parameter(param, resolved_id, options) + if data is not None: + result.update(data) + + return result, debug_info From da0c27a389514c313f3e85079fdc37e7c869d816 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 23 Oct 2026 20:19:42 +0200 Subject: [PATCH 2/8] regression_check.py: add -k/--insecure for self-signed TLS --- simpleAPI/regression_check.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/simpleAPI/regression_check.py b/simpleAPI/regression_check.py index e52c128faa..8869dde566 100644 --- a/simpleAPI/regression_check.py +++ b/simpleAPI/regression_check.py @@ -109,9 +109,10 @@ def deep_diff(old: Any, new: Any, path: str, float_tolerance: float, diffs: List diffs.append(f"{path}: OLD={old!r} NEW={new!r}") -def fetch(base_url: str, query: Dict[str, Any], timeout: float) -> Tuple[Optional[int], Any, Optional[str]]: +def fetch(base_url: str, query: Dict[str, Any], timeout: float, + verify: bool) -> Tuple[Optional[int], Any, Optional[str]]: try: - resp = requests.get(base_url, params=query, timeout=timeout) + resp = requests.get(base_url, params=query, timeout=timeout, verify=verify) except requests.RequestException as exc: return None, None, str(exc) try: @@ -121,9 +122,9 @@ def fetch(base_url: str, query: Dict[str, Any], timeout: float) -> Tuple[Optiona def compare(old_base: str, new_base: str, query: Dict[str, Any], path: str, - float_tolerance: float, timeout: float) -> Tuple[Optional[bool], List[str]]: - old_status, old_body, old_err = fetch(old_base, query, timeout) - new_status, new_body, new_err = fetch(new_base, query, timeout) + float_tolerance: float, timeout: float, verify: bool) -> Tuple[Optional[bool], List[str]]: + old_status, old_body, old_err = fetch(old_base, query, timeout, verify) + new_status, new_body, new_err = fetch(new_base, query, timeout, verify) if old_err or new_err: return False, [f"request failed: OLD error={old_err!r} NEW error={new_err!r}"] @@ -159,12 +160,14 @@ def build_common_params(args: argparse.Namespace) -> Dict[str, Any]: def check_read_param(param: str, args: argparse.Namespace, common: Dict[str, Any]) -> Tuple[Optional[bool], List[str]]: query = {**common, param: args.id} - return compare(args.old_base_url, args.new_base_url, query, param, args.float_tolerance, args.timeout) + return compare(args.old_base_url, args.new_base_url, query, param, + args.float_tolerance, args.timeout, not args.insecure) def check_list_components(args: argparse.Namespace, common: Dict[str, Any]) -> Tuple[Optional[bool], List[str]]: query = {**common, 'list_components': 'true'} - return compare(args.old_base_url, args.new_base_url, query, 'list_components', args.float_tolerance, args.timeout) + return compare(args.old_base_url, args.new_base_url, query, 'list_components', + args.float_tolerance, args.timeout, not args.insecure) def check_write_param(param: str, args: argparse.Namespace, common: Dict[str, Any]) -> Tuple[Optional[bool], List[str]]: @@ -187,7 +190,8 @@ def check_write_param(param: str, args: argparse.Namespace, common: Dict[str, An if param == 'set_io_output': query['io_output'] = args.io_output_name - return compare(args.old_base_url, args.new_base_url, query, param, args.float_tolerance, args.timeout) + return compare(args.old_base_url, args.new_base_url, query, param, + args.float_tolerance, args.timeout, not args.insecure) def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: @@ -233,6 +237,9 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: "the two near-simultaneous requests (default: 0.0, exact match)") parser.add_argument('--timeout', type=float, default=10.0, help="per-request HTTP timeout in seconds") parser.add_argument('--max-diff-lines', type=int, default=20, help="diff lines printed per failing parameter") + parser.add_argument( + '-k', '--insecure', action='store_true', + help="don't verify TLS certificates (e.g. for a device's self-signed HTTPS cert)") return parser.parse_args(argv) @@ -240,6 +247,10 @@ def main(argv: Optional[List[str]] = None) -> int: args = parse_args(argv) common = build_common_params(args) + if args.insecure: + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + read_params = [p for p in READ_PARAMETER_ORDER if selected(p, args.only, args.skip)] write_params = [p for p in WRITE_PARAMETER_ORDER if selected(p, args.only, args.skip)] From e895ebd8a82ba12f27a69310f31a5d1155966bae Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 23 Oct 2026 20:24:16 +0200 Subject: [PATCH 3/8] regression_check.py: stream results instead of buffering --- simpleAPI/regression_check.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/simpleAPI/regression_check.py b/simpleAPI/regression_check.py index 8869dde566..fac5f6a0c0 100644 --- a/simpleAPI/regression_check.py +++ b/simpleAPI/regression_check.py @@ -253,6 +253,9 @@ def main(argv: Optional[List[str]] = None) -> int: read_params = [p for p in READ_PARAMETER_ORDER if selected(p, args.only, args.skip)] write_params = [p for p in WRITE_PARAMETER_ORDER if selected(p, args.only, args.skip)] + checks = (['list_components'] if selected('list_components', args.only, args.skip) else []) + read_params + if args.include_writes: + checks += write_params print("simpleAPI regression check") print(f" OLD (PHP): {args.old_base_url}") @@ -268,27 +271,22 @@ def main(argv: Optional[List[str]] = None) -> int: time.sleep(3) print() - results: List[Tuple[str, Optional[bool], List[str]]] = [] - - if selected('list_components', args.only, args.skip): - ok, diffs = check_list_components(args, common) - results.append(('list_components', ok, diffs)) - - for param in read_params: - ok, diffs = check_read_param(param, args, common) - results.append((param, ok, diffs)) - - if args.include_writes: - for param in write_params: - ok, diffs = check_write_param(param, args, common) - results.append((param, ok, diffs)) - - name_width = max((len(name) for name, _, _ in results), default=10) + 2 + name_width = max((len(name) for name in checks), default=10) + 2 print(f"{'PARAMETER':<{name_width}}RESULT") print('-' * (name_width + 6)) - for name, ok, diffs in results: + + results: List[Tuple[str, Optional[bool], List[str]]] = [] + for name in checks: + if name == 'list_components': + ok, diffs = check_list_components(args, common) + elif name in write_params: + ok, diffs = check_write_param(name, args, common) + else: + ok, diffs = check_read_param(name, args, common) + results.append((name, ok, diffs)) + status = 'PASS' if ok is True else 'SKIP' if ok is None else 'FAIL' - print(f"{name:<{name_width}}{status}") + print(f"{name:<{name_width}}{status}", flush=True) if ok is False: for line in diffs[:args.max_diff_lines]: print(f" {line}") From 3fefd16f97043ed9b55f3df7961ba85e2351fa09 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 23 Oct 2026 20:50:15 +0200 Subject: [PATCH 4/8] simpleAPI: fix get_lastlivevaluesjson and empty-result shape --- simpleAPI/src/http_server.py | 2 ++ simpleAPI/src/parameter_handler.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/simpleAPI/src/http_server.py b/simpleAPI/src/http_server.py index 58f18a408d..3a46636722 100644 --- a/simpleAPI/src/http_server.py +++ b/simpleAPI/src/http_server.py @@ -135,6 +135,8 @@ def _handle(self, params: Dict[str, str]): else: if len(read_params) == 1 and next(iter(read_params)) == 'get_lastlivevaluesjson': self._send(200, format_raw_output(result, 'get_lastlivevaluesjson'), raw=True) + elif not result: + self._send(200, []) else: self._send(200, result) return diff --git a/simpleAPI/src/parameter_handler.py b/simpleAPI/src/parameter_handler.py index 86f88d2bc8..0971296db7 100644 --- a/simpleAPI/src/parameter_handler.py +++ b/simpleAPI/src/parameter_handler.py @@ -991,7 +991,7 @@ def dispatch_read( for param, id_ in read_params.items(): resolved_id = id_ - if id_ in ('auto', ''): + if param != 'get_lastlivevaluesjson' and id_ in ('auto', ''): type_ = get_type_from_parameter(param) resolved_id = ph.daemon.lowest_ids.get(type_) if resolved_id is None: From a1f929c2fabac364a84781f4c393e0a8557a40c6 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 23 Oct 2026 21:00:26 +0200 Subject: [PATCH 5/8] simpleAPI: fix get_chargepoint_rfid empty default --- simpleAPI/src/parameter_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simpleAPI/src/parameter_handler.py b/simpleAPI/src/parameter_handler.py index 0971296db7..d9815b7082 100644 --- a/simpleAPI/src/parameter_handler.py +++ b/simpleAPI/src/parameter_handler.py @@ -350,7 +350,7 @@ def _get_chargepoint_frequency(daemon, id_): def _get_chargepoint_rfid(daemon, id_): - return _scalar(id_, 'rfid', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/rfid"), None)) + return _scalar(id_, 'rfid', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/rfid"))) def _get_chargepoint_rfid_timestamp(daemon, id_): From 35b869a2aec45083eeebcdf0b86a24a3720eae87 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 23 Oct 2026 21:18:12 +0200 Subject: [PATCH 6/8] simpleAPI: remove old PHP implementation --- data/config/apache/000-default.conf | 5 +- data/config/apache/apache-openwb-ssl.conf | 5 +- data/config/apache/localhost.conf | 5 +- simpleAPI/config/config.php | 39 - simpleAPI/simpleapi.php | 630 ----- simpleAPI/src/Authenticator.php | 212 -- simpleAPI/src/MqttClient.php | 359 --- simpleAPI/src/MqttClientSimple.php | 159 -- simpleAPI/src/ParameterHandler.php | 2986 --------------------- 9 files changed, 3 insertions(+), 4397 deletions(-) delete mode 100644 simpleAPI/config/config.php delete mode 100644 simpleAPI/simpleapi.php delete mode 100644 simpleAPI/src/Authenticator.php delete mode 100644 simpleAPI/src/MqttClient.php delete mode 100644 simpleAPI/src/MqttClientSimple.php delete mode 100644 simpleAPI/src/ParameterHandler.php diff --git a/data/config/apache/000-default.conf b/data/config/apache/000-default.conf index e79502090c..ccdf7d3fbe 100644 --- a/data/config/apache/000-default.conf +++ b/data/config/apache/000-default.conf @@ -1,4 +1,4 @@ -# openwb-version:7 +# openwb-version:8 # The ServerName directive sets the request scheme, hostname and port that # the server uses to identify itself. This is used when creating @@ -43,9 +43,6 @@ # Proxy simpleAPI requests to the Python HTTP server in simpleAPI_mqtt.py # (port from data/config/simpleAPI_mqtt_config.json's "http" section). - # Matched/proxied here before the request ever resolves to the on-disk - # simpleAPI/simpleapi.php, so the old PHP file stays unreferenced for - # this path while remaining in place for now. ProxyPassMatch "^/openWB/simpleAPI(/|$)" "http://127.0.0.1:8099/" ProxyPassReverse "/openWB/simpleAPI/" "http://127.0.0.1:8099/" diff --git a/data/config/apache/apache-openwb-ssl.conf b/data/config/apache/apache-openwb-ssl.conf index 08cf64fce2..390a54896a 100644 --- a/data/config/apache/apache-openwb-ssl.conf +++ b/data/config/apache/apache-openwb-ssl.conf @@ -1,4 +1,4 @@ -# openwb-version:9 +# openwb-version:10 ServerAdmin webmaster@localhost @@ -145,9 +145,6 @@ # Proxy simpleAPI requests to the Python HTTP server in simpleAPI_mqtt.py # (port from data/config/simpleAPI_mqtt_config.json's "http" section). - # Matched/proxied here before the request ever resolves to the on-disk - # simpleAPI/simpleapi.php, so the old PHP file stays unreferenced for - # this path while remaining in place for now. ProxyPassMatch "^/openWB/simpleAPI(/|$)" "http://127.0.0.1:8099/" ProxyPassReverse "/openWB/simpleAPI/" "http://127.0.0.1:8099/" diff --git a/data/config/apache/localhost.conf b/data/config/apache/localhost.conf index b8df8686ee..dce60415ef 100644 --- a/data/config/apache/localhost.conf +++ b/data/config/apache/localhost.conf @@ -1,4 +1,4 @@ -# openwb-version:3 +# openwb-version:4 Listen 127.0.0.1:81 @@ -45,9 +45,6 @@ Listen 127.0.0.1:81 # Proxy simpleAPI requests to the Python HTTP server in simpleAPI_mqtt.py # (port from data/config/simpleAPI_mqtt_config.json's "http" section). - # Matched/proxied here before the request ever resolves to the on-disk - # simpleAPI/simpleapi.php, so the old PHP file stays unreferenced for - # this path while remaining in place for now. ProxyPassMatch "^/openWB/simpleAPI(/|$)" "http://127.0.0.1:8099/" ProxyPassReverse "/openWB/simpleAPI/" "http://127.0.0.1:8099/" diff --git a/simpleAPI/config/config.php b/simpleAPI/config/config.php deleted file mode 100644 index f6a537950d..0000000000 --- a/simpleAPI/config/config.php +++ /dev/null @@ -1,39 +0,0 @@ - [ - 'server' => 'localhost', - 'port' => 8883, - 'validate_cert' => false, - 'username' => '', - 'password' => '', - 'clientid' => 'SimpleAPI_' . uniqid() - ], - - // API Konfiguration - 'api' => [ - 'cors_enabled' => true, - 'max_request_size' => '10M' - ], - - // Authentifizierung - 'auth' => [ - 'enabled' => true, - 'require_https' => false, - - // Gültige Tokens - 'tokens' => [ - // 'your-secret-token-here' - ], - - // Benutzer (Username => Passwort/Hash) - 'users' => [ - // 'admin' => password_hash('admin123', PASSWORD_DEFAULT), - // 'user' => 'plaintext_password' - ] - ], - - // Debug-Modus - 'debug' => false -]; diff --git a/simpleAPI/simpleapi.php b/simpleAPI/simpleapi.php deleted file mode 100644 index 5c97ffa66b..0000000000 --- a/simpleAPI/simpleapi.php +++ /dev/null @@ -1,630 +0,0 @@ -config = require __DIR__ . '/config/config.php'; - - // MQTT Client initialisieren - $this->mqttClient = new MqttClient($this->config); - - // Parameter Handler initialisieren - $this->parameterHandler = new ParameterHandler($this->mqttClient); - - // Authenticator initialisieren (mit MqttClient für Anmeldedaten-Test) - $this->authenticator = new Authenticator($this->config, $this->mqttClient); - } - - /** - * HTTP Request verarbeiten - */ - public function handleRequest() - { - try { - // Content-Type setzen - header('Content-Type: application/json'); - - // CORS Headers wenn konfiguriert - if ($this->config['api']['cors_enabled'] ?? false) { - header('Access-Control-Allow-Origin: *'); - header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); - header('Access-Control-Allow-Headers: Content-Type, Authorization'); - } - - // OPTIONS Request für CORS - if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { - http_response_code(200); - return; - } - - // Parameter sammeln (GET und POST) - $params = array_merge($_GET, $_POST); - - // Debug-Modus - $debugInfo = []; - if (isset($params['debug']) && $params['debug'] === 'true') { - $this->config['debug'] = true; - } - - // Authentifizierung prüfen wenn erforderlich - if (!$this->authenticator->authenticate($params)) { - http_response_code(401); - echo json_encode([ - 'success' => false, - 'message' => 'Authentication failed' - ]); - return; - } - - // MQTT-Anmeldedaten aus Parametern übernehmen falls vorhanden - if (isset($params['username']) && isset($params['password'])) { - $this->mqttClient->setCredentials($params['username'], $params['password']); - - if ($this->config['debug']) { - $debugInfo[] = "Using MQTT credentials from parameters: username={$params['username']}"; - } - } else { - // Keine Parameter-Anmeldedaten vorhanden - if ($this->config['debug']) { - $debugInfo[] = "No credentials provided via parameters, using config or anonymous access"; - } - } - - // Komponenten-Übersicht: alle verfügbaren IDs je Typ auflisten - if (isset($params['list_components'])) { - echo json_encode($this->mqttClient->findAllAvailableIds()); - return; - } - - // Schreibvorgänge prüfen - $writeParams = $this->getWriteParameters($params); - if (!empty($writeParams)) { - $result = $this->handleWriteRequest($writeParams, $params); - - // Raw-Output für Schreibvorgänge - if (isset($params['raw']) && $params['raw'] === 'true') { - if ($result['success']) { - echo "OK"; // Erfolgreiche Schreibvorgänge -> "OK" - } else { - echo "ERROR: " . ($result['message'] ?? 'Unknown error'); - } - } else { - echo json_encode($result); - } - return; - } - - // Lesevorgänge verarbeiten - $readParams = $this->getReadParameters($params); - if (!empty($readParams)) { - $result = $this->handleReadRequest($readParams, $params, $debugInfo); - - // Raw-Ausgabe Validierung - if (isset($params['raw']) && $params['raw'] === 'true') { - if (count($readParams) > 1) { - // Fehler: Raw-Output nur bei einzelnen Parametern erlaubt - http_response_code(400); - echo json_encode([ - 'success' => false, - 'message' => 'Raw output (raw=true) is only allowed for single parameter requests', - 'error' => 'Multiple parameters detected: ' . implode(', ', array_keys($readParams)) - ]); - return; - } - - // Prüfe ob der Parameter für Raw-Output geeignet ist - $paramName = array_keys($readParams)[0]; - if ($this->isComplexParameter($paramName)) { - http_response_code(400); - echo json_encode([ - 'success' => false, - 'message' => 'Raw output (raw=true) is not supported for complex parameters', - 'error' => "Parameter '{$paramName}' returns multiple values. Use specific single-value parameters instead.", - 'suggestion' => 'Try parameters like get_counter_power, get_counter_voltage_p1, etc.' - ]); - return; - } - - // Einzelner Parameter: Raw-Output verwenden - echo $this->formatRawOutput($result, $paramName); - } else { - // Für get_lastlivevaluesjson: JSON direkt ausgeben auch ohne raw=true - if (count($readParams) === 1 && array_keys($readParams)[0] === 'get_lastlivevaluesjson') { - echo $this->formatRawOutput($result, 'get_lastlivevaluesjson'); - } else { - echo json_encode($result); - } - } - return; - } - - // Keine gültigen Parameter - http_response_code(400); - echo json_encode([ - 'success' => false, - 'message' => 'No valid parameters provided' - ]); - } catch (Exception $e) { - http_response_code(500); - echo json_encode([ - 'success' => false, - 'message' => 'Internal server error', - 'error' => $this->config['debug'] ? $e->getMessage() : 'An error occurred' - ]); - } - } - - /** - * Schreibbare Parameter aus Request extrahieren - */ - private function getWriteParameters($params) - { - $writeParams = []; - $writeableKeys = [ - 'set_chargemode', - 'chargecurrent', - 'minimal_pv_soc', - 'minimal_permanent_current', - 'max_price_eco', - 'chargepoint_lock', - 'bat_mode', - 'bat_power_reserve', - 'instant_charging_limit', - 'instant_charging_amount', - 'instant_charging_soc', - 'pv_charging_limit', - 'pv_charging_amount', - 'pv_charging_soc', - 'vehicle', - 'manual_soc', - 'set_io_output' - ]; - - foreach ($writeableKeys as $key) { - if (isset($params[$key])) { - $writeParams[$key] = $params[$key]; - } - } - - return $writeParams; - } - - /** - * Lesbare Parameter aus Request extrahieren - */ - private function getReadParameters($params) - { - $readParams = []; - $readableKeys = [ - // Chargepoint - Alle Daten - 'get_chargepoint_all', - // Chargepoint - Spannungen - 'get_chargepoint_voltage_p1', - 'get_chargepoint_voltage_p2', - 'get_chargepoint_voltage_p3', - 'get_chargepoint_voltages', - // Chargepoint - Ströme - 'get_chargepoint_current_p1', - 'get_chargepoint_current_p2', - 'get_chargepoint_current_p3', - 'get_chargepoint_currents', - // Chargepoint - Leistungen - 'get_chargepoint_power', - 'get_chargepoint_powers', - // Chargepoint - Status & Energie - 'get_chargepoint_imported', - 'get_chargepoint_exported', - 'get_chargepoint_daily_imported', - 'get_chargepoint_daily_exported', - 'get_chargepoint_monthly_exported', - 'get_chargepoint_yearly_exported', - 'get_chargepoint_frequency', - 'get_chargepoint_rfid', - 'get_chargepoint_rfid_timestamp', - 'get_chargepoint_evse_current', - 'get_chargepoint_power_factors', - 'get_chargepoint_power_factor_p1', - 'get_chargepoint_power_factor_p2', - 'get_chargepoint_power_factor_p3', - 'get_chargepoint_config_name', - 'get_chargepoint_connected_vehicle_name', - 'get_chargepoint_charge_template_name', - 'get_chargepoint_charge_template_min_current', - 'get_chargepoint_instant_charging_current', - 'get_chargepoint_pv_charging_min_current', - 'get_chargepoint_soc', - 'get_chargepoint_state_str', - 'get_chargepoint_fault_str', - 'get_chargepoint_fault_state', - 'get_chargepoint_phases_in_use', - 'get_chargepoint_plug_state', - 'get_chargepoint_charge_state', - 'get_chargepoint_chargemode', - // Counter - Alle Daten - 'get_counter', - // Counter - Spannungen - 'get_counter_voltage_p1', - 'get_counter_voltage_p2', - 'get_counter_voltage_p3', - 'get_counter_voltages', - // Counter - Ströme - 'get_counter_current_p1', - 'get_counter_current_p2', - 'get_counter_current_p3', - 'get_counter_currents', - // Counter - Leistungen - 'get_counter_power', - 'get_counter_powers', - 'get_counter_power_factors', - // Counter - Energie & Status - 'get_counter_imported', - 'get_counter_exported', - 'get_counter_daily_imported', - 'get_counter_daily_exported', - 'get_counter_frequency', - 'get_counter_fault_str', - 'get_counter_fault_state', - // Battery - Alle Daten - 'battery', - 'get_battery', - // Battery - Einzelwerte - 'get_battery_power', - 'get_battery_soc', - 'get_battery_currents', - 'get_battery_imported', - 'get_battery_exported', - 'get_battery_daily_imported', - 'get_battery_daily_exported', - 'get_battery_fault_str', - 'get_battery_fault_state', - 'get_battery_power_limit_controllable', - // PV - Alle Daten - 'pv', - 'get_pv', - // PV - Einzelwerte - 'get_pv_power', - 'get_pv_currents', - 'get_pv_imported', - 'get_pv_exported', - 'get_pv_daily_exported', - 'get_pv_monthly_exported', - 'get_pv_yearly_exported', - 'get_pv_fault_str', - 'get_pv_fault_state', - // Consumer - Alle Daten - 'consumer', - 'get_consumer', - 'get_consumer_all', - // Consumer - Einzelwerte - 'get_consumer_power', - 'get_consumer_currents', - 'get_consumer_voltages', - 'get_consumer_powers', - 'get_consumer_imported', - 'get_consumer_exported', - 'get_consumer_daily_imported', - 'get_consumer_phases_in_use', - 'get_consumer_charge_state', - 'get_consumer_state_str', - 'get_consumer_fault_str', - 'get_consumer_fault_state', - 'get_consumer_usage_type', - // IO - Ausgaenge - 'get_io_output_all', - 'get_io_output', - // System - Werte - 'get_lastlivevaluesjson' - ]; - - foreach ($readableKeys as $key) { - if (isset($params[$key])) { - $readParams[$key] = $params[$key]; - } - } - - return $readParams; - } - - /** - * Schreibanfrage verarbeiten - */ - private function handleWriteRequest($writeParams, $allParams) - { - $firstTargetId = null; - - foreach ($writeParams as $param => $value) { - $targetId = null; - $idType = null; - - if ($this->isChargepointParameter($param)) { - $targetId = $allParams['chargepoint_nr'] ?? null; - $idType = 'chargepoint'; - } elseif ($this->isIoParameter($param)) { - $targetId = $allParams['io_nr'] ?? null; - $idType = 'io'; - } - - if ($idType !== null && ($targetId === null || $targetId === '' || $targetId === 'auto')) { - try { - $targetId = $this->mqttClient->getLowestId($idType); - if ($targetId === null) { - return [ - 'success' => false, - 'message' => "No {$idType} devices available for auto-ID" - ]; - } - } catch (Exception $e) { - return [ - 'success' => false, - 'message' => "Auto-ID failed for {$idType}: " . $e->getMessage() - ]; - } - } - - $options = [ - 'io_output' => $allParams['io_output'] ?? null, - 'io_output_type' => $allParams['io_output_type'] ?? null - ]; - - $result = $this->parameterHandler->writeParameter($param, $value, $targetId, $options); - - if ($firstTargetId === null) { - $firstTargetId = $targetId; - } - - if (!$result['success']) { - return $result; - } - } - - // Erfolgreiche Antwort für ersten Parameter (OpenWB Kompatibilität) - $firstParam = array_keys($writeParams)[0]; - $firstValue = $writeParams[$firstParam]; - - return [ - 'success' => true, - 'message' => $this->getSuccessMessage($firstParam, $firstValue, $firstTargetId ?? null), - 'data' => [ - 'chargepoint_nr' => $this->isChargepointParameter($firstParam) ? $firstTargetId : null, - 'io_nr' => $this->isIoParameter($firstParam) ? $firstTargetId : null, - $firstParam => $firstValue - ] - ]; - } - - /** - * Leseanfrage verarbeiten - */ - private function handleReadRequest($readParams, $allParams, $debugInfo = []) - { - $result = []; - - // Debug-Informationen hinzufügen wenn vorhanden - if (!empty($debugInfo) && $this->config['debug']) { - $result['debug_info'] = $debugInfo; - } - - foreach ($readParams as $param => $id) { - try { - // Auto-ID Feature: Niedrigste ID finden wenn "auto" oder leer - if ($id === 'auto' || $id === '') { - $type = $this->getTypeFromParameter($param); - $id = $this->mqttClient->getLowestId($type); - - if ($id === null) { - continue; // Skip wenn keine ID gefunden - } - } - - $options = [ - 'io_output' => $allParams['io_output'] ?? null, - 'io_output_type' => $allParams['io_output_type'] ?? null - ]; - - $data = $this->parameterHandler->readParameter($param, $id, $options); - if ($data !== null) { - $result = array_merge($result, $data); - } - } catch (Exception $e) { - // Wenn Auto-ID fehlschlägt, versuche ID 0 als Fallback - if (($id === 'auto' || $id === '') && $this->config['debug']) { - $result['debug_info'][] = "Auto-ID failed for $param: " . $e->getMessage(); - } - - // Fallback auf ID 0 für Chargepoints - if (strpos($param, 'chargepoint') !== false) { - try { - $data = $this->parameterHandler->readParameter($param, 0); - if ($data !== null) { - $result = array_merge($result, $data); - } - } catch (Exception $fallbackError) { - if ($this->config['debug']) { - $result['debug_info'][] = "Fallback to ID 0 failed for $param: " . $fallbackError->getMessage(); - } - } - } - } - } - - return $result; - } - - /** - * Typ aus Parameter ermitteln - */ - private function getTypeFromParameter($param) - { - if (strpos($param, 'chargepoint') !== false || strpos($param, 'get_chargepoint') !== false) { - return 'chargepoint'; - } elseif (strpos($param, 'io') !== false) { - return 'io'; - } elseif (strpos($param, 'battery') !== false) { - return 'bat'; // MQTT Topic verwendet 'bat' nicht 'battery' - } elseif (strpos($param, 'pv') !== false) { - return 'pv'; - } elseif (strpos($param, 'counter') !== false) { - return 'counter'; - } elseif (strpos($param, 'consumer') !== false) { - return 'consumer'; - } - - return 'chargepoint'; // Fallback - } - - /** - * Prüfen ob Parameter komplex ist (mehrere Werte zurückgibt) - */ - private function isComplexParameter($param) - { - $complexParameters = [ - 'get_chargepoint_all', - 'get_counter', - 'battery', - 'get_battery', - 'pv', - 'get_pv', - 'get_chargepoint_voltages', - 'get_chargepoint_currents', - 'get_chargepoint_powers', - 'get_chargepoint_power_factors', - 'get_counter_voltages', - 'get_counter_currents', - 'get_counter_powers', - 'get_counter_power_factors', - 'get_battery_currents', - 'get_pv_currents', - 'get_io_output_all', - 'get_consumer_all', - 'get_consumer', - 'consumer', - 'get_consumer_currents', - 'get_consumer_voltages', - 'get_consumer_powers' - ]; - - return in_array($param, $complexParameters); - } - - /** - * Prüfen ob Parameter zu Chargepoint gehört - */ - private function isChargepointParameter($param) - { - $chargepointParameters = [ - 'set_chargemode', - 'chargecurrent', - 'minimal_pv_soc', - 'minimal_permanent_current', - 'max_price_eco', - 'chargepoint_lock', - 'bat_mode', - 'bat_power_reserve', - 'instant_charging_limit', - 'instant_charging_amount', - 'instant_charging_soc', - 'pv_charging_limit', - 'pv_charging_amount', - 'pv_charging_soc', - 'vehicle', - 'manual_soc' - ]; - - return in_array($param, $chargepointParameters) || strpos($param, 'chargepoint') !== false; - } - - /** - * Pruefen ob Parameter zu IO gehoert - */ - private function isIoParameter($param) - { - return in_array($param, ['set_io_output']) || strpos($param, 'get_io_') === 0; - } - - /** - * Erfolgs-Nachricht generieren - */ - private function getSuccessMessage($param, $value, $chargepointId) - { - switch ($param) { - case 'set_chargemode': - return "Chargemode for chargepoint {$chargepointId} set to {$value}."; - case 'chargecurrent': - return "Chargecurrent for chargepoint {$chargepointId} set to {$value}A"; - case 'instant_charging_limit': - return "Instant charging limit for chargepoint {$chargepointId} set to {$value}."; - case 'instant_charging_amount': - return "Instant charging amount for chargepoint {$chargepointId} set to {$value}kWh."; - case 'instant_charging_soc': - return "Instant charging SoC for chargepoint {$chargepointId} set to {$value}%."; - case 'pv_charging_limit': - return "PV charging limit for chargepoint {$chargepointId} set to {$value}."; - case 'pv_charging_amount': - return "PV charging amount for chargepoint {$chargepointId} set to {$value}kWh."; - case 'pv_charging_soc': - return "PV charging SoC for chargepoint {$chargepointId} set to {$value}%."; - case 'vehicle': - return "Vehicle {$value} assigned to chargepoint {$chargepointId}."; - case 'manual_soc': - return "Manual SoC set to {$value}% for chargepoint {$chargepointId}."; - case 'set_io_output': - return "IO output set to {$value} for io {$chargepointId}."; - default: - return "Parameter {$param} set to {$value}."; - } - } - - /** - * Raw-Ausgabe formatieren - */ - private function formatRawOutput($data, $paramName = null) - { - if (is_array($data)) { - $firstKey = array_keys($data)[0]; - $firstValue = $data[$firstKey]; - - // Für get_lastlivevaluesjson: JSON direkt zurückgeben - if ($paramName === 'get_lastlivevaluesjson' && is_string($firstValue)) { - return $firstValue; - } - - // Für JSON-Strings: direkt den String-Wert zurückgeben - if (is_string($firstValue)) { - return $firstValue; - } - - if (is_array($firstValue) && count($firstValue) === 1) { - return array_values($firstValue)[0]; - } - - // Für andere Array-Strukturen: ersten Wert zurückgeben - return $firstValue; - } - - return $data; - } -} - -// API instanziieren und Request verarbeiten -$api = new SimpleAPI(); -$api->handleRequest(); diff --git a/simpleAPI/src/Authenticator.php b/simpleAPI/src/Authenticator.php deleted file mode 100644 index a93af05052..0000000000 --- a/simpleAPI/src/Authenticator.php +++ /dev/null @@ -1,212 +0,0 @@ -config = $config; - $this->mqttClient = $mqttClient; - } - - /** - * Authentifizierung prüfen - */ - public function authenticate($params) - { - // Wenn keine Authentifizierung erforderlich ist - if (!$this->isAuthRequired()) { - return true; - } - - // Bearer Token prüfen - if ($this->checkBearerToken()) { - return true; - } - - // Username/Password prüfen - if ($this->checkUsernamePassword($params)) { - return true; - } - - // Token aus Parameter prüfen - if ($this->checkParameterToken($params)) { - return true; - } - - // Fallback: Prüfen ob MQTT Broker ohne Authentifizierung zugänglich ist - if ($this->checkMqttNoAuth()) { - return true; - } - - return false; - } - - /** - * Prüfen ob Authentifizierung erforderlich ist - */ - private function isAuthRequired() - { - return $this->config['auth']['enabled'] ?? false; - } - - /** - * Bearer Token aus Authorization Header prüfen - */ - private function checkBearerToken() - { - $headers = $this->getAuthorizationHeader(); - - if (!$headers) { - return false; - } - - // Bearer Token extrahieren - if (preg_match('/Bearer\s+(.*)$/i', $headers, $matches)) { - $token = $matches[1]; - return $this->validateToken($token); - } - - return false; - } - - /** - * Username/Password aus POST-Parametern prüfen - */ - private function checkUsernamePassword($params) - { - if (!isset($params['username']) || !isset($params['password'])) { - return false; - } - - $username = $params['username']; - $password = $params['password']; - - // Nur bei HTTPS erlaubt - if (!$this->isHttps() && $this->config['auth']['require_https']) { - return false; - } - - return $this->validateCredentials($username, $password); - } - - /** - * Token aus Parameter prüfen - */ - private function checkParameterToken($params) - { - if (!isset($params['token'])) { - return false; - } - - // Nur bei HTTPS erlaubt - if (!$this->isHttps() && $this->config['auth']['require_https']) { - return false; - } - - return $this->validateToken($params['token']); - } - - /** - * Token validieren - */ - private function validateToken($token) - { - $validTokens = $this->config['auth']['tokens'] ?? []; - - foreach ($validTokens as $validToken) { - if (hash_equals($validToken, $token)) { - return true; - } - } - - return false; - } - - /** - * Benutzerdaten validieren - */ - private function validateCredentials($username, $password) - { - $users = $this->config['auth']['users'] ?? []; - - // Zuerst gegen definierte Benutzer prüfen - if (!empty($users) && isset($users[$username])) { - $storedPassword = $users[$username]; - - // Passwort-Hash prüfen - if (strpos($storedPassword, '$') === 0) { - // Gehashtes Passwort - return password_verify($password, $storedPassword); - } else { - // Klartext (nicht empfohlen) - return hash_equals($storedPassword, $password); - } - } - - // Wenn keine lokalen Benutzer definiert sind oder MqttClient verfügbar ist: - // Anmeldedaten gegen MQTT Broker testen - if ($this->mqttClient && (empty($users) || !isset($users[$username]))) { - return $this->mqttClient->testCredentials($username, $password); - } - - return false; - } - - /** - * Authorization Header abrufen - */ - private function getAuthorizationHeader() - { - $headers = null; - - if (isset($_SERVER['Authorization'])) { - $headers = trim($_SERVER['Authorization']); - } elseif (isset($_SERVER['HTTP_AUTHORIZATION'])) { - $headers = trim($_SERVER['HTTP_AUTHORIZATION']); - } elseif (function_exists('apache_request_headers')) { - $requestHeaders = apache_request_headers(); - $requestHeaders = array_combine( - array_map('ucwords', array_keys($requestHeaders)), - array_values($requestHeaders) - ); - - if (isset($requestHeaders['Authorization'])) { - $headers = trim($requestHeaders['Authorization']); - } - } - - return $headers; - } - - /** - * Prüfen ob HTTPS verwendet wird - */ - private function isHttps() - { - return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || - $_SERVER['SERVER_PORT'] == 443 || - (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https'); - } - - /** - * Prüfen ob MQTT Broker ohne Authentifizierung zugänglich ist - */ - private function checkMqttNoAuth() - { - // Nur wenn MqttClient verfügbar ist - if (!$this->mqttClient) { - return false; - } - - // Test-Verbindung ohne Anmeldedaten - return $this->mqttClient->testCredentials('', ''); - } -} diff --git a/simpleAPI/src/MqttClient.php b/simpleAPI/src/MqttClient.php deleted file mode 100644 index d715f195b7..0000000000 --- a/simpleAPI/src/MqttClient.php +++ /dev/null @@ -1,359 +0,0 @@ -server = $config['mqtt']['server'] ?? 'localhost'; - $this->port = $config['mqtt']['port'] ?? 1884; - $this->validate_cert = $config['mqtt']['validate_cert'] ?? false; - $this->username = $config['mqtt']['username'] ?? ''; - $this->password = $config['mqtt']['password'] ?? ''; - $this->clientid = $config['mqtt']['clientid'] ?? 'SimpleAPI_' . uniqid(); - } - - /** - * MQTT-Anmeldedaten aktualisieren - */ - public function setCredentials($username, $password) - { - $this->username = $username; - $this->password = $password; - } - - /** - * Verbindung testen - */ - public function connect() - { - // Test-Verbindung mit mosquitto_sub - $cmd = $this->buildMosquittoCommand('sub', 'test/connection', '', 1); - $result = shell_exec($cmd . ' 2>&1'); - - // Wenn kein Fehler zurückkommt, ist die Verbindung OK - return !preg_match('/error|failed|unable/i', $result ?? ''); - } - - /** - * Anmeldedaten am MQTT Broker testen - */ - public function testCredentials($username, $password) - { - // Test-Verbindung mit mosquitto_sub (sehr kurzes Timeout) - $cmd = $this->buildMosquittoCommandWithCredentials('sub', '$SYS/broker/uptime', '', 1, 2, $username, $password); - $result = shell_exec($cmd . ' 2>&1'); - - // Prüfen auf Authentifizierungsfehler - if (preg_match('/not authorised|connection refused|authentication failed|username or password invalid/i', $result ?? '')) { - return false; - } - - // Prüfen auf andere schwerwiegende Fehler - if (preg_match('/connection error|network unreachable|no such host/i', $result ?? '')) { - return false; - } - - return true; - } - - /** - * Wert aus MQTT Topic lesen - */ - public function getValue($topic) - { - $cmd = $this->buildMosquittoCommand('sub', $topic, '', 1); - $output = shell_exec($cmd . ' 2>/dev/null'); - $value = trim($output ?? ''); - - if ($value === '') { - throw new \Exception("No data received for topic: $topic"); - } - - // Bei mosquitto_sub mit -v wird das Format "topic value" ausgegeben - // Wir müssen das Topic-Präfix entfernen - if (strpos($value, $topic . ' ') === 0) { - $value = substr($value, strlen($topic . ' ')); - } - - return $value; - } - - /** - * Mehrere Topics gleichzeitig abfragen (Performance-Optimierung) - */ - public function getMultipleValues($topics) - { - $results = []; - - $cmd = $this->buildMosquittoCommand('sub', $topics, '', count($topics)); - $cmd .= ' 2>/dev/null'; - - $output = shell_exec($cmd); - $lines = explode("\n", trim($output ?? '')); - - foreach ($lines as $line) { - if (strpos($line, ' ') !== false) { - list($topic, $value) = explode(' ', $line, 2); - $results[$topic] = $value; - } - } - - // Nur Topics zurückgeben, die erfolgreich abgerufen wurden - return $results; - } - - /** - * Alle Werte für ein MQTT-Wildcard-Topic lesen. - */ - public function getValuesByWildcard($topicPattern) - { - $results = []; - - $cmd = $this->buildMosquittoCommand('sub', $topicPattern, ''); - $cmd .= ' 2>/dev/null'; - - $output = shell_exec($cmd); - $lines = explode("\n", trim($output ?? '')); - - foreach ($lines as $line) { - if (strpos($line, ' ') !== false) { - list($topic, $value) = explode(' ', $line, 2); - $results[$topic] = $value; - } - } - - return $results; - } - - /** - * Wert in MQTT Topic schreiben - */ - public function setValue($topic, $value) - { - $cmd = $this->buildMosquittoCommand('pub', $topic, $value); - $result = shell_exec($cmd . ' 2>&1'); - - // Prüfen ob Fehler aufgetreten sind - if (preg_match('/error|failed|unable/i', $result ?? '')) { - throw new \Exception("Failed to publish to topic: $topic - $result"); - } - - return true; - } - - /** - * Mosquitto-Kommando erstellen - */ - private function buildMosquittoCommand($type, $topics, $message = '', $count = null, $timeout = 1, $extraArgs = []) - { - return $this->buildMosquittoCommandWithCredentials($type, $topics, $message, $count, $timeout, $this->username, $this->password, $extraArgs); - } - - /** - * Mosquitto-Kommando mit spezifischen Anmeldedaten erstellen - */ - private function buildMosquittoCommandWithCredentials($type, $topics, $message = '', $count = null, $timeout = 1, $username = null, $password = null, $extraArgs = []) - { - $binary = $type === 'sub' ? 'mosquitto_sub' : 'mosquitto_pub'; - - $cmd = sprintf( - "%s -h %s -p %d", - $binary, - escapeshellarg($this->server), - $this->port - ); - - // Zertifikatsvalidierung deaktivieren falls konfiguriert - if (!$this->validate_cert) { - $cmd .= " --insecure"; - } - - // Username/Passwort hinzufügen falls konfiguriert - $useUsername = $username !== null ? $username : $this->username; - $usePassword = $password !== null ? $password : $this->password; - - if (!empty($useUsername)) { - $cmd .= sprintf(" -u %s", escapeshellarg($useUsername)); - } - if (!empty($usePassword)) { - $cmd .= sprintf(" -P %s", escapeshellarg($usePassword)); - } - - // Topic(s) hinzufügen - if (is_array($topics)) { - foreach ($topics as $topic) { - $cmd .= sprintf(" -t %s", escapeshellarg($topic)); - } - } else { - $cmd .= sprintf(" -t %s", escapeshellarg($topics)); - } - - // Count und Timeout für subscribe - if ($type === 'sub') { - // Ausgabe des Topics erzwingen - $cmd .= " -v"; - if ($count !== null) { - $cmd .= sprintf(" -C %d", intval($count)); - } - if ($timeout !== null) { - $cmd .= sprintf(" -W %d", intval($timeout)); - } - } - - // Message für publish - if ($type === 'pub' && $message !== '') { - $cmd .= sprintf(" -m %s", escapeshellarg($message)); - } - - // Extra-Argumente hinzufügen - foreach ($extraArgs as $arg) { - $cmd .= " " . $arg; - } - - return $cmd; - } - - /** - * Scan-Konfiguration je Typ: welches Wildcard-Topic zuverlässig für jedes konfigurierte - * Gerät dieses Typs publiziert wird, und wie die ID daraus extrahiert wird. - */ - private function getScanConfig() - { - return [ - 'chargepoint' => ['pattern' => 'openWB/chargepoint/+/get/imported', 'regex' => '/openWB\/chargepoint\/(\d+)\/get\/imported\s+(.+)/'], - 'bat' => ['pattern' => 'openWB/bat/+/get/imported', 'regex' => '/openWB\/bat\/(\d+)\/get\/imported\s+(.+)/'], - 'pv' => ['pattern' => 'openWB/pv/+/get/exported', 'regex' => '/openWB\/pv\/(\d+)\/get\/exported\s+(.+)/'], - 'counter' => ['pattern' => 'openWB/counter/+/get/imported', 'regex' => '/openWB\/counter\/(\d+)\/get\/imported\s+(.+)/'], - 'consumer' => ['pattern' => 'openWB/consumer/+/module', 'regex' => '/openWB\/consumer\/(\d+)\/module\s+(.+)/'], - 'io' => ['pattern' => 'openWB/io/states/+/get/digital_output', 'regex' => '/openWB\/io\/states\/(\d+)\/get\/digital_output\s+(.+)/'] - ]; - } - - /** - * Alle verfügbaren IDs für einen bestimmten Typ finden (MQTT Wildcard Scan). - * Wirft eine Exception, wenn keine Geräte gefunden wurden (genutzt von getLowestId, wo ein - * leeres Ergebnis ein echter Fehlerfall ist). - */ - public function findAvailableIds($type) - { - $ids = $this->scanAvailableIds($type); - - if (empty($ids)) { - throw new \Exception("No {$type} devices found via MQTT wildcard scan"); - } - - return $ids; - } - - /** - * Wie findAvailableIds(), aber ohne Exception bei leerem Ergebnis - für list_components, wo - * ein Typ ohne konfigurierte Geräte kein Fehler, sondern schlicht eine leere Liste ist. - */ - private function scanAvailableIds($type) - { - $config = $this->getScanConfig()[$type] ?? null; - if ($config === null) { - throw new \Exception("Unsupported type for ID scan: {$type}"); - } - - $pattern = $config['pattern']; - $matchRegex = $config['regex']; - - $cmd = $this->buildMosquittoCommand('sub', $pattern, ''); - $cmd .= ' 2>/dev/null'; - - $output = shell_exec($cmd); - $ids = []; - - if ($output) { - $lines = explode("\n", trim($output)); - foreach ($lines as $line) { - if (preg_match($matchRegex, $line, $matches)) { - $id = intval($matches[1]); - $value = trim($matches[2]); - - // Nur IDs mit gültigen Werten (bei IO/Consumer auch JSON erlaubt) - if ($value !== '' && $value !== 'null' && - ($type === 'io' || $type === 'consumer' || is_numeric($value))) { - $ids[] = $id; - } - } - } - } - - // array_unique() zuerst (behält Original-Keys, ggf. lückenhaft), dann sort() zum - // Schluss, da sort() die Keys wieder lückenlos ab 0 durchnummeriert - sonst würde - // json_encode() bei entfernten Duplikaten ein Objekt statt ein Array ausgeben. - $ids = array_unique($ids); - sort($ids, SORT_NUMERIC); - return $ids; - } - - /** - * Alle verfügbaren IDs für alle bekannten Typen auf einmal finden (für list_components). - * Typen ohne konfigurierte Geräte liefern eine leere Liste statt einer Exception. - */ - public function findAllAvailableIds() - { - $result = []; - foreach (array_keys($this->getScanConfig()) as $type) { - // Öffentlicher Name: 'bat' intern, aber 'battery' in Parametern/Antworten üblich. - $publicName = $type === 'bat' ? 'battery' : $type; - $result[$publicName] = $this->scanAvailableIds($type); - } - return $result; - } - - /** - * Niedrigste verfügbare ID für einen Typ finden (Dynamische Erkennung) - */ - public function getLowestId($type) - { - // Cache prüfen - if (isset(self::$knownIds[$type])) { - return self::$knownIds[$type]; - } - - // Alle verfügbaren IDs für diesen Typ finden - $availableIds = $this->findAvailableIds($type); - - if (!empty($availableIds)) { - // Niedrigste ID zurückgeben - sort($availableIds, SORT_NUMERIC); - $lowestId = $availableIds[0]; - self::$knownIds[$type] = $lowestId; - return $lowestId; - } - - return null; - } - - /** - * Verbindung schließen (dummy für Kompatibilität) - */ - public function disconnect() - { - // Nicht nötig bei mosquitto_sub/pub - } - - public function __destruct() - { - $this->disconnect(); - } -} diff --git a/simpleAPI/src/MqttClientSimple.php b/simpleAPI/src/MqttClientSimple.php deleted file mode 100644 index 26848fbee2..0000000000 --- a/simpleAPI/src/MqttClientSimple.php +++ /dev/null @@ -1,159 +0,0 @@ -server = $config['mqtt']['server'] ?? 'localhost'; - $this->port = $config['mqtt']['port'] ?? 1883; - $this->username = $config['mqtt']['username'] ?? ''; - $this->password = $config['mqtt']['password'] ?? ''; - $this->clientid = $config['mqtt']['clientid'] ?? 'SimpleAPI_' . uniqid(); - } - - /** - * Verbindung testen - */ - public function connect() - { - // Test-Verbindung mit mosquitto_sub - $cmd = $this->buildMosquittoCommand('sub', 'test/connection', '', ['-C', '1', '-W', '1']); - $result = shell_exec($cmd . ' 2>&1'); - - // Wenn kein Fehler zurückkommt, ist die Verbindung OK - return !preg_match('/error|failed|unable/i', $result ?? ''); - } - - /** - * Wert aus MQTT Topic lesen - */ - public function getValue($topic) - { - $cmd = $this->buildMosquittoCommand('sub', $topic, '', ['-C', '1', '-W', '3']); - $output = shell_exec($cmd . ' 2>/dev/null'); - $value = trim($output ?? ''); - - if ($value === '') { - throw new \Exception("No data received for topic: $topic"); - } - - return $value; - } - - /** - * Wert in MQTT Topic schreiben - */ - public function setValue($topic, $value) - { - $cmd = $this->buildMosquittoCommand('pub', $topic, $value); - $result = shell_exec($cmd . ' 2>&1'); - - // Prüfen ob Fehler aufgetreten sind - if (preg_match('/error|failed|unable/i', $result ?? '')) { - throw new \Exception("Failed to publish to topic: $topic - $result"); - } - - return true; - } - - /** - * Mosquitto-Kommando erstellen - */ - private function buildMosquittoCommand($type, $topic, $message = '', $extraArgs = []) - { - $binary = $type === 'sub' ? 'mosquitto_sub' : 'mosquitto_pub'; - - $cmd = sprintf( - "%s -h %s -p %d", - $binary, - escapeshellarg($this->server), - $this->port - ); - - // Username/Passwort hinzufügen falls konfiguriert - if (!empty($this->username)) { - $cmd .= sprintf(" -u %s", escapeshellarg($this->username)); - } - if (!empty($this->password)) { - $cmd .= sprintf(" -P %s", escapeshellarg($this->password)); - } - - // Topic hinzufügen - $cmd .= sprintf(" -t %s", escapeshellarg($topic)); - - // Message für publish - if ($type === 'pub' && $message !== '') { - $cmd .= sprintf(" -m %s", escapeshellarg($message)); - } - - // Extra-Argumente hinzufügen - foreach ($extraArgs as $arg) { - $cmd .= " " . $arg; - } - - return $cmd; - } - - /** - * Alle verfügbaren IDs für einen bestimmten Typ finden - */ - public function findAvailableIds($type) - { - $ids = []; - $maxIds = $type === 'chargepoint' ? 8 : 10; - - for ($i = 0; $i <= $maxIds; $i++) { - try { - $testTopic = "openWB/{$type}/{$i}/get/power"; - $value = $this->getValue($testTopic); - - if ($value !== null && $value !== '') { - $ids[] = $i; - } - } catch (\Exception $e) { - // ID nicht verfügbar, weiter - continue; - } - } - - if (empty($ids)) { - throw new \Exception("No {$type} devices found via MQTT"); - } - - return $ids; - } - - /** - * Niedrigste verfügbare ID für einen Typ finden - */ - public function getLowestId($type) - { - $ids = $this->findAvailableIds($type); - return min($ids); - } - - /** - * Verbindung schließen (dummy für Kompatibilität) - */ - public function disconnect() - { - // Nicht nötig bei mosquitto_sub/pub - } - - public function __destruct() - { - $this->disconnect(); - } -} diff --git a/simpleAPI/src/ParameterHandler.php b/simpleAPI/src/ParameterHandler.php deleted file mode 100644 index 8e886b8167..0000000000 --- a/simpleAPI/src/ParameterHandler.php +++ /dev/null @@ -1,2986 +0,0 @@ -mqttClient = $mqttClient; - } - - /** - * Parameter lesen - */ - public function readParameter($param, $id, $options = []) - { - switch ($param) { - case 'get_chargepoint_all': - return $this->getChargepointAll($id); - - case 'get_chargepoint_voltage_p1': - return $this->getChargepointVoltage($id, 1); - - case 'get_chargepoint_voltage_p2': - return $this->getChargepointVoltage($id, 2); - - case 'get_chargepoint_voltage_p3': - return $this->getChargepointVoltage($id, 3); - - case 'get_chargepoint_voltages': - return $this->getChargepointVoltages($id); - - case 'get_chargepoint_current_p1': - return $this->getChargepointCurrent($id, 1); - - case 'get_chargepoint_current_p2': - return $this->getChargepointCurrent($id, 2); - - case 'get_chargepoint_current_p3': - return $this->getChargepointCurrent($id, 3); - - case 'get_chargepoint_currents': - return $this->getChargepointCurrents($id); - - case 'get_chargepoint_power': - return $this->getChargepointPower($id); - - case 'get_chargepoint_powers': - return $this->getChargepointPowers($id); - - case 'battery': - return $this->getBattery($id); - - case 'pv': - return $this->getPv($id); - - case 'get_counter': - return $this->getCounter($id); - - // Chargepoint - Einzelwerte - case 'get_chargepoint_imported': - return $this->getChargepointImported($id); - case 'get_chargepoint_exported': - return $this->getChargepointExported($id); - case 'get_chargepoint_daily_imported': - return $this->getChargepointDailyImported($id); - case 'get_chargepoint_daily_exported': - return $this->getChargepointDailyExported($id); - case 'get_chargepoint_monthly_exported': - return $this->getChargepointMonthlyExported($id); - case 'get_chargepoint_yearly_exported': - return $this->getChargepointYearlyExported($id); - case 'get_chargepoint_frequency': - return $this->getChargepointFrequency($id); - case 'get_chargepoint_rfid': - return $this->getChargepointRfid($id); - case 'get_chargepoint_rfid_timestamp': - return $this->getChargepointRfidTimestamp($id); - case 'get_chargepoint_evse_current': - return $this->getChargepointEvseCurrent($id); - case 'get_chargepoint_power_factors': - return $this->getChargepointPowerFactors($id); - case 'get_chargepoint_power_factor_p1': - return $this->getChargepointPowerFactor($id, 1); - case 'get_chargepoint_power_factor_p2': - return $this->getChargepointPowerFactor($id, 2); - case 'get_chargepoint_power_factor_p3': - return $this->getChargepointPowerFactor($id, 3); - case 'get_chargepoint_config_name': - return $this->getChargepointConfigName($id); - case 'get_chargepoint_connected_vehicle_name': - return $this->getChargepointConnectedVehicleName($id); - case 'get_chargepoint_charge_template_name': - return $this->getChargepointChargeTemplateName($id); - case 'get_chargepoint_charge_template_min_current': - return $this->getChargepointChargeTemplateMinCurrent($id); - case 'get_chargepoint_instant_charging_current': - return $this->getChargepointInstantChargingCurrent($id); - case 'get_chargepoint_pv_charging_min_current': - return $this->getChargepointPvChargingMinCurrent($id); - case 'get_chargepoint_soc': - return $this->getChargepointSoc($id); - case 'get_chargepoint_state_str': - return $this->getChargepointStateStr($id); - case 'get_chargepoint_fault_str': - return $this->getChargepointFaultStr($id); - case 'get_chargepoint_fault_state': - return $this->getChargepointFaultState($id); - case 'get_chargepoint_phases_in_use': - return $this->getChargepointPhasesInUse($id); - case 'get_chargepoint_plug_state': - return $this->getChargepointPlugState($id); - case 'get_chargepoint_charge_state': - return $this->getChargepointChargeState($id); - case 'get_chargepoint_chargemode': - return $this->getChargepointChargemode($id); - - // Counter - Einzelwerte - case 'get_counter_voltage_p1': - return $this->getCounterVoltageP1($id); - case 'get_counter_voltage_p2': - return $this->getCounterVoltageP2($id); - case 'get_counter_voltage_p3': - return $this->getCounterVoltageP3($id); - case 'get_counter_voltages': - return $this->getCounterVoltages($id); - case 'get_counter_current_p1': - return $this->getCounterCurrentP1($id); - case 'get_counter_current_p2': - return $this->getCounterCurrentP2($id); - case 'get_counter_current_p3': - return $this->getCounterCurrentP3($id); - case 'get_counter_currents': - return $this->getCounterCurrents($id); - case 'get_counter_power': - return $this->getCounterPower($id); - case 'get_counter_powers': - return $this->getCounterPowers($id); - case 'get_counter_power_factors': - return $this->getCounterPowerFactors($id); - case 'get_counter_imported': - return $this->getCounterImported($id); - case 'get_counter_exported': - return $this->getCounterExported($id); - case 'get_counter_daily_imported': - return $this->getCounterDailyImported($id); - case 'get_counter_daily_exported': - return $this->getCounterDailyExported($id); - case 'get_counter_frequency': - return $this->getCounterFrequency($id); - case 'get_counter_fault_str': - return $this->getCounterFaultStr($id); - case 'get_counter_fault_state': - return $this->getCounterFaultState($id); - - // Battery - Zusätzliche Einzelwerte - case 'get_battery': - return $this->getBattery($id); - case 'get_battery_power': - return $this->getBatteryPower($id); - case 'get_battery_soc': - return $this->getBatterySoc($id); - case 'get_battery_currents': - return $this->getBatteryCurrents($id); - case 'get_battery_imported': - return $this->getBatteryImported($id); - case 'get_battery_exported': - return $this->getBatteryExported($id); - case 'get_battery_daily_imported': - return $this->getBatteryDailyImported($id); - case 'get_battery_daily_exported': - return $this->getBatteryDailyExported($id); - case 'get_battery_fault_str': - return $this->getBatteryFaultStr($id); - case 'get_battery_fault_state': - return $this->getBatteryFaultState($id); - case 'get_battery_power_limit_controllable': - return $this->getBatteryPowerLimitControllable($id); - - // PV - Zusätzliche Einzelwerte - case 'get_pv': - return $this->getPv($id); - case 'get_pv_power': - return $this->getPvPower($id); - case 'get_pv_currents': - return $this->getPvCurrents($id); - case 'get_pv_imported': - return $this->getPvImported($id); - case 'get_pv_exported': - return $this->getPvExported($id); - case 'get_pv_daily_exported': - return $this->getPvDailyExported($id); - case 'get_pv_monthly_exported': - return $this->getPvMonthlyExported($id); - case 'get_pv_yearly_exported': - return $this->getPvYearlyExported($id); - case 'get_pv_fault_str': - return $this->getPvFaultStr($id); - case 'get_pv_fault_state': - return $this->getPvFaultState($id); - - // Consumer - Alle Daten - case 'consumer': - case 'get_consumer': - case 'get_consumer_all': - return $this->getConsumerAll($id); - // Consumer - Einzelwerte - case 'get_consumer_power': - return $this->getConsumerPower($id); - case 'get_consumer_currents': - return $this->getConsumerCurrents($id); - case 'get_consumer_voltages': - return $this->getConsumerVoltages($id); - case 'get_consumer_powers': - return $this->getConsumerPowers($id); - case 'get_consumer_imported': - return $this->getConsumerImported($id); - case 'get_consumer_exported': - return $this->getConsumerExported($id); - case 'get_consumer_daily_imported': - return $this->getConsumerDailyImported($id); - case 'get_consumer_phases_in_use': - return $this->getConsumerPhasesInUse($id); - case 'get_consumer_charge_state': - return $this->getConsumerChargeState($id); - case 'get_consumer_state_str': - return $this->getConsumerStateStr($id); - case 'get_consumer_fault_str': - return $this->getConsumerFaultStr($id); - case 'get_consumer_fault_state': - return $this->getConsumerFaultState($id); - case 'get_consumer_usage_type': - return $this->getConsumerUsageType($id); - - case 'get_io_output_all': - return $this->getIoOutputAll($id); - case 'get_io_output': - return $this->getIoOutput($id, $options['io_output'] ?? null, $options['io_output_type'] ?? null); - - case 'get_lastlivevaluesjson': - return $this->getLastLiveValuesJson(); - - default: - return null; - } - } - - /** - * Parameter schreiben - */ - public function writeParameter($param, $value, $targetId = null, $options = []) - { - try { - switch ($param) { - case 'set_chargemode': - return $this->setChargemode($targetId, $value); - case 'chargecurrent': - return $this->setChargecurrent($targetId, $value); - case 'minimal_permanent_current': - return $this->setMinimalPermanentCurrent($targetId, $value); - case 'minimal_pv_soc': - return $this->setMinimalPvSoc($targetId, $value); - case 'max_price_eco': - return $this->setMaxPriceEco($targetId, $value); - case 'chargepoint_lock': - return $this->setChargepointLock($targetId, $value); - case 'bat_mode': - return $this->setBatMode($value); - case 'bat_power_reserve': - return $this->setBatPowerReserve($value); - case 'instant_charging_limit': - return $this->setInstantChargingLimit($targetId, $value); - case 'instant_charging_amount': - return $this->setInstantChargingAmount($targetId, $value); - case 'instant_charging_soc': - return $this->setInstantChargingSoc($targetId, $value); - case 'pv_charging_limit': - return $this->setPvChargingLimit($targetId, $value); - case 'pv_charging_amount': - return $this->setPvChargingAmount($targetId, $value); - case 'pv_charging_soc': - return $this->setPvChargingSoc($targetId, $value); - case 'vehicle': - return $this->setVehicle($targetId, $value); - case 'manual_soc': - return $this->setManualSoc($targetId, $value); - case 'set_io_output': - return $this->setIoOutput($targetId, $value, $options['io_output'] ?? null, $options['io_output_type'] ?? null); - default: - return ['success' => false, 'message' => 'Unknown write parameter']; - } - } catch (Exception $e) { - return ['success' => false, 'message' => $e->getMessage()]; - } - } - - /** - * Alle Daten eines Ladepunkts (Performance-optimiert) - */ - private function getChargepointAll($id) - { - $prefix = "openWB/chargepoint/{$id}/get/"; - - // Alle benötigten Topics in einem Aufruf abfragen - $topics = [ - $prefix . 'power', - $prefix . 'voltages', - $prefix . 'currents', - $prefix . 'powers', - $prefix . 'state_str', - $prefix . 'fault_str', - $prefix . 'fault_state', - $prefix . 'imported', - $prefix . 'exported', - $prefix . 'daily_imported', - $prefix . 'daily_exported', - $prefix . 'phases_in_use', - $prefix . 'plug_state', - $prefix . 'charge_state', - $prefix . 'soc', - $prefix . 'soc_timestamp', - $prefix . 'vehicle_id', - $prefix . 'evse_current', - $prefix . 'frequency', - $prefix . 'power_factors', - $prefix . 'rfid', - $prefix . 'rfid_timestamp', - $prefix . 'connected_vehicle/info', - "openWB/chargepoint/{$id}/config", - - "openWB/chargepoint/{$id}/set/charge_template" - ]; - - $values = $this->mqttClient->getMultipleValues($topics); - - // Arrays parsen - try { - $voltages = json_decode($values[$prefix . 'voltages'] ?? '[]', true) ?: [0, 0, 0]; - $currents = json_decode($values[$prefix . 'currents'] ?? '[]', true) ?: [0, 0, 0]; - $powers = json_decode($values[$prefix . 'powers'] ?? '[]', true) ?: [0, 0, 0]; - $powerFactors = json_decode($values[$prefix . 'power_factors'] ?? '[]', true) ?: [0, 0, 0]; - } catch (Exception $e) { - $voltages = [0, 0, 0]; - $currents = [0, 0, 0]; - $powers = [0, 0, 0]; - $powerFactors = [0, 0, 0]; - } - - // Chargemode und min_current aus Template extrahieren - $chargemode = 'stop'; - $minCurrent = 0; - $instantChargingCurrent = 0; - $pvChargingMinCurrent = 0; - $chargeTemplateName = null; - $connectedVehicleName = null; - $configName = null; - $instantChargingLimit = 'none'; - $instantChargingAmount = 0; - $instantChargingSoc = 0; - $pvChargingLimit = 'none'; - $pvChargingAmount = 0; - $pvChargingSoc = 0; - $maxPriceEco = 0; - $soc = 0; - $rangeCharged = 0; - - try { - $template = json_decode($values["openWB/chargepoint/{$id}/set/charge_template"] ?? '{}', true); - $chargemode = $template['chargemode']['selected'] ?? 'stop'; - $chargeTemplateName = $template['name'] ?? null; - - // Alle relevanten Ströme aus dem Template extrahieren - $instantChargingCurrent = $template['chargemode']['instant_charging']['current'] ?? 0; - $pvChargingMinCurrent = $template['chargemode']['pv_charging']['min_current'] ?? 0; - - // Instant Charging Limit-Parameter extrahieren - $instantChargingLimit = $template['chargemode']['instant_charging']['limit']['selected'] ?? 'none'; - $instantChargingAmount = $template['chargemode']['instant_charging']['limit']['amount'] ?? 0; - $instantChargingSoc = $template['chargemode']['instant_charging']['limit']['soc'] ?? 0; - - // PV Charging Limit-Parameter extrahieren - $pvChargingLimit = $template['chargemode']['pv_charging']['limit']['selected'] ?? 'none'; - $pvChargingAmount = $template['chargemode']['pv_charging']['limit']['amount'] ?? 0; - $pvChargingSoc = $template['chargemode']['pv_charging']['limit']['soc'] ?? 0; - - // ECO Charging max_price extrahieren - $maxPriceEco = isset($template['chargemode']['eco_charging']['max_price']) ? number_format((float)$template['chargemode']['eco_charging']['max_price'], 6, '.', '') : '0.0000'; - $maxPriceEco = $maxPriceEco * 100000; - - // SoC und Range aus connected_vehicle extrahieren - $connectedVehicleSocTopic = "openWB/chargepoint/{$id}/get/connected_vehicle/soc"; - $connectedVehicleSocValue = $this->mqttClient->getValue($connectedVehicleSocTopic); - $connectedVehicleSocData = json_decode($connectedVehicleSocValue ?? '{}', true); - $soc = $connectedVehicleSocData['soc'] ?? 0; - $rangeCharged = $connectedVehicleSocData['range_charged'] ?? 0; - - // min_current aus dem entsprechenden Lademodus extrahieren - switch ($chargemode) { - case 'instant_charging': - $minCurrent = $instantChargingCurrent; - break; - case 'pv_charging': - case 'eco_charging': - $minCurrent = $pvChargingMinCurrent; - break; - case 'scheduled_charging': - $minCurrent = $template['chargemode']['scheduled_charging']['current'] ?? 0; - break; - } - } catch (Exception $e) { - // Fallback - } - - // Connected Vehicle Name aus JSON extrahieren - try { - $connectedVehicleInfo = json_decode($values[$prefix . 'connected_vehicle/info'] ?? '{}', true); - $connectedVehicleName = $connectedVehicleInfo['name'] ?? null; - } catch (Exception $e) { - // Fallback - } - - // Config Name aus JSON extrahieren - try { - $configInfo = json_decode($values["openWB/chargepoint/{$id}/config"] ?? '{}', true); - $configName = $configInfo['name'] ?? null; - } catch (Exception $e) { - // Fallback - } - - $data = [ - "chargepoint_{$id}" => [ - 'power' => floatval($values[$prefix . 'power'] ?? 0), - 'voltages' => [ - floatval($voltages[0] ?? 0), - floatval($voltages[1] ?? 0), - floatval($voltages[2] ?? 0) - ], - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ], - 'powers' => [ - floatval($powers[0] ?? 0), - floatval($powers[1] ?? 0), - floatval($powers[2] ?? 0) - ], - 'state_str' => $this->decodeStringValue($values[$prefix . 'state_str'] ?? null, 'Unbekannt'), - 'fault_str' => $this->decodeStringValue($values[$prefix . 'fault_str'] ?? null, 'Kein Fehler'), - 'fault_state' => intval($values[$prefix . 'fault_state'] ?? 0), - 'imported' => floatval($values[$prefix . 'imported'] ?? 0), - 'exported' => floatval($values[$prefix . 'exported'] ?? 0), - 'daily_imported' => floatval($values[$prefix . 'daily_imported'] ?? 0), - 'daily_exported' => floatval($values[$prefix . 'daily_exported'] ?? 0), - 'phases_in_use' => intval($values[$prefix . 'phases_in_use'] ?? 1), - 'plug_state' => $this->parseBooleanValue($values[$prefix . 'plug_state'] ?? 'false'), - 'charge_state' => $this->parseBooleanValue($values[$prefix . 'charge_state'] ?? 'false'), - 'pro_soc' => floatval($values[$prefix . 'soc'] ?? 0), - 'soc_timestamp' => $values[$prefix . 'soc_timestamp'] ?? null, - 'vehicle_id' => $values[$prefix . 'vehicle_id'] ?? null, - 'evse_current' => floatval($values[$prefix . 'evse_current'] ?? 0), - 'frequency' => floatval($values[$prefix . 'frequency'] ?? 0), - 'power_factors' => [ - floatval($powerFactors[0] ?? 0), - floatval($powerFactors[1] ?? 0), - floatval($powerFactors[2] ?? 0) - ], - 'rfid' => $this->decodeStringValue($values[$prefix . 'rfid'] ?? null, null), - 'rfid_timestamp' => $values[$prefix . 'rfid_timestamp'] ?? null, - 'config_name' => $configName, - 'connected_vehicle_name' => $connectedVehicleName, - 'charge_template_name' => $chargeTemplateName, - 'min_current' => floatval($minCurrent), - 'instant_charging_current' => floatval($instantChargingCurrent), - 'pv_charging_min_current' => floatval($pvChargingMinCurrent), - 'instant_charging_limit' => $instantChargingLimit, - 'instant_charging_amount' => intval($instantChargingAmount), - 'instant_charging_soc' => intval($instantChargingSoc), - 'pv_charging_limit' => $pvChargingLimit, - 'pv_charging_amount' => intval($pvChargingAmount), - 'pv_charging_soc' => intval($pvChargingSoc), - 'max_price_eco' => floatval($maxPriceEco), - 'soc' => floatval($soc), - 'range_charged' => floatval($rangeCharged), - 'chargemode' => $chargemode - ] - ]; - - // manual_lock Status auslesen - $manualLockTopic = "openWB/chargepoint/{$id}/set/manual_lock"; - try { - $manualLock = $this->mqttClient->getValue($manualLockTopic); - } catch (Exception $e) { - $manualLock = 'false'; - } - $data["chargepoint_{$id}"]['manual_lock'] = $this->parseBooleanValue($manualLock ?? 'false'); - - return $data; - } - - /** - * Boolean-Wert parsen - */ - private function parseBooleanValue($value) - { - if (is_bool($value)) { - return $value; - } - - $value = strtolower(trim($value, '"')); - return in_array($value, ['true', '1', 'yes', 'on']); - } - - /** - * String-Wert aus MQTT dekodieren. OpenWB kodiert auch reine String-Payloads per - * json.dumps() (z.B. "Kein Fehler." statt Kein Fehler.) - ohne json_decode() blieben die - * Anführungszeichen im Wert erhalten und würden beim äußeren json_encode() der Antwort - * nochmal escaped. Fällt bei nicht-JSON-Werten (z.B. leer) auf den Rohwert/Default zurück. - */ - private function decodeStringValue($value, $default = '') - { - if ($value === null || $value === '') { - return $default; - } - $decoded = json_decode($value, true); - return is_string($decoded) ? $decoded : $value; - } - - /** - * Spannung einer Phase - */ - private function getChargepointVoltage($id, $phase) - { - // OpenWB gibt Spannungen als Array zurück: [237.79, 0, 0] - $topic = "openWB/chargepoint/{$id}/get/voltages"; - $voltagesJson = $this->mqttClient->getValue($topic); - - try { - $voltages = json_decode($voltagesJson, true); - $voltage = $voltages[$phase - 1] ?? 0; // Array ist 0-basiert, Phase 1-basiert - - return [ - "chargepoint_{$id}" => [ - "voltage_p{$phase}" => floatval($voltage) - ] - ]; - } catch (Exception $e) { - return [ - "chargepoint_{$id}" => [ - "voltage_p{$phase}" => 0 - ] - ]; - } - } - - /** - * Alle Spannungen - */ - private function getChargepointVoltages($id) - { - $topic = "openWB/chargepoint/{$id}/get/voltages"; - $voltagesJson = $this->mqttClient->getValue($topic); - - try { - $voltages = json_decode($voltagesJson, true); - - return [ - "chargepoint_{$id}" => [ - 'voltages' => [ - floatval($voltages[0] ?? 0), - floatval($voltages[1] ?? 0), - floatval($voltages[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "chargepoint_{$id}" => [ - 'voltages' => [0, 0, 0] - ] - ]; - } - } - - /** - * Strom einer Phase - */ - private function getChargepointCurrent($id, $phase) - { - // OpenWB gibt Ströme als Array zurück: [0, 0, 0] - $topic = "openWB/chargepoint/{$id}/get/currents"; - $currentsJson = $this->mqttClient->getValue($topic); - - try { - $currents = json_decode($currentsJson, true); - $current = $currents[$phase - 1] ?? 0; // Array ist 0-basiert, Phase 1-basiert - - return [ - "chargepoint_{$id}" => [ - "current_p{$phase}" => floatval($current) - ] - ]; - } catch (Exception $e) { - return [ - "chargepoint_{$id}" => [ - "current_p{$phase}" => 0 - ] - ]; - } - } - - /** - * Alle Ströme - */ - private function getChargepointCurrents($id) - { - $topic = "openWB/chargepoint/{$id}/get/currents"; - $currentsJson = $this->mqttClient->getValue($topic); - - try { - $currents = json_decode($currentsJson, true); - - return [ - "chargepoint_{$id}" => [ - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "chargepoint_{$id}" => [ - 'currents' => [0, 0, 0] - ] - ]; - } - } - - /** - * Gesamtleistung - */ - private function getChargepointPower($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('chargepoint', 'power'); - return ['chargepoint_total' => ['power' => $sum]]; - } - - try { - $topic = "openWB/chargepoint/{$id}/get/power"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['power' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['power' => 0]]; - } - } - - /** - * Leistung aller Phasen - */ - private function getChargepointPowers($id) - { - $topic = "openWB/chargepoint/{$id}/get/powers"; - $powersJson = $this->mqttClient->getValue($topic); - - try { - $powers = json_decode($powersJson, true); - - return [ - "chargepoint_{$id}" => [ - 'powers' => [ - floatval($powers[0] ?? 0), - floatval($powers[1] ?? 0), - floatval($powers[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "chargepoint_{$id}" => [ - 'powers' => [0, 0, 0] - ] - ]; - } - } - - /** - * Batterie-Daten (Performance-optimiert) - */ - private function getBattery($id) - { - $prefix = "openWB/bat/{$id}/get/"; - - // Alle benötigten Topics in einem Aufruf abfragen - $topics = [ - $prefix . 'power', - $prefix . 'soc', - $prefix . 'currents', - $prefix . 'imported', - $prefix . 'exported', - $prefix . 'daily_imported', - $prefix . 'daily_exported', - $prefix . 'fault_str', - $prefix . 'fault_state', - $prefix . 'power_limit_controllable' - ]; - - $values = $this->mqttClient->getMultipleValues($topics); - - // Currents Array parsen - try { - $currents = json_decode($values[$prefix . 'currents'] ?? '[]', true) ?: [0, 0, 0]; - } catch (Exception $e) { - $currents = [0, 0, 0]; - } - - return [ - "battery_{$id}" => [ - 'power' => floatval($values[$prefix . 'power'] ?? 0), - 'soc' => intval($values[$prefix . 'soc'] ?? 0), - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ], - 'imported' => floatval($values[$prefix . 'imported'] ?? 0), - 'exported' => floatval($values[$prefix . 'exported'] ?? 0), - 'daily_imported' => floatval($values[$prefix . 'daily_imported'] ?? 0), - 'daily_exported' => floatval($values[$prefix . 'daily_exported'] ?? 0), - 'fault_str' => $this->decodeStringValue($values[$prefix . 'fault_str'] ?? null, 'Kein Fehler'), - 'fault_state' => intval($values[$prefix . 'fault_state'] ?? 0), - 'power_limit_controllable' => $this->parseBooleanValue($values[$prefix . 'power_limit_controllable'] ?? 'false') - ] - ]; - } - - /** - * PV-Daten (Performance-optimiert) - */ - private function getPv($id) - { - $prefix = "openWB/pv/{$id}/get/"; - - // Alle benötigten Topics in einem Aufruf abfragen - $topics = [ - $prefix . 'power', - $prefix . 'currents', - $prefix . 'exported', - $prefix . 'daily_exported', - $prefix . 'monthly_exported', - $prefix . 'yearly_exported', - $prefix . 'fault_str', - $prefix . 'fault_state' - ]; - - $values = $this->mqttClient->getMultipleValues($topics); - - // Currents Array parsen - try { - $currents = json_decode($values[$prefix . 'currents'] ?? '[]', true) ?: [0, 0, 0]; - } catch (Exception $e) { - $currents = [0, 0, 0]; - } - - return [ - "pv_{$id}" => [ - 'power' => floatval($values[$prefix . 'power'] ?? 0), - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ], - 'exported' => floatval($values[$prefix . 'exported'] ?? 0), - 'daily_exported' => floatval($values[$prefix . 'daily_exported'] ?? 0), - 'monthly_exported' => floatval($values[$prefix . 'monthly_exported'] ?? 0), - 'yearly_exported' => floatval($values[$prefix . 'yearly_exported'] ?? 0), - 'fault_str' => $this->decodeStringValue($values[$prefix . 'fault_str'] ?? null, 'Kein Fehler'), - 'fault_state' => intval($values[$prefix . 'fault_state'] ?? 0) - ] - ]; - } - - /** - * Verbraucher-Daten (Consumer) - Performance-optimiert - */ - private function getConsumerAll($id) - { - $prefix = "openWB/consumer/{$id}/get/"; - - // Alle benötigten Topics in einem Aufruf abfragen - $topics = [ - $prefix . 'power', - $prefix . 'currents', - $prefix . 'voltages', - $prefix . 'powers', - $prefix . 'imported', - $prefix . 'exported', - $prefix . 'daily_imported', - $prefix . 'phases_in_use', - $prefix . 'charge_state', - $prefix . 'state_str', - $prefix . 'fault_str', - $prefix . 'fault_state', - "openWB/consumer/{$id}/usage" - ]; - - $values = $this->mqttClient->getMultipleValues($topics); - - // Arrays parsen - try { - $currents = json_decode($values[$prefix . 'currents'] ?? '[]', true) ?: [0, 0, 0]; - $voltages = json_decode($values[$prefix . 'voltages'] ?? '[]', true) ?: [0, 0, 0]; - $powers = json_decode($values[$prefix . 'powers'] ?? '[]', true) ?: [0, 0, 0]; - } catch (Exception $e) { - $currents = [0, 0, 0]; - $voltages = [0, 0, 0]; - $powers = [0, 0, 0]; - } - - $usageType = null; - try { - $usageInfo = json_decode($values["openWB/consumer/{$id}/usage"] ?? '{}', true); - $usageType = $usageInfo['type'] ?? null; - } catch (Exception $e) { - // Fallback - } - - return [ - "consumer_{$id}" => [ - 'usage_type' => $usageType, - 'power' => floatval($values[$prefix . 'power'] ?? 0), - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ], - 'voltages' => [ - floatval($voltages[0] ?? 0), - floatval($voltages[1] ?? 0), - floatval($voltages[2] ?? 0) - ], - 'powers' => [ - floatval($powers[0] ?? 0), - floatval($powers[1] ?? 0), - floatval($powers[2] ?? 0) - ], - 'imported' => floatval($values[$prefix . 'imported'] ?? 0), - 'exported' => floatval($values[$prefix . 'exported'] ?? 0), - 'daily_imported' => floatval($values[$prefix . 'daily_imported'] ?? 0), - 'phases_in_use' => intval($values[$prefix . 'phases_in_use'] ?? 0), - 'charge_state' => $this->parseBooleanValue($values[$prefix . 'charge_state'] ?? 'false'), - 'state_str' => $this->decodeStringValue($values[$prefix . 'state_str'] ?? null, null), - 'fault_str' => $this->decodeStringValue($values[$prefix . 'fault_str'] ?? null, 'Kein Fehler'), - 'fault_state' => intval($values[$prefix . 'fault_state'] ?? 0) - ] - ]; - } - - /** - * Zähler-Daten (Counter) - Performance-optimiert - */ - private function getCounter($id) - { - $prefix = "openWB/counter/{$id}/get/"; - - // Alle benötigten Topics in einem Aufruf abfragen - $topics = [ - $prefix . 'power', - $prefix . 'voltages', - $prefix . 'currents', - $prefix . 'powers', - $prefix . 'power_factors', - $prefix . 'frequency', - $prefix . 'exported', - $prefix . 'daily_exported', - $prefix . 'imported', - $prefix . 'daily_imported', - $prefix . 'fault_str', - $prefix . 'fault_state' - ]; - - $values = $this->mqttClient->getMultipleValues($topics); - - // Arrays parsen - try { - $voltages = json_decode($values[$prefix . 'voltages'] ?? '[]', true) ?: [0, 0, 0]; - $currents = json_decode($values[$prefix . 'currents'] ?? '[]', true) ?: [0, 0, 0]; - $powers = json_decode($values[$prefix . 'powers'] ?? '[]', true) ?: [0, 0, 0]; - $power_factors = json_decode($values[$prefix . 'power_factors'] ?? '[]', true) ?: [0, 0, 0]; - } catch (Exception $e) { - $voltages = [0, 0, 0]; - $currents = [0, 0, 0]; - $powers = [0, 0, 0]; - $power_factors = [0, 0, 0]; - } - - return [ - "counter_{$id}" => [ - 'power' => floatval($values[$prefix . 'power'] ?? 0), - 'voltages' => [ - floatval($voltages[0] ?? 0), - floatval($voltages[1] ?? 0), - floatval($voltages[2] ?? 0) - ], - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ], - 'powers' => [ - floatval($powers[0] ?? 0), - floatval($powers[1] ?? 0), - floatval($powers[2] ?? 0) - ], - 'power_factors' => [ - floatval($power_factors[0] ?? 0), - floatval($power_factors[1] ?? 0), - floatval($power_factors[2] ?? 0) - ], - 'frequency' => floatval($values[$prefix . 'frequency'] ?? 50.0), - 'exported' => floatval($values[$prefix . 'exported'] ?? 0), - 'daily_exported' => floatval($values[$prefix . 'daily_exported'] ?? 0), - 'imported' => floatval($values[$prefix . 'imported'] ?? 0), - 'daily_imported' => floatval($values[$prefix . 'daily_imported'] ?? 0), - 'fault_str' => $this->decodeStringValue($values[$prefix . 'fault_str'] ?? null, 'Kein Fehler'), - 'fault_state' => intval($values[$prefix . 'fault_state'] ?? 0) - ] - ]; - } - - /** - * Lademodus setzen (OpenWB Template-System) - */ - private function setChargemode($chargepointId, $mode) - { - // Gültige Modi mapping (sowohl kurze als auch vollständige Namen) - $validModes = [ - 'instant' => 'instant_charging', - 'pv' => 'pv_charging', - 'eco' => 'eco_charging', - 'stop' => 'stop', - 'target' => 'scheduled_charging', - // Vollständige Namen direkt unterstützen - 'instant_charging' => 'instant_charging', - 'pv_charging' => 'pv_charging', - 'eco_charging' => 'eco_charging', - 'scheduled_charging' => 'scheduled_charging' - ]; - - if (!isset($validModes[$mode])) { - return ['success' => false, 'message' => 'Invalid chargemode. Valid modes: ' . implode(', ', array_keys($validModes))]; - } - - $selectedMode = $validModes[$mode]; - - try { - // 1. Aktuelles Template von /set/charge_template auslesen - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - - if (!$templateJson) { - return ['success' => false, 'message' => "Could not read current charge template from topic: {$templateTopic}"]; - } - - $template = json_decode($templateJson, true); - if ($template === null) { - return ['success' => false, 'message' => 'Invalid charge template format - JSON decode failed: ' . json_last_error_msg() . ". Template JSON: " . substr($templateJson, 0, 200)]; - } - - if (!is_array($template)) { - return ['success' => false, 'message' => 'Invalid charge template format - not an array']; - } - - // 2. Chargemode im Template ändern - if (!isset($template['chargemode'])) { - $template['chargemode'] = []; - } - - $template['chargemode']['selected'] = $selectedMode; - - // 3. Geändertes Template an /set/charge_template zurückschreiben - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "Chargemode set to {$mode} ({$selectedMode})"]; - } - - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting chargemode: ' . $e->getMessage()]; - } - } - - /** - * Ladestrom setzen (instant_charging.current im Template) - */ - private function setChargecurrent($chargepointId, $current) - { - try { - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template from set topic']; - } - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['instant_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing instant_charging']; - } - $template['chargemode']['instant_charging']['current'] = floatval($current); - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "Chargecurrent set to {$current}A for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting chargecurrent: ' . $e->getMessage()]; - } - } - - /** - * Minimalen permanenten Strom setzen (pv_charging.min_current im Template) - */ - private function setMinimalPermanentCurrent($chargepointId, $value) - { - try { - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template from set topic']; - } - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['pv_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing pv_charging']; - } - $template['chargemode']['pv_charging']['min_current'] = floatval($value); - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "Minimal permanent current set to {$value}A for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting minimal permanent current: ' . $e->getMessage()]; - } - } - - /** - * Minimalen PV SoC setzen (pv_charging.min_soc im Template) - */ - private function setMinimalPvSoc($chargepointId, $value) - { - try { - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template from set topic']; - } - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['pv_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing pv_charging']; - } - $template['chargemode']['pv_charging']['min_soc'] = intval($value); - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "Minimal PV SoC set to {$value}% for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting minimal PV SoC: ' . $e->getMessage()]; - } - } - - /** - * Maximalen ECO-Preis setzen (eco_charging.max_price im Template) - */ - private function setMaxPriceEco($chargepointId, $value) - { - try { - $value = $value / 100000; - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template from set topic']; - } - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['eco_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing eco_charging']; - } - $template['chargemode']['eco_charging']['max_price'] = floatval($value); - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "Max price eco set to {$value} for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting max price eco: ' . $e->getMessage()]; - } - } - - /** - * Chargepoint Lock setzen - */ - private function setChargepointLock($chargepointId, $value) - { - $topic = "openWB/set/chargepoint/{$chargepointId}/set/manual_lock"; - $lockValue = $value ? 'true' : 'false'; - if ($this->mqttClient->setValue($topic, $lockValue)) { - return ['success' => true, 'message' => "Chargepoint lock set to {$lockValue} for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to set chargepoint lock']; - } - - /** - * Chargepoint Daily Imported - */ - private function getChargepointDailyImported($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/daily_imported"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['daily_imported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['daily_imported' => 0]]; - } - } - - /** - * Chargepoint Daily Exported - */ - private function getChargepointDailyExported($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/daily_exported"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['daily_exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['daily_exported' => 0]]; - } - } - - /** - * Chargepoint Frequency - */ - private function getChargepointFrequency($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/frequency"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['frequency' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['frequency' => 0]]; - } - } - - /** - * Chargepoint RFID - */ - private function getChargepointRfid($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/rfid"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['rfid' => $this->decodeStringValue($value)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['rfid' => '']]; - } - } - - /** - * Chargepoint RFID Timestamp - */ - private function getChargepointRfidTimestamp($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/rfid_timestamp"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['rfid_timestamp' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['rfid_timestamp' => 0]]; - } - } - - /** - * Chargepoint EVSE Current - */ - private function getChargepointEvseCurrent($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/evse_current"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['evse_current' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['evse_current' => 0]]; - } - } - - /** - * Chargepoint Power Factors (alle Phasen) - */ - private function getChargepointPowerFactors($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/power_factors"; - $value = $this->mqttClient->getValue($topic); - $powerFactors = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "chargepoint_{$id}" => [ - 'power_factors' => [ - floatval($powerFactors[0] ?? 0), - floatval($powerFactors[1] ?? 0), - floatval($powerFactors[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "chargepoint_{$id}" => [ - 'power_factors' => [0, 0, 0] - ] - ]; - } - } - - /** - * Chargepoint Power Factor einzelne Phase - */ - private function getChargepointPowerFactor($id, $phase) - { - try { - $topic = "openWB/chargepoint/{$id}/get/power_factors"; - $value = $this->mqttClient->getValue($topic); - $powerFactors = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "chargepoint_{$id}" => [ - "power_factor_p{$phase}" => floatval($powerFactors[$phase - 1] ?? 0) - ] - ]; - } catch (Exception $e) { - return [ - "chargepoint_{$id}" => [ - "power_factor_p{$phase}" => 0 - ] - ]; - } - } - - /** - * Chargepoint Config Name - */ - private function getChargepointConfigName($id) - { - try { - $topic = "openWB/chargepoint/{$id}/config"; - $value = $this->mqttClient->getValue($topic); - $config = json_decode($value ?? '{}', true) ?: []; - - return ["chargepoint_{$id}" => ['config_name' => strval($config['name'] ?? '')]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['config_name' => '']]; - } - } - - /** - * Chargepoint Connected Vehicle Name - */ - private function getChargepointConnectedVehicleName($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/connected_vehicle/info"; - $value = $this->mqttClient->getValue($topic); - $info = json_decode($value ?? '{}', true) ?: []; - - return ["chargepoint_{$id}" => ['connected_vehicle_name' => strval($info['name'] ?? '')]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['connected_vehicle_name' => '']]; - } - } - - /** - * Chargepoint Charge Template Name - */ - private function getChargepointChargeTemplateName($id) - { - try { - $topic = "openWB/chargepoint/{$id}/set/charge_template"; - $value = $this->mqttClient->getValue($topic); - $template = json_decode($value ?? '{}', true) ?: []; - - return ["chargepoint_{$id}" => ['charge_template_name' => strval($template['name'] ?? '')]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['charge_template_name' => '']]; - } - } - - /** - * Chargepoint Charge Template Min Current - */ - private function getChargepointChargeTemplateMinCurrent($id) - { - try { - $topic = "openWB/chargepoint/{$id}/set/charge_template"; - $value = $this->mqttClient->getValue($topic); - $template = json_decode($value ?? '{}', true) ?: []; - - $minCurrent = 0; - if (isset($template['chargemode']['pv_charging']['min_current'])) { - $minCurrent = $template['chargemode']['pv_charging']['min_current']; - } - - return ["chargepoint_{$id}" => ['charge_template_min_current' => floatval($minCurrent)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['charge_template_min_current' => 0]]; - } - } - - /** - * Chargepoint Instant Charging Current - */ - private function getChargepointInstantChargingCurrent($id) - { - try { - $topic = "openWB/chargepoint/{$id}/set/charge_template"; - $value = $this->mqttClient->getValue($topic); - $template = json_decode($value ?? '{}', true) ?: []; - - $instantCurrent = 0; - if (isset($template['chargemode']['instant_charging']['current'])) { - $instantCurrent = $template['chargemode']['instant_charging']['current']; - } - - return ["chargepoint_{$id}" => ['instant_charging_current' => floatval($instantCurrent)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['instant_charging_current' => 0]]; - } - } - - /** - * Chargepoint PV Charging Min Current - */ - private function getChargepointPvChargingMinCurrent($id) - { - try { - $topic = "openWB/chargepoint/{$id}/set/charge_template"; - $value = $this->mqttClient->getValue($topic); - $template = json_decode($value ?? '{}', true) ?: []; - - $pvMinCurrent = 0; - if (isset($template['chargemode']['pv_charging']['min_current'])) { - $pvMinCurrent = $template['chargemode']['pv_charging']['min_current']; - } - - return ["chargepoint_{$id}" => ['pv_charging_min_current' => floatval($pvMinCurrent)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['pv_charging_min_current' => 0]]; - } - } - - /** - * Chargepoint Imported - */ - private function getChargepointImported($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('chargepoint', 'imported'); - return ['chargepoint_total' => ['imported' => $sum]]; - } - - try { - $topic = "openWB/chargepoint/{$id}/get/imported"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['imported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['imported' => 0]]; - } - } - - /** - * Chargepoint Exported - */ - private function getChargepointExported($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('chargepoint', 'exported'); - return ['chargepoint_total' => ['exported' => $sum]]; - } - - try { - $topic = "openWB/chargepoint/{$id}/get/exported"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['exported' => 0]]; - } - } - - /** - * Chargepoint Monthly Exported - */ - private function getChargepointMonthlyExported($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('chargepoint', 'monthly_exported'); - return ['chargepoint_total' => ['monthly_exported' => $sum]]; - } - - try { - $topic = "openWB/chargepoint/{$id}/get/monthly_exported"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['monthly_exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['monthly_exported' => 0]]; - } - } - - /** - * Chargepoint Yearly Exported - */ - private function getChargepointYearlyExported($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('chargepoint', 'yearly_exported'); - return ['chargepoint_total' => ['yearly_exported' => $sum]]; - } - - try { - $topic = "openWB/chargepoint/{$id}/get/yearly_exported"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['yearly_exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['yearly_exported' => 0]]; - } - } - - /** - * Chargepoint SoC - */ - private function getChargepointSoc($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/soc"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['soc' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['soc' => 0]]; - } - } - - /** - * Chargepoint State String - */ - private function getChargepointStateStr($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/state_str"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['state_str' => $this->decodeStringValue($value)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['state_str' => '']]; - } - } - - /** - * Chargepoint Fault String - */ - private function getChargepointFaultStr($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/fault_str"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['fault_str' => $this->decodeStringValue($value)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['fault_str' => '']]; - } - } - - /** - * Chargepoint Fault State - */ - private function getChargepointFaultState($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/fault_state"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['fault_state' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['fault_state' => 0]]; - } - } - - /** - * Chargepoint Phases in Use - */ - private function getChargepointPhasesInUse($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/phases_in_use"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['phases_in_use' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['phases_in_use' => 0]]; - } - } - - /** - * Chargepoint Plug State - */ - private function getChargepointPlugState($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/plug_state"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['plug_state' => boolval($value ?? false)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['plug_state' => false]]; - } - } - - /** - * Chargepoint Charge State - */ - private function getChargepointChargeState($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/charge_state"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['charge_state' => boolval($value ?? false)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['charge_state' => false]]; - } - } - - /** - * Chargepoint Chargemode - */ - private function getChargepointChargemode($id) - { - try { - $topic = "openWB/chargepoint/{$id}/get/chargemode"; - $value = $this->mqttClient->getValue($topic); - return ["chargepoint_{$id}" => ['chargemode' => $this->decodeStringValue($value)]]; - } catch (Exception $e) { - return ["chargepoint_{$id}" => ['chargemode' => '']]; - } - } - - /** - * Instant Charging Limit setzen - */ - private function setInstantChargingLimit($chargepointId, $value) - { - $validLimits = ['none', 'amount', 'soc']; - - if (!in_array($value, $validLimits)) { - return ['success' => false, 'message' => 'Invalid instant_charging_limit. Valid values: ' . implode(', ', $validLimits)]; - } - - try { - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template']; - } - - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['instant_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing instant_charging']; - } - - $template['chargemode']['instant_charging']['limit']['selected'] = $value; - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "Instant charging limit set to {$value} for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting instant charging limit: ' . $e->getMessage()]; - } - } - - /** - * Instant Charging Amount setzen (kWh -> Wh) - */ - private function setInstantChargingAmount($chargepointId, $value) - { - $amount = floatval($value); - if ($amount < 0) { - return ['success' => false, 'message' => 'Amount must be >= 0']; - } - - // kWh zu Wh konvertieren - $amountWh = intval($amount * 1000); - - try { - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template']; - } - - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['instant_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing instant_charging']; - } - - $template['chargemode']['instant_charging']['limit']['amount'] = $amountWh; - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "Instant charging amount set to {$value}kWh ({$amountWh}Wh) for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting instant charging amount: ' . $e->getMessage()]; - } - } - - /** - * Instant Charging SoC setzen - */ - private function setInstantChargingSoc($chargepointId, $value) - { - $soc = intval($value); - if ($soc < 0 || $soc > 100) { - return ['success' => false, 'message' => 'SoC must be between 0 and 100']; - } - - try { - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template']; - } - - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['instant_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing instant_charging']; - } - - $template['chargemode']['instant_charging']['limit']['soc'] = $soc; - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "Instant charging SoC set to {$soc}% for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting instant charging SoC: ' . $e->getMessage()]; - } - } - - /** - * PV Charging Limit setzen - */ - private function setPvChargingLimit($chargepointId, $value) - { - $validLimits = ['none', 'amount', 'soc']; - - if (!in_array($value, $validLimits)) { - return ['success' => false, 'message' => 'Invalid pv_charging_limit. Valid values: ' . implode(', ', $validLimits)]; - } - - try { - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template']; - } - - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['pv_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing pv_charging']; - } - - $template['chargemode']['pv_charging']['limit']['selected'] = $value; - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "PV charging limit set to {$value} for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting PV charging limit: ' . $e->getMessage()]; - } - } - - /** - * PV Charging Amount setzen (kWh -> Wh) - */ - private function setPvChargingAmount($chargepointId, $value) - { - $amount = floatval($value); - if ($amount < 0) { - return ['success' => false, 'message' => 'Amount must be >= 0']; - } - - // kWh zu Wh konvertieren - $amountWh = intval($amount * 1000); - - try { - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template']; - } - - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['pv_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing pv_charging']; - } - - $template['chargemode']['pv_charging']['limit']['amount'] = $amountWh; - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "PV charging amount set to {$value}kWh ({$amountWh}Wh) for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting PV charging amount: ' . $e->getMessage()]; - } - } - - /** - * PV Charging SoC setzen - */ - private function setPvChargingSoc($chargepointId, $value) - { - $soc = intval($value); - if ($soc < 0 || $soc > 100) { - return ['success' => false, 'message' => 'SoC must be between 0 and 100']; - } - - try { - $templateTopic = "openWB/chargepoint/{$chargepointId}/set/charge_template"; - $templateJson = $this->mqttClient->getValue($templateTopic); - if (!$templateJson) { - return ['success' => false, 'message' => 'Could not read current charge template']; - } - - $template = json_decode($templateJson, true); - if (!$template || !isset($template['chargemode']['pv_charging'])) { - return ['success' => false, 'message' => 'Invalid charge template format or missing pv_charging']; - } - - $template['chargemode']['pv_charging']['limit']['soc'] = $soc; - $setTopic = "openWB/set/chargepoint/{$chargepointId}/set/charge_template"; - $newTemplateJson = json_encode($template); - - if ($this->mqttClient->setValue($setTopic, $newTemplateJson)) { - return ['success' => true, 'message' => "PV charging SoC set to {$soc}% for chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to update charge template']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting PV charging SoC: ' . $e->getMessage()]; - } - } - - /** - * Fahrzeug für Chargepoint setzen - */ - private function setVehicle($chargepointId, $value) - { - $vehicleId = intval($value); - if ($vehicleId < 0) { - return ['success' => false, 'message' => 'Vehicle ID must be >= 0']; - } - - try { - $topic = "openWB/set/chargepoint/{$chargepointId}/config/ev"; - - if ($this->mqttClient->setValue($topic, strval($vehicleId))) { - return ['success' => true, 'message' => "Vehicle {$vehicleId} assigned to chargepoint {$chargepointId}"]; - } - return ['success' => false, 'message' => 'Failed to set vehicle']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting vehicle: ' . $e->getMessage()]; - } - } - - /** - * Manuellen SoC für Fahrzeug setzen - */ - private function setManualSoc($chargepointId, $value) - { - $soc = intval($value); - if ($soc < 0 || $soc > 100) { - return ['success' => false, 'message' => 'Manual SoC must be between 0 and 100']; - } - - try { - // Erst die Fahrzeug-ID vom Chargepoint ermitteln - $configTopic = "openWB/chargepoint/{$chargepointId}/config"; - $configJson = $this->mqttClient->getValue($configTopic); - if (!$configJson) { - return ['success' => false, 'message' => 'Could not read chargepoint config']; - } - - $config = json_decode($configJson, true); - if (!$config || !isset($config['ev'])) { - return ['success' => false, 'message' => 'No vehicle assigned to chargepoint']; - } - - $vehicleId = $config['ev']; - $topic = "openWB/set/vehicle/{$vehicleId}/soc_module/calculated_soc_state/manual_soc"; - - if ($this->mqttClient->setValue($topic, strval($soc))) { - return ['success' => true, 'message' => "Manual SoC set to {$soc}% for vehicle {$vehicleId} (chargepoint {$chargepointId})"]; - } - return ['success' => false, 'message' => 'Failed to set manual SoC']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting manual SoC: ' . $e->getMessage()]; - } - } - - /** - * Batterie-Modus setzen - */ - private function setBatMode($value) - { - // Gültige Modi - $validModes = ['min_soc_bat_mode', 'ev_mode', 'bat_mode']; - - if (!in_array($value, $validModes)) { - return ['success' => false, 'message' => 'Invalid bat_mode. Valid modes: ' . implode(', ', $validModes)]; - } - - try { - $topic = "openWB/set/general/chargemode_config/bat/mode"; - - if ($this->mqttClient->setValue($topic, $value)) { - return ['success' => true, 'message' => "Bat mode set to {$value}"]; - } - - return ['success' => false, 'message' => 'Failed to set bat mode']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting bat mode: ' . $e->getMessage()]; - } - } - - /** - * Reservierte Batterie-Ladeleistung setzen (W) - * Setzt openWB/set/general/chargemode_config/bat/power_reserve - * Ermöglicht externes Energiemanagement der Speicherreservierung ohne UI-Eingriff. - */ - private function setBatPowerReserve($value) - { - $watts = intval($value); - - if ($watts < 0) { - return ['success' => false, 'message' => 'bat_power_reserve must be >= 0']; - } - - try { - $topic = "openWB/set/general/chargemode_config/bat/power_reserve"; - - if ($this->mqttClient->setValue($topic, $watts)) { - return [ - 'success' => true, - 'message' => "bat_power_reserve set to {$watts}W", - 'data' => ['bat_power_reserve' => $watts] - ]; - } - - return ['success' => false, 'message' => 'Failed to set bat_power_reserve']; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Error setting bat_power_reserve: ' . $e->getMessage()]; - } - } - - /** - * Lese openWB/system/lastlivevaluesJson Topic 1:1 aus - */ - private function getLastLiveValuesJson() - { - $topic = "openWB/graph/lastlivevaluesJson"; - $jsonValue = $this->mqttClient->getValue($topic); - - if ($jsonValue === null) { - return [ - 'get_lastlivevaluesjson' => null - ]; - } - - // JSON-String 1:1 zurückgeben - return [ - 'get_lastlivevaluesjson' => $jsonValue - ]; - } - - /** - * Counter Power - */ - private function getCounterPower($id) - { - try { - $topic = "openWB/counter/{$id}/get/power"; - $value = $this->mqttClient->getValue($topic); - return ["counter_{$id}" => ['power' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['power' => 0]]; - } - } - - /** - * Counter Powers - */ - private function getCounterPowers($id) - { - try { - $topic = "openWB/counter/{$id}/get/powers"; - $value = $this->mqttClient->getValue($topic); - $powers = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "counter_{$id}" => [ - 'powers' => [ - floatval($powers[0] ?? 0), - floatval($powers[1] ?? 0), - floatval($powers[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "counter_{$id}" => [ - 'powers' => [0, 0, 0] - ] - ]; - } - } - - /** - * Counter Voltage P1 - */ - private function getCounterVoltageP1($id) - { - try { - $topic = "openWB/counter/{$id}/get/voltages"; - $value = $this->mqttClient->getValue($topic); - $voltages = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - return ["counter_{$id}" => ['voltage_p1' => floatval($voltages[0] ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['voltage_p1' => 0]]; - } - } - - /** - * Counter Voltage P2 - */ - private function getCounterVoltageP2($id) - { - try { - $topic = "openWB/counter/{$id}/get/voltages"; - $value = $this->mqttClient->getValue($topic); - $voltages = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - return ["counter_{$id}" => ['voltage_p2' => floatval($voltages[1] ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['voltage_p2' => 0]]; - } - } - - /** - * Counter Voltage P3 - */ - private function getCounterVoltageP3($id) - { - try { - $topic = "openWB/counter/{$id}/get/voltages"; - $value = $this->mqttClient->getValue($topic); - $voltages = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - return ["counter_{$id}" => ['voltage_p3' => floatval($voltages[2] ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['voltage_p3' => 0]]; - } - } - - /** - * Counter Voltages - */ - private function getCounterVoltages($id) - { - try { - $topic = "openWB/counter/{$id}/get/voltages"; - $value = $this->mqttClient->getValue($topic); - $voltages = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "counter_{$id}" => [ - 'voltages' => [ - floatval($voltages[0] ?? 0), - floatval($voltages[1] ?? 0), - floatval($voltages[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "counter_{$id}" => [ - 'voltages' => [0, 0, 0] - ] - ]; - } - } - - /** - * Counter Current P1 - */ - private function getCounterCurrentP1($id) - { - try { - $topic = "openWB/counter/{$id}/get/currents"; - $value = $this->mqttClient->getValue($topic); - $currents = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - return ["counter_{$id}" => ['current_p1' => floatval($currents[0] ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['current_p1' => 0]]; - } - } - - /** - * Counter Current P2 - */ - private function getCounterCurrentP2($id) - { - try { - $topic = "openWB/counter/{$id}/get/currents"; - $value = $this->mqttClient->getValue($topic); - $currents = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - return ["counter_{$id}" => ['current_p2' => floatval($currents[1] ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['current_p2' => 0]]; - } - } - - /** - * Counter Current P3 - */ - private function getCounterCurrentP3($id) - { - try { - $topic = "openWB/counter/{$id}/get/currents"; - $value = $this->mqttClient->getValue($topic); - $currents = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - return ["counter_{$id}" => ['current_p3' => floatval($currents[2] ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['current_p3' => 0]]; - } - } - - /** - * Counter Currents - */ - private function getCounterCurrents($id) - { - try { - $topic = "openWB/counter/{$id}/get/currents"; - $value = $this->mqttClient->getValue($topic); - $currents = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "counter_{$id}" => [ - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "counter_{$id}" => [ - 'currents' => [0, 0, 0] - ] - ]; - } - } - - /** - * Counter Power Factors - */ - private function getCounterPowerFactors($id) - { - try { - $topic = "openWB/counter/{$id}/get/power_factors"; - $value = $this->mqttClient->getValue($topic); - $powerFactors = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "counter_{$id}" => [ - 'power_factors' => [ - floatval($powerFactors[0] ?? 0), - floatval($powerFactors[1] ?? 0), - floatval($powerFactors[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "counter_{$id}" => [ - 'power_factors' => [0, 0, 0] - ] - ]; - } - } - - /** - * Counter Imported - */ - private function getCounterImported($id) - { - try { - $topic = "openWB/counter/{$id}/get/imported"; - $value = $this->mqttClient->getValue($topic); - return ["counter_{$id}" => ['imported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['imported' => 0]]; - } - } - - /** - * Counter Exported - */ - private function getCounterExported($id) - { - try { - $topic = "openWB/counter/{$id}/get/exported"; - $value = $this->mqttClient->getValue($topic); - return ["counter_{$id}" => ['exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['exported' => 0]]; - } - } - - /** - * Counter Daily Imported - */ - private function getCounterDailyImported($id) - { - try { - $topic = "openWB/counter/{$id}/get/daily_imported"; - $value = $this->mqttClient->getValue($topic); - return ["counter_{$id}" => ['daily_imported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['daily_imported' => 0]]; - } - } - - /** - * Counter Daily Exported - */ - private function getCounterDailyExported($id) - { - try { - $topic = "openWB/counter/{$id}/get/daily_exported"; - $value = $this->mqttClient->getValue($topic); - return ["counter_{$id}" => ['daily_exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['daily_exported' => 0]]; - } - } - - /** - * Counter Frequency - */ - private function getCounterFrequency($id) - { - try { - $topic = "openWB/counter/{$id}/get/frequency"; - $value = $this->mqttClient->getValue($topic); - return ["counter_{$id}" => ['frequency' => floatval($value ?? 50.0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['frequency' => 50.0]]; - } - } - - /** - * Counter Fault String - */ - private function getCounterFaultStr($id) - { - try { - $topic = "openWB/counter/{$id}/get/fault_str"; - $value = $this->mqttClient->getValue($topic); - return ["counter_{$id}" => ['fault_str' => $this->decodeStringValue($value, 'Kein Fehler')]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['fault_str' => 'Kein Fehler']]; - } - } - - /** - * Counter Fault State - */ - private function getCounterFaultState($id) - { - try { - $topic = "openWB/counter/{$id}/get/fault_state"; - $value = $this->mqttClient->getValue($topic); - return ["counter_{$id}" => ['fault_state' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["counter_{$id}" => ['fault_state' => 0]]; - } - } - - /** - * Battery Power - */ - private function getBatteryPower($id) - { - try { - $topic = "openWB/bat/{$id}/get/power"; - $value = $this->mqttClient->getValue($topic); - return ["battery_{$id}" => ['power' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["battery_{$id}" => ['power' => 0]]; - } - } - - /** - * Battery SoC - */ - private function getBatterySoc($id) - { - try { - $topic = "openWB/bat/{$id}/get/soc"; - $value = $this->mqttClient->getValue($topic); - return ["battery_{$id}" => ['soc' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["battery_{$id}" => ['soc' => 0]]; - } - } - - /** - * Battery Currents - */ - private function getBatteryCurrents($id) - { - try { - $topic = "openWB/bat/{$id}/get/currents"; - $value = $this->mqttClient->getValue($topic); - $currents = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "battery_{$id}" => [ - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "battery_{$id}" => [ - 'currents' => [0, 0, 0] - ] - ]; - } - } - - /** - * Battery Imported - */ - private function getBatteryImported($id) - { - try { - $topic = "openWB/bat/{$id}/get/imported"; - $value = $this->mqttClient->getValue($topic); - return ["battery_{$id}" => ['imported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["battery_{$id}" => ['imported' => 0]]; - } - } - - /** - * Battery Exported - */ - private function getBatteryExported($id) - { - try { - $topic = "openWB/bat/{$id}/get/exported"; - $value = $this->mqttClient->getValue($topic); - return ["battery_{$id}" => ['exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["battery_{$id}" => ['exported' => 0]]; - } - } - - /** - * Battery Daily Imported - */ - private function getBatteryDailyImported($id) - { - try { - $topic = "openWB/bat/{$id}/get/daily_imported"; - $value = $this->mqttClient->getValue($topic); - return ["battery_{$id}" => ['daily_imported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["battery_{$id}" => ['daily_imported' => 0]]; - } - } - - /** - * Battery Daily Exported - */ - private function getBatteryDailyExported($id) - { - try { - $topic = "openWB/bat/{$id}/get/daily_exported"; - $value = $this->mqttClient->getValue($topic); - return ["battery_{$id}" => ['daily_exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["battery_{$id}" => ['daily_exported' => 0]]; - } - } - - /** - * Battery Fault String - */ - private function getBatteryFaultStr($id) - { - try { - $topic = "openWB/bat/{$id}/get/fault_str"; - $value = $this->mqttClient->getValue($topic); - return ["battery_{$id}" => ['fault_str' => $this->decodeStringValue($value, 'Kein Fehler')]]; - } catch (Exception $e) { - return ["battery_{$id}" => ['fault_str' => 'Kein Fehler']]; - } - } - - /** - * Battery Fault State - */ - private function getBatteryFaultState($id) - { - try { - $topic = "openWB/bat/{$id}/get/fault_state"; - $value = $this->mqttClient->getValue($topic); - return ["battery_{$id}" => ['fault_state' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["battery_{$id}" => ['fault_state' => 0]]; - } - } - - /** - * Battery Power Limit Controllable - */ - private function getBatteryPowerLimitControllable($id) - { - try { - $topic = "openWB/bat/{$id}/get/power_limit_controllable"; - $value = $this->mqttClient->getValue($topic); - return ["battery_{$id}" => ['power_limit_controllable' => boolval($value ?? false)]]; - } catch (Exception $e) { - return ["battery_{$id}" => ['power_limit_controllable' => false]]; - } - } - - /** - * PV Power - */ - private function getPvPower($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('pv', 'power'); - return ['pv_total' => ['power' => $sum]]; - } - - try { - $topic = "openWB/pv/{$id}/get/power"; - $value = $this->mqttClient->getValue($topic); - return ["pv_{$id}" => ['power' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["pv_{$id}" => ['power' => 0]]; - } - } - - /** - * PV Currents - */ - private function getPvCurrents($id) - { - try { - $topic = "openWB/pv/{$id}/get/currents"; - $value = $this->mqttClient->getValue($topic); - $currents = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "pv_{$id}" => [ - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return [ - "pv_{$id}" => [ - 'currents' => [0, 0, 0] - ] - ]; - } - } - - /** - * PV Imported - */ - private function getPvImported($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('pv', 'imported'); - return ['pv_total' => ['imported' => $sum]]; - } - - try { - $topic = "openWB/pv/{$id}/get/imported"; - $value = $this->mqttClient->getValue($topic); - return ["pv_{$id}" => ['imported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["pv_{$id}" => ['imported' => 0]]; - } - } - - /** - * PV Exported - */ - private function getPvExported($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('pv', 'exported'); - return ['pv_total' => ['exported' => $sum]]; - } - - try { - $topic = "openWB/pv/{$id}/get/exported"; - $value = $this->mqttClient->getValue($topic); - return ["pv_{$id}" => ['exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["pv_{$id}" => ['exported' => 0]]; - } - } - - /** - * PV Daily Exported - */ - private function getPvDailyExported($id) - { - try { - $topic = "openWB/pv/{$id}/get/daily_exported"; - $value = $this->mqttClient->getValue($topic); - return ["pv_{$id}" => ['daily_exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["pv_{$id}" => ['daily_exported' => 0]]; - } - } - - /** - * PV Monthly Exported - */ - private function getPvMonthlyExported($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('pv', 'monthly_exported'); - return ['pv_total' => ['monthly_exported' => $sum]]; - } - - try { - $topic = "openWB/pv/{$id}/get/monthly_exported"; - $value = $this->mqttClient->getValue($topic); - return ["pv_{$id}" => ['monthly_exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["pv_{$id}" => ['monthly_exported' => 0]]; - } - } - - /** - * PV Yearly Exported - */ - private function getPvYearlyExported($id) - { - if ($this->isTotalId($id)) { - $sum = $this->getTotalMetricValue('pv', 'yearly_exported'); - return ['pv_total' => ['yearly_exported' => $sum]]; - } - - try { - $topic = "openWB/pv/{$id}/get/yearly_exported"; - $value = $this->mqttClient->getValue($topic); - return ["pv_{$id}" => ['yearly_exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["pv_{$id}" => ['yearly_exported' => 0]]; - } - } - - /** - * PV Fault String - */ - private function getPvFaultStr($id) - { - try { - $topic = "openWB/pv/{$id}/get/fault_str"; - $value = $this->mqttClient->getValue($topic); - return ["pv_{$id}" => ['fault_str' => $this->decodeStringValue($value, 'Kein Fehler')]]; - } catch (Exception $e) { - return ["pv_{$id}" => ['fault_str' => 'Kein Fehler']]; - } - } - - /** - * PV Fault State - */ - private function getPvFaultState($id) - { - try { - $topic = "openWB/pv/{$id}/get/fault_state"; - $value = $this->mqttClient->getValue($topic); - return ["pv_{$id}" => ['fault_state' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["pv_{$id}" => ['fault_state' => 0]]; - } - } - - /** - * Consumer Power - */ - private function getConsumerPower($id) - { - try { - $topic = "openWB/consumer/{$id}/get/power"; - $value = $this->mqttClient->getValue($topic); - return ["consumer_{$id}" => ['power' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['power' => 0]]; - } - } - - /** - * Consumer Currents - */ - private function getConsumerCurrents($id) - { - try { - $topic = "openWB/consumer/{$id}/get/currents"; - $value = $this->mqttClient->getValue($topic); - $currents = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "consumer_{$id}" => [ - 'currents' => [ - floatval($currents[0] ?? 0), - floatval($currents[1] ?? 0), - floatval($currents[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['currents' => [0, 0, 0]]]; - } - } - - /** - * Consumer Voltages - */ - private function getConsumerVoltages($id) - { - try { - $topic = "openWB/consumer/{$id}/get/voltages"; - $value = $this->mqttClient->getValue($topic); - $voltages = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "consumer_{$id}" => [ - 'voltages' => [ - floatval($voltages[0] ?? 0), - floatval($voltages[1] ?? 0), - floatval($voltages[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['voltages' => [0, 0, 0]]]; - } - } - - /** - * Consumer Powers - */ - private function getConsumerPowers($id) - { - try { - $topic = "openWB/consumer/{$id}/get/powers"; - $value = $this->mqttClient->getValue($topic); - $powers = json_decode($value ?? '[]', true) ?: [0, 0, 0]; - - return [ - "consumer_{$id}" => [ - 'powers' => [ - floatval($powers[0] ?? 0), - floatval($powers[1] ?? 0), - floatval($powers[2] ?? 0) - ] - ] - ]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['powers' => [0, 0, 0]]]; - } - } - - /** - * Consumer Imported - */ - private function getConsumerImported($id) - { - try { - $topic = "openWB/consumer/{$id}/get/imported"; - $value = $this->mqttClient->getValue($topic); - return ["consumer_{$id}" => ['imported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['imported' => 0]]; - } - } - - /** - * Consumer Exported - */ - private function getConsumerExported($id) - { - try { - $topic = "openWB/consumer/{$id}/get/exported"; - $value = $this->mqttClient->getValue($topic); - return ["consumer_{$id}" => ['exported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['exported' => 0]]; - } - } - - /** - * Consumer Daily Imported - */ - private function getConsumerDailyImported($id) - { - try { - $topic = "openWB/consumer/{$id}/get/daily_imported"; - $value = $this->mqttClient->getValue($topic); - return ["consumer_{$id}" => ['daily_imported' => floatval($value ?? 0)]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['daily_imported' => 0]]; - } - } - - /** - * Consumer Phases In Use - */ - private function getConsumerPhasesInUse($id) - { - try { - $topic = "openWB/consumer/{$id}/get/phases_in_use"; - $value = $this->mqttClient->getValue($topic); - return ["consumer_{$id}" => ['phases_in_use' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['phases_in_use' => 0]]; - } - } - - /** - * Consumer Charge State - */ - private function getConsumerChargeState($id) - { - try { - $topic = "openWB/consumer/{$id}/get/charge_state"; - $value = $this->mqttClient->getValue($topic); - return ["consumer_{$id}" => ['charge_state' => $this->parseBooleanValue($value ?? 'false')]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['charge_state' => false]]; - } - } - - /** - * Consumer State String - */ - private function getConsumerStateStr($id) - { - try { - $topic = "openWB/consumer/{$id}/get/state_str"; - $value = $this->mqttClient->getValue($topic); - return ["consumer_{$id}" => ['state_str' => $this->decodeStringValue($value, null)]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['state_str' => null]]; - } - } - - /** - * Consumer Fault String - */ - private function getConsumerFaultStr($id) - { - try { - $topic = "openWB/consumer/{$id}/get/fault_str"; - $value = $this->mqttClient->getValue($topic); - return ["consumer_{$id}" => ['fault_str' => $this->decodeStringValue($value, 'Kein Fehler')]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['fault_str' => 'Kein Fehler']]; - } - } - - /** - * Consumer Fault State - */ - private function getConsumerFaultState($id) - { - try { - $topic = "openWB/consumer/{$id}/get/fault_state"; - $value = $this->mqttClient->getValue($topic); - return ["consumer_{$id}" => ['fault_state' => intval($value ?? 0)]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['fault_state' => 0]]; - } - } - - /** - * Consumer Usage Type - */ - private function getConsumerUsageType($id) - { - try { - $topic = "openWB/consumer/{$id}/usage"; - $value = $this->mqttClient->getValue($topic); - $usage = json_decode($value ?? '{}', true); - return ["consumer_{$id}" => ['usage_type' => $usage['type'] ?? null]]; - } catch (Exception $e) { - return ["consumer_{$id}" => ['usage_type' => null]]; - } - } - - /** - * Hilfsmethode für numerische Werte - */ - private function getNumericValue($topic) - { - try { - $value = $this->mqttClient->getValue($topic); - return floatval($value ?? 0); - } catch (Exception $e) { - return 0; - } - } - - /** - * IO-Ausgaenge komplett lesen (digital + analog). - */ - private function getIoOutputAll($id) - { - $digital = $this->getIoOutputMap($id, 'digital_output'); - $analog = $this->getIoOutputMap($id, 'analog_output'); - - return [ - "io_{$id}" => [ - 'digital_output' => $digital, - 'analog_output' => $analog - ] - ]; - } - - /** - * Einzelnen IO-Ausgang lesen. - */ - private function getIoOutput($id, $outputName, $outputType = null) - { - if ($outputName === null || trim((string)$outputName) === '') { - return $this->getIoOutputAll($id); - } - - $outputName = trim((string)$outputName); - $type = $this->normalizeIoOutputType($outputType); - $result = null; - - if ($type === 'digital_output' || $type === null) { - $digital = $this->getIoOutputMap($id, 'digital_output'); - if (array_key_exists($outputName, $digital)) { - $result = $digital[$outputName]; - $type = 'digital_output'; - } - } - - if ($result === null && ($type === 'analog_output' || $type === null)) { - $analog = $this->getIoOutputMap($id, 'analog_output'); - if (array_key_exists($outputName, $analog)) { - $result = $analog[$outputName]; - $type = 'analog_output'; - } - } - - return [ - "io_{$id}" => [ - 'output' => $outputName, - 'output_type' => $type, - 'value' => $result - ] - ]; - } - - /** - * IO-Ausgang manuell setzen. - */ - private function setIoOutput($id, $value, $outputName, $outputType = null) - { - if ($id === null || $id === '') { - return ['success' => false, 'message' => 'Missing io_nr']; - } - - if ($outputName === null || trim((string)$outputName) === '') { - return ['success' => false, 'message' => 'Missing io_output']; - } - - $outputName = trim((string)$outputName); - $normalizedType = $this->normalizeIoOutputType($outputType); - $parsedValue = $this->parseIoOutputValue($value); - - if ($normalizedType === null) { - // Automatisch anhand des aktuellen Status bestimmen. - $digital = $this->getIoOutputMap($id, 'digital_output'); - $analog = $this->getIoOutputMap($id, 'analog_output'); - - if (array_key_exists($outputName, $digital)) { - $normalizedType = 'digital_output'; - } elseif (array_key_exists($outputName, $analog)) { - $normalizedType = 'analog_output'; - } elseif (is_bool($parsedValue)) { - $normalizedType = 'digital_output'; - } else { - $normalizedType = 'analog_output'; - } - } - - $topic = "openWB/set/system/io/{$id}/set/manual/{$normalizedType}/{$outputName}"; - - if ($normalizedType === 'digital_output') { - if (is_bool($parsedValue)) { - $payload = $parsedValue ? 'true' : 'false'; - } elseif (is_numeric($parsedValue) && (float)$parsedValue === 0.0) { - $payload = 'false'; - } elseif (is_numeric($parsedValue) && (float)$parsedValue === 1.0) { - $payload = 'true'; - } else { - return ['success' => false, 'message' => 'Digital output expects boolean or 0/1']; - } - } else { - if (is_bool($parsedValue)) { - $payload = $parsedValue ? '1' : '0'; - } else { - $payload = (string)$parsedValue; - } - } - - try { - $this->mqttClient->setValue($topic, $payload); - return [ - 'success' => true, - 'message' => "Set {$normalizedType}/{$outputName} to {$payload} for io {$id}", - 'data' => [ - 'io_nr' => intval($id), - 'io_output' => $outputName, - 'io_output_type' => $normalizedType, - 'value' => $parsedValue - ] - ]; - } catch (Exception $e) { - return ['success' => false, 'message' => 'Failed to set IO output: ' . $e->getMessage()]; - } - } - - /** - * IO-Output-Map von MQTT laden. - */ - private function getIoOutputMap($id, $outputType) - { - try { - $topic = "openWB/io/states/{$id}/get/{$outputType}"; - $value = $this->mqttClient->getValue($topic); - $decoded = json_decode($value ?? '{}', true); - return is_array($decoded) ? $decoded : []; - } catch (Exception $e) { - return []; - } - } - - /** - * Normiert den optionalen io_output_type Parameter. - */ - private function normalizeIoOutputType($outputType) - { - if ($outputType === null || trim((string)$outputType) === '') { - return null; - } - - $value = strtolower(trim((string)$outputType)); - if ($value === 'digital' || $value === 'digital_output') { - return 'digital_output'; - } - if ($value === 'analog' || $value === 'analog_output') { - return 'analog_output'; - } - - return null; - } - - /** - * Wandelt Eingaben fuer IO-Werte in bool/float um. - */ - private function parseIoOutputValue($value) - { - if (is_bool($value)) { - return $value; - } - - if (is_numeric($value)) { - return floatval($value); - } - - $normalized = strtolower(trim((string)$value)); - if (in_array($normalized, ['true', 'on', 'yes'], true)) { - return true; - } - if (in_array($normalized, ['false', 'off', 'no'], true)) { - return false; - } - - if (is_numeric($normalized)) { - return floatval($normalized); - } - - return $value; - } - - /** - * Prüft, ob eine total-Abfrage angefordert wurde. - */ - private function isTotalId($id) - { - return is_string($id) && strtolower(trim($id)) === 'total'; - } - - /** - * Summiert einen Metrik-Wert über alle vorhandenen IDs per MQTT-Wildcard. - */ - private function getTotalMetricValue($componentType, $metric) - { - try { - $pattern = "openWB/{$componentType}/+/get/{$metric}"; - $values = $this->mqttClient->getValuesByWildcard($pattern); - - $sum = 0.0; - foreach ($values as $value) { - if (is_numeric($value)) { - $sum += floatval($value); - } - } - - return $sum; - } catch (Exception $e) { - return 0; - } - } -} From 62b23c480e954d7c87cc8199cdae5ea3cccfb308 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 23 Oct 2026 21:55:57 +0200 Subject: [PATCH 7/8] simpleAPI: clean up Python port, fix chargepoint-ID write bug --- data/config/apache/000-default.conf | 2 +- data/config/apache/apache-openwb-ssl.conf | 2 +- data/config/apache/localhost.conf | 2 +- data/config/openwb-simpleAPI.service | 2 +- simpleAPI/regression_check.py | 306 ---------- simpleAPI/{simpleAPI_mqtt.py => simpleapi.py} | 498 +++++---------- simpleAPI/src/http_server.py | 4 +- simpleAPI/src/parameter_handler.py | 575 +++++------------- 8 files changed, 321 insertions(+), 1070 deletions(-) delete mode 100644 simpleAPI/regression_check.py rename simpleAPI/{simpleAPI_mqtt.py => simpleapi.py} (72%) diff --git a/data/config/apache/000-default.conf b/data/config/apache/000-default.conf index ccdf7d3fbe..de6aff8aad 100644 --- a/data/config/apache/000-default.conf +++ b/data/config/apache/000-default.conf @@ -1,4 +1,4 @@ -# openwb-version:8 +# openwb-version:7 # The ServerName directive sets the request scheme, hostname and port that # the server uses to identify itself. This is used when creating diff --git a/data/config/apache/apache-openwb-ssl.conf b/data/config/apache/apache-openwb-ssl.conf index 390a54896a..6ae98975a5 100644 --- a/data/config/apache/apache-openwb-ssl.conf +++ b/data/config/apache/apache-openwb-ssl.conf @@ -1,4 +1,4 @@ -# openwb-version:10 +# openwb-version:9 ServerAdmin webmaster@localhost diff --git a/data/config/apache/localhost.conf b/data/config/apache/localhost.conf index dce60415ef..85e2eeb384 100644 --- a/data/config/apache/localhost.conf +++ b/data/config/apache/localhost.conf @@ -1,4 +1,4 @@ -# openwb-version:4 +# openwb-version:3 Listen 127.0.0.1:81 diff --git a/data/config/openwb-simpleAPI.service b/data/config/openwb-simpleAPI.service index b549c9223b..6f9c751249 100644 --- a/data/config/openwb-simpleAPI.service +++ b/data/config/openwb-simpleAPI.service @@ -6,7 +6,7 @@ After=mosquitto.service [Service] User=openwb WorkingDirectory=/var/www/html/openWB -ExecStart=/var/www/html/openWB/simpleAPI/simpleAPI_mqtt.py +ExecStart=/var/www/html/openWB/simpleAPI/simpleapi.py Restart=always # extend timeout to 15min for long running atreboot TimeoutStartSec=900 diff --git a/simpleAPI/regression_check.py b/simpleAPI/regression_check.py deleted file mode 100644 index fac5f6a0c0..0000000000 --- a/simpleAPI/regression_check.py +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env python3 -"""simpleAPI regression harness: diffs the old PHP endpoint against the new -Python endpoint for every parameter in the whitelist, so the HTTP-contract -port can be verified parameter-by-parameter against a real device instead of -by inspection. - -Parameter names are imported directly from src/parameter_handler.py (the -single source of truth for the whitelist) rather than hand-copied here, so -the two can't drift apart silently. - -Typical usage, before Apache's ProxyPass for /openWB/simpleAPI/ is deployed -(hits the old PHP file through Apache as today, and the new daemon directly -on its local port): - - python3 simpleAPI/regression_check.py \\ - --old-base-url "https:///openWB/simpleAPI/simpleapi.php" \\ - --new-base-url "http://127.0.0.1:8099/" - -Once the ProxyPass is live, the public URL serves the NEW backend for both -"old" and "new" - at that point this script can only confirm the new backend -answers consistently, not provide an old-vs-new diff anymore. Do the real -old-vs-new comparison beforehand (e.g. via an SSH tunnel to the new backend's -local port, or by temporarily pointing --old-base-url at a second device still -running the PHP version). - -Write parameters are skipped by default - see --include-writes. -""" -import argparse -import sys -import time -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import requests - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from src.parameter_handler import ( # noqa: E402 - READ_PARAMETER_ORDER, - WRITE_PARAMETER_ORDER, - is_chargepoint_parameter, - is_io_parameter, -) - -# Fields whose name marks them as point-in-time event/publish data rather than -# live telemetry - these are expected to legitimately differ between two -# separate HTTP requests (even back-to-back, even against the same backend) -# and are excluded from the diff. Everything else (power, voltages, soc, -# fault_state, ...) is compared exactly, since both endpoints read the same -# retained MQTT state and should agree. -TIMESTAMP_FIELD_NAMES = {'timestamp', 'time'} -TIMESTAMP_FIELD_SUFFIX = '_timestamp' - -# Conservative values for write parameters under --include-writes: chosen to -# be a no-op-ish or easily-reversible state change, not a disruptive one -# (e.g. chargepoint_lock=false rather than true, bat_power_reserve=0 rather -# than some nonzero reservation). 'vehicle' and 'set_io_output' are left out -# deliberately - see --vehicle-id/--io-output-name. -DEFAULT_WRITE_VALUES = { - 'set_chargemode': 'pv', - 'chargecurrent': '6', - 'minimal_pv_soc': '50', - 'minimal_permanent_current': '6', - 'max_price_eco': '10', - 'chargepoint_lock': 'false', - 'bat_mode': 'bat_mode', - 'bat_power_reserve': '0', - 'instant_charging_limit': 'none', - 'instant_charging_amount': '10', - 'instant_charging_soc': '80', - 'pv_charging_limit': 'none', - 'pv_charging_amount': '10', - 'pv_charging_soc': '80', - 'manual_soc': '50', -} - - -def is_timestamp_field(key: str) -> bool: - return key in TIMESTAMP_FIELD_NAMES or key.endswith(TIMESTAMP_FIELD_SUFFIX) - - -def deep_diff(old: Any, new: Any, path: str, float_tolerance: float, diffs: List[str]) -> None: - if isinstance(old, dict) and isinstance(new, dict): - for key in sorted(set(old) | set(new)): - if is_timestamp_field(key): - continue - if key not in old: - diffs.append(f"{path}.{key}: missing in OLD, NEW={new[key]!r}") - elif key not in new: - diffs.append(f"{path}.{key}: OLD={old[key]!r}, missing in NEW") - else: - deep_diff(old[key], new[key], f"{path}.{key}", float_tolerance, diffs) - return - - if isinstance(old, list) and isinstance(new, list): - if len(old) != len(new): - diffs.append(f"{path}: length OLD={len(old)} NEW={len(new)}") - for i, (o, n) in enumerate(zip(old, new)): - deep_diff(o, n, f"{path}[{i}]", float_tolerance, diffs) - return - - is_old_num = isinstance(old, (int, float)) and not isinstance(old, bool) - is_new_num = isinstance(new, (int, float)) and not isinstance(new, bool) - if is_old_num and is_new_num: - if abs(float(old) - float(new)) > float_tolerance: - diffs.append(f"{path}: OLD={old!r} NEW={new!r}") - return - - if old != new: - diffs.append(f"{path}: OLD={old!r} NEW={new!r}") - - -def fetch(base_url: str, query: Dict[str, Any], timeout: float, - verify: bool) -> Tuple[Optional[int], Any, Optional[str]]: - try: - resp = requests.get(base_url, params=query, timeout=timeout, verify=verify) - except requests.RequestException as exc: - return None, None, str(exc) - try: - return resp.status_code, resp.json(), None - except ValueError: - return resp.status_code, resp.text, None - - -def compare(old_base: str, new_base: str, query: Dict[str, Any], path: str, - float_tolerance: float, timeout: float, verify: bool) -> Tuple[Optional[bool], List[str]]: - old_status, old_body, old_err = fetch(old_base, query, timeout, verify) - new_status, new_body, new_err = fetch(new_base, query, timeout, verify) - - if old_err or new_err: - return False, [f"request failed: OLD error={old_err!r} NEW error={new_err!r}"] - if old_status != new_status: - return False, [f"HTTP status: OLD={old_status} NEW={new_status}"] - - diffs: List[str] = [] - deep_diff(old_body, new_body, path, float_tolerance, diffs) - return (len(diffs) == 0), diffs - - -def selected(param: str, only: Optional[List[str]], skip: List[str]) -> bool: - if skip and param in skip: - return False - if only: - return param in only - return True - - -def build_common_params(args: argparse.Namespace) -> Dict[str, Any]: - params: Dict[str, Any] = {} - for item in args.param: - key, _, value = item.partition('=') - params[key] = value - if args.token: - params['token'] = args.token - if args.username is not None: - params['username'] = args.username - if args.password is not None: - params['password'] = args.password - return params - - -def check_read_param(param: str, args: argparse.Namespace, common: Dict[str, Any]) -> Tuple[Optional[bool], List[str]]: - query = {**common, param: args.id} - return compare(args.old_base_url, args.new_base_url, query, param, - args.float_tolerance, args.timeout, not args.insecure) - - -def check_list_components(args: argparse.Namespace, common: Dict[str, Any]) -> Tuple[Optional[bool], List[str]]: - query = {**common, 'list_components': 'true'} - return compare(args.old_base_url, args.new_base_url, query, 'list_components', - args.float_tolerance, args.timeout, not args.insecure) - - -def check_write_param(param: str, args: argparse.Namespace, common: Dict[str, Any]) -> Tuple[Optional[bool], List[str]]: - if param == 'vehicle': - if not args.vehicle_id: - return None, ["skipped: pass --vehicle-id to exercise this parameter"] - value: Any = args.vehicle_id - elif param == 'set_io_output': - if not args.io_output_name: - return None, ["skipped: pass --io-output-name (and --io-output-value) to exercise this parameter"] - value = args.io_output_value - else: - value = DEFAULT_WRITE_VALUES[param] - - query = {**common, param: value} - if is_chargepoint_parameter(param): - query['chargepoint_nr'] = args.chargepoint_nr - if is_io_parameter(param): - query['io_nr'] = args.io_nr - if param == 'set_io_output': - query['io_output'] = args.io_output_name - - return compare(args.old_base_url, args.new_base_url, query, param, - args.float_tolerance, args.timeout, not args.insecure) - - -def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument( - '--old-base-url', required=True, - help="old PHP simpleAPI endpoint, e.g. https:///openWB/simpleAPI/simpleapi.php") - parser.add_argument( - '--new-base-url', required=True, - help="new Python endpoint, e.g. http://127.0.0.1:8099/ " - "(or the public URL once Apache's ProxyPass is live)") - parser.add_argument( - '--id', default='auto', - help="device id used for every read parameter (default: 'auto', resolves to the lowest known id " - "of the matching component type on each side)") - parser.add_argument('--token', help="bare 'token' auth parameter sent with every request") - parser.add_argument('--username') - parser.add_argument('--password') - parser.add_argument( - '--param', action='append', default=[], metavar='KEY=VALUE', - help="extra query parameter sent with every request (repeatable), e.g. --param debug=true") - parser.add_argument('--only', nargs='+', metavar='PARAM', help="only check these exact parameter names") - parser.add_argument('--skip', nargs='+', metavar='PARAM', default=[], help="skip these parameter names") - parser.add_argument( - '--include-writes', action='store_true', - help="also exercise write parameters - performs REAL write operations against whatever device " - "--old-base-url/--new-base-url point at") - parser.add_argument('--chargepoint-nr', default='auto', help="chargepoint_nr for write-parameter checks") - parser.add_argument('--io-nr', default='auto', help="io_nr for write-parameter checks") - parser.add_argument( - '--io-output-name', - help="io_output name required to exercise set_io_output under --include-writes; omitted -> " - "set_io_output is skipped even with --include-writes") - parser.add_argument('--io-output-value', default='0', help="value to write for set_io_output (default: 0)") - parser.add_argument( - '--vehicle-id', - help="vehicle id required to exercise the 'vehicle' write parameter under --include-writes; " - "omitted -> 'vehicle' is skipped even with --include-writes") - parser.add_argument( - '--float-tolerance', type=float, default=0.0, - help="allowed absolute difference for numeric leaves, to absorb live power/voltage jitter between " - "the two near-simultaneous requests (default: 0.0, exact match)") - parser.add_argument('--timeout', type=float, default=10.0, help="per-request HTTP timeout in seconds") - parser.add_argument('--max-diff-lines', type=int, default=20, help="diff lines printed per failing parameter") - parser.add_argument( - '-k', '--insecure', action='store_true', - help="don't verify TLS certificates (e.g. for a device's self-signed HTTPS cert)") - return parser.parse_args(argv) - - -def main(argv: Optional[List[str]] = None) -> int: - args = parse_args(argv) - common = build_common_params(args) - - if args.insecure: - import urllib3 - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - read_params = [p for p in READ_PARAMETER_ORDER if selected(p, args.only, args.skip)] - write_params = [p for p in WRITE_PARAMETER_ORDER if selected(p, args.only, args.skip)] - checks = (['list_components'] if selected('list_components', args.only, args.skip) else []) + read_params - if args.include_writes: - checks += write_params - - print("simpleAPI regression check") - print(f" OLD (PHP): {args.old_base_url}") - print(f" NEW (Python): {args.new_base_url}") - print(f" read parameters: {len(read_params)}") - - if args.include_writes: - print() - print("WARNING: --include-writes performs REAL write operations (chargemode, chargecurrent,") - print("bat_mode, lock state, io outputs, ...) against whatever device both base URLs point at.") - print("Only run this against a non-production test device. Starting in 3 seconds...") - print(f" write parameters: {len(write_params)}") - time.sleep(3) - print() - - name_width = max((len(name) for name in checks), default=10) + 2 - print(f"{'PARAMETER':<{name_width}}RESULT") - print('-' * (name_width + 6)) - - results: List[Tuple[str, Optional[bool], List[str]]] = [] - for name in checks: - if name == 'list_components': - ok, diffs = check_list_components(args, common) - elif name in write_params: - ok, diffs = check_write_param(name, args, common) - else: - ok, diffs = check_read_param(name, args, common) - results.append((name, ok, diffs)) - - status = 'PASS' if ok is True else 'SKIP' if ok is None else 'FAIL' - print(f"{name:<{name_width}}{status}", flush=True) - if ok is False: - for line in diffs[:args.max_diff_lines]: - print(f" {line}") - if len(diffs) > args.max_diff_lines: - print(f" ... {len(diffs) - args.max_diff_lines} more diff line(s)") - - passed = sum(1 for _, ok, _ in results if ok is True) - failed = sum(1 for _, ok, _ in results if ok is False) - skipped = sum(1 for _, ok, _ in results if ok is None) - print() - print(f"{passed} passed, {failed} failed, {skipped} skipped (of {len(results)} checked)") - - return 1 if failed else 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/simpleAPI/simpleAPI_mqtt.py b/simpleAPI/simpleapi.py similarity index 72% rename from simpleAPI/simpleAPI_mqtt.py rename to simpleAPI/simpleapi.py index 366c7c6883..a4feb1bf92 100755 --- a/simpleAPI/simpleAPI_mqtt.py +++ b/simpleAPI/simpleapi.py @@ -14,7 +14,7 @@ import sys import ssl import math -from typing import Dict, Any, Optional, Set, Tuple +from typing import Callable, Dict, Any, Optional, Set, Tuple from pathlib import Path import paho.mqtt.client as mqtt import re @@ -60,10 +60,6 @@ def __init__(self, config_file: str): # original openWB topics the HTTP read handlers need to mirror. self.raw_value_cache: Dict[str, Any] = {} - # Raw JSON text of openWB/graph/lastlivevaluesJson, served 1:1 by - # get_lastlivevaluesjson - self.last_live_values_json: Optional[str] = None - # Track per-ID component values for efficient total topic aggregation self.total_metrics = { 'pv': {'exported', 'monthly_exported', 'yearly_exported', 'imported', 'power'}, @@ -139,7 +135,6 @@ def _on_connect(self, client, userdata, flags, rc): client.subscribe("openWB/counter/#", qos=0) client.subscribe("openWB/consumer/#", qos=0) client.subscribe("openWB/io/#", qos=0) - client.subscribe("openWB/graph/lastlivevaluesJson", qos=0) # Subscribe to simpleAPI set topics for write operations client.subscribe("openWB/simpleAPI/set/#", qos=0) @@ -197,10 +192,6 @@ def _on_message(self, client, userdata, msg): if '/simpleAPI/' in topic: return - if topic == 'openWB/graph/lastlivevaluesJson': - self.last_live_values_json = payload - return - # Cache charge_template configurations for later use if '/set/charge_template' in topic: self._cache_charge_template(topic, payload) @@ -215,28 +206,17 @@ def _on_message(self, client, userdata, msg): self._update_total_topics(topic, parsed_payload) # Transform and publish the message - self._transform_and_publish(topic, payload, parsed_payload) + self._transform_and_publish(topic, parsed_payload) except Exception as e: log.error(f"Error processing message {msg.topic}: {e}") - def _transform_and_publish(self, original_topic: str, payload: str, parsed_payload: Any = None): + def _transform_and_publish(self, original_topic: str, parsed_payload: Any): """Transform original topic to simpleAPI format and publish if value changed.""" try: - log.debug(f"DEBUG: Processing original topic: {original_topic}") - - # Parse the payload - if parsed_payload is None: - parsed_payload = self._parse_payload(payload) - # Generate transformed topics transformed_topics = self._generate_simple_topics(original_topic, parsed_payload) - log.debug( - f"DEBUG: Generated {len(transformed_topics)} transformed topics: " - f"{list(transformed_topics.keys())}" - ) - # Publish each transformed topic if value changed for topic, value in transformed_topics.items(): self._publish_if_changed(topic, value) @@ -349,8 +329,10 @@ def _generate_simple_topics(self, original_topic: str, parsed_value: Any) -> Dic if simple_base is None: # Topic should be filtered out return result - # Handle different value types - self._expand_value_to_topics(simple_base, parsed_value, result) + # Handle different value types - simple_base was already run through the + # chargepoint transform above, so the top-level (non-recursive) expansion + # doesn't need to redo it. + self._expand_value_to_topics(simple_base, parsed_value, result, already_transformed=True) # Generate simplified topics for lowest IDs simplified_topics = self._generate_simplified_topics(simple_base, parsed_value) @@ -361,25 +343,17 @@ def _generate_simple_topics(self, original_topic: str, parsed_value: Any) -> Dic def _transform_chargepoint_topic(self, simple_base: str) -> Optional[str]: """Transform chargepoint topics according to simplified structure.""" - log.debug(f"DEBUG: _transform_chargepoint_topic input: {simple_base}") + log.debug(f"_transform_chargepoint_topic input: {simple_base}") # FIRST: Handle connected_vehicle transformations (both /get/ and direct) # This must happen BEFORE any other filtering if '/connected_vehicle/config/chargemode' in simple_base: - log.debug(f"DEBUG: Found chargemode pattern, transforming: {simple_base}") - original = simple_base simple_base = re.sub(r'/get/connected_vehicle/config/chargemode$', '/chargemode', simple_base) - log.debug(f"DEBUG: After first regex: {simple_base}") simple_base = re.sub(r'/connected_vehicle/config/chargemode$', '/chargemode', simple_base) - log.debug(f"DEBUG: After second regex: {simple_base}") - if original != simple_base: - log.debug(f"DEBUG: Charge mode transformation successful: {original} -> {simple_base}") elif '/connected_vehicle/info/name' in simple_base: - log.debug(f"DEBUG: Found vehicle name pattern, transforming: {simple_base}") simple_base = re.sub(r'/get/connected_vehicle/info/name$', '/vehicle_name', simple_base) simple_base = re.sub(r'/connected_vehicle/info/name$', '/vehicle_name', simple_base) elif '/connected_vehicle/soc' in simple_base: - log.debug(f"DEBUG: Found connected_vehicle soc pattern, transforming: {simple_base}") simple_base = re.sub(r'/get/connected_vehicle/soc$', '/soc', simple_base) simple_base = re.sub(r'/connected_vehicle/soc$', '/soc', simple_base) @@ -439,10 +413,10 @@ def _transform_chargepoint_topic(self, simple_base: str) -> Optional[str]: # but exclude the ones we already transformed if not re.search(r'/(chargemode|vehicle_name|soc)$', simple_base): if re.search(r'/connected_vehicle/', simple_base): - log.debug(f"DEBUG: Filtering out connected_vehicle topic: {simple_base}") + log.debug(f"Filtering out connected_vehicle topic: {simple_base}") return None - log.debug(f"DEBUG: _transform_chargepoint_topic output: {simple_base}") + log.debug(f"_transform_chargepoint_topic output: {simple_base}") return simple_base def _track_component_ids(self, topic: str): @@ -464,8 +438,15 @@ def _register_component_id(self, component_type: str, component_id: int): else: self.lowest_ids[component_type] = min(self.lowest_ids[component_type], component_id) - def _expand_value_to_topics(self, base_topic: str, value: Any, result: Dict[str, Any]): - """Expand complex values (JSON, tuples) into individual topics.""" + def _expand_value_to_topics( + self, base_topic: str, value: Any, result: Dict[str, Any], already_transformed: bool = False): + """Expand complex values (JSON, tuples) into individual topics. + + already_transformed is True only for the initial (non-recursive) call, + whose base_topic has already been run through _transform_chargepoint_topic + once by the caller - recursive calls append a new key/index suffix the + caller never saw, so those topics still need their own transform. + """ if isinstance(value, dict): # Handle JSON objects for key, val in value.items(): @@ -479,7 +460,7 @@ def _expand_value_to_topics(self, base_topic: str, value: Any, result: Dict[str, else: # Raw value - apply chargepoint transformations to final topics final_topic = base_topic - if '/chargepoint/' in base_topic: + if '/chargepoint/' in base_topic and not already_transformed: transformed = self._transform_chargepoint_topic(base_topic) if transformed is not None: final_topic = transformed @@ -506,12 +487,14 @@ def _generate_simplified_topics(self, simple_topic: str, parsed_value: Any) -> D simplified_base = f"openWB/simpleAPI/{component_type}/{remaining_path}" # Apply chargepoint transformations to simplified topics as well + already_transformed = False if component_type == 'chargepoint': simplified_base = self._transform_chargepoint_topic(simplified_base) if simplified_base is None: return result + already_transformed = True - self._expand_value_to_topics(simplified_base, parsed_value, result) + self._expand_value_to_topics(simplified_base, parsed_value, result, already_transformed) return result @@ -554,111 +537,73 @@ def _cache_charge_template(self, topic: str, payload: str): except Exception as e: log.error(f"Error caching charge_template for {topic}: {e}") + # (path from charge_template root, key in that container, output field name, + # optional value transform) - the set of charge_template values that get + # mirrored out as simpleAPI read topics whenever a charge_template is cached. + _CHARGE_TEMPLATE_READ_FIELDS: Tuple[Tuple[Tuple[str, ...], str, str, Optional[Callable[[Any], Any]]], ...] = ( + (('chargemode', 'pv_charging'), 'min_current', 'minimal_permanent_current', None), + (('chargemode', 'pv_charging'), 'min_soc', 'minimal_pv_soc', None), + (('chargemode', 'eco_charging'), 'max_price', 'max_price_eco', None), + (('chargemode', 'instant_charging', 'limit'), 'selected', 'instant_charging_limit', None), + # amount is stored internally in Wh - published in kWh + (('chargemode', 'instant_charging', 'limit'), 'amount', 'instant_charging_limit_amount', lambda v: v / 1000), + (('chargemode', 'instant_charging', 'limit'), 'soc', 'instant_charging_limit_soc', None), + (('chargemode', 'pv_charging', 'limit'), 'selected', 'pv_charging_limit', None), + (('chargemode', 'pv_charging', 'limit'), 'amount', 'pv_charging_limit_amount', lambda v: v / 1000), + (('chargemode', 'pv_charging', 'limit'), 'soc', 'pv_charging_limit_soc', None), + ) + + def _is_lowest_chargepoint(self, chargepoint_id: str) -> bool: + return 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id) + + def _publish_pair(self, chargepoint_id: str, field: str, value: Any): + """Publish a chargepoint-scoped simpleAPI topic, and also under the + ID-less alias if this chargepoint happens to be the lowest known one.""" + self._publish_if_changed(f"openWB/simpleAPI/chargepoint/{chargepoint_id}/{field}", value) + if self._is_lowest_chargepoint(chargepoint_id): + self._publish_if_changed(f"openWB/simpleAPI/chargepoint/{field}", value) + def _publish_charge_template_read_topics(self, chargepoint_id: str, charge_template: Dict[str, Any]): """Publish readable topics for charge_template values.""" try: - chargemode = charge_template.get('chargemode', {}) - - # Extract min_current from pv_charging - pv_charging = chargemode.get('pv_charging', {}) - if 'min_current' in pv_charging: - topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/minimal_permanent_current" - self._publish_if_changed(topic, pv_charging['min_current']) - - # Also publish for lowest ID if this is it - if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id): - simple_topic = "openWB/simpleAPI/chargepoint/minimal_permanent_current" - self._publish_if_changed(simple_topic, pv_charging['min_current']) - - # Extract min_soc from pv_charging (minimal_pv_soc) - if 'min_soc' in pv_charging: - topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/minimal_pv_soc" - self._publish_if_changed(topic, pv_charging['min_soc']) - - # Also publish for lowest ID if this is it - if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id): - simple_topic = "openWB/simpleAPI/chargepoint/minimal_pv_soc" - self._publish_if_changed(simple_topic, pv_charging['min_soc']) - - # Extract max_price from eco_charging - eco_charging = chargemode.get('eco_charging', {}) - if 'max_price' in eco_charging: - topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/max_price_eco" - self._publish_if_changed(topic, eco_charging['max_price']) - - # Also publish for lowest ID if this is it - if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id): - simple_topic = "openWB/simpleAPI/chargepoint/max_price_eco" - self._publish_if_changed(simple_topic, eco_charging['max_price']) - - # Extract instant_charging_limit values - instant_charging = chargemode.get('instant_charging', {}) - limit = instant_charging.get('limit', {}) - - if 'selected' in limit: - topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/instant_charging_limit" - self._publish_if_changed(topic, limit['selected']) - - # Also publish for lowest ID if this is it - if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id): - simple_topic = "openWB/simpleAPI/chargepoint/instant_charging_limit" - self._publish_if_changed(simple_topic, limit['selected']) - - if 'amount' in limit: - # Convert from internal value (Wh) to kWh for display - amount_kwh = limit['amount'] / 1000 - topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/instant_charging_limit_amount" - self._publish_if_changed(topic, amount_kwh) - - # Also publish for lowest ID if this is it - if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id): - simple_topic = "openWB/simpleAPI/chargepoint/instant_charging_limit_amount" - self._publish_if_changed(simple_topic, amount_kwh) - - if 'soc' in limit: - topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/instant_charging_limit_soc" - self._publish_if_changed(topic, limit['soc']) - - # Also publish for lowest ID if this is it - if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id): - simple_topic = "openWB/simpleAPI/chargepoint/instant_charging_limit_soc" - self._publish_if_changed(simple_topic, limit['soc']) - - # Extract pv_charging_limit values - pv_limit = pv_charging.get('limit', {}) - - if 'selected' in pv_limit: - topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/pv_charging_limit" - self._publish_if_changed(topic, pv_limit['selected']) - - # Also publish for lowest ID if this is it - if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id): - simple_topic = "openWB/simpleAPI/chargepoint/pv_charging_limit" - self._publish_if_changed(simple_topic, pv_limit['selected']) - - if 'amount' in pv_limit: - # Convert from internal value (Wh) to kWh for display - amount_kwh = pv_limit['amount'] / 1000 - topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/pv_charging_limit_amount" - self._publish_if_changed(topic, amount_kwh) - - # Also publish for lowest ID if this is it - if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id): - simple_topic = "openWB/simpleAPI/chargepoint/pv_charging_limit_amount" - self._publish_if_changed(simple_topic, amount_kwh) - - if 'soc' in pv_limit: - topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/pv_charging_limit_soc" - self._publish_if_changed(topic, pv_limit['soc']) - - # Also publish for lowest ID if this is it - if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id): - simple_topic = "openWB/simpleAPI/chargepoint/pv_charging_limit_soc" - self._publish_if_changed(simple_topic, pv_limit['soc']) + for path, key, field, transform in self._CHARGE_TEMPLATE_READ_FIELDS: + container = charge_template + for step in path: + container = container.get(step, {}) + if key in container: + value = container[key] + if transform is not None: + value = transform(value) + self._publish_pair(chargepoint_id, field, value) except Exception as e: log.error(f"Error publishing charge_template read topics: {e}") + # Ordered (topic substring, handler method name) pairs for instant/pv charging + # limit set topics - checked in this order so the more specific suffixes + # (_soc, _amount) are matched before the bare limit type, which is itself a + # substring of both. + _LIMIT_OPERATIONS: Tuple[Tuple[str, str], ...] = ( + ('instant_charging_limit_soc', '_handle_instant_charging_limit_soc_operation'), + ('instant_charging_limit_amount', '_handle_instant_charging_limit_amount_operation'), + ('instant_charging_limit', '_handle_instant_charging_limit_operation'), + ('pv_charging_limit_soc', '_handle_pv_charging_limit_soc_operation'), + ('pv_charging_limit_amount', '_handle_pv_charging_limit_amount_operation'), + ('pv_charging_limit', '_handle_pv_charging_limit_operation'), + ) + + def _parse_limit_topic_target(self, topic_remainder: str) -> Tuple[Optional[str], str]: + """Split a set-topic remainder into (explicit chargepoint ID or None, parameter name). + + A limit topic arrives either as a bare parameter name (falls back to the + lowest known chargepoint ID) or as chargepoint// (explicit + ID) - mirrors the explicit-ID check in _handle_chargepoint_operation. + """ + parts = topic_remainder.split('/') + if len(parts) >= 3 and parts[0] == 'chargepoint' and parts[1].isdigit(): + return parts[1], parts[2] + return None, topic_remainder + def _handle_write_operation(self, topic: str, payload: str): """Handle write operations from simpleAPI set topics.""" try: @@ -672,39 +617,15 @@ def _handle_write_operation(self, topic: str, payload: str): # Parse the set topic to extract operation details topic_remainder = topic.replace('openWB/simpleAPI/set/', '') - # Check for instant_charging_limit operations first (can be with or without chargepoint ID) - if 'instant_charging_limit_soc' in topic_remainder: - success, _ = self._handle_instant_charging_limit_soc_operation(payload) - if success: - self._clear_set_topic(topic) - return - elif 'instant_charging_limit_amount' in topic_remainder: - success, _ = self._handle_instant_charging_limit_amount_operation(payload) - if success: - self._clear_set_topic(topic) - return - elif 'instant_charging_limit' in topic_remainder: - success, _ = self._handle_instant_charging_limit_operation(payload) - if success: - self._clear_set_topic(topic) - return - - # Check for pv_charging_limit operations first (can be with or without chargepoint ID) - if 'pv_charging_limit_soc' in topic_remainder: - success, _ = self._handle_pv_charging_limit_soc_operation(payload) - if success: - self._clear_set_topic(topic) - return - elif 'pv_charging_limit_amount' in topic_remainder: - success, _ = self._handle_pv_charging_limit_amount_operation(payload) - if success: - self._clear_set_topic(topic) - return - elif 'pv_charging_limit' in topic_remainder: - success, _ = self._handle_pv_charging_limit_operation(payload) - if success: - self._clear_set_topic(topic) - return + # Instant/pv charging limit operations (can be with or without an explicit chargepoint ID) + chargepoint_id, limit_param = self._parse_limit_topic_target(topic_remainder) + for substring, handler_name in self._LIMIT_OPERATIONS: + if substring in limit_param: + handler = getattr(self, handler_name) + success, _ = handler(payload, chargepoint_id) + if success: + self._clear_set_topic(topic) + return topic_parts = topic_remainder.split('/') @@ -787,6 +708,24 @@ def _get_charge_template(self, chargepoint_id: str) -> Optional[Dict[str, Any]]: log.warning(f"No cached charge_template for chargepoint {chargepoint_id}") return None + def _set_charge_template_field( + self, chargepoint_id: str, keypath: Tuple[str, ...], value: Any) -> Optional[Dict[str, Any]]: + """Fetch chargepoint_id's cached charge_template, set the nested field at + keypath, publish it back to openWB, and return the mutated template - or + None if no cached template is available (caller supplies the failure message).""" + charge_template = self._get_charge_template(chargepoint_id) + if charge_template is None: + return None + + container = charge_template + for step in keypath[:-1]: + container = container[step] + container[keypath[-1]] = value + + target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" + self._publish_json(target_topic, charge_template) + return charge_template + def _set_chargemode(self, chargepoint_id: str, mode: str) -> Tuple[bool, str]: """Set chargemode for a chargepoint.""" # Mapping from simple values to internal values @@ -809,19 +748,11 @@ def _set_chargemode(self, chargepoint_id: str, mode: str) -> Tuple[bool, str]: return False, message internal_mode = mode_mapping[mode] - charge_template = self._get_charge_template(chargepoint_id) - - if charge_template is None: + if self._set_charge_template_field(chargepoint_id, ('chargemode', 'selected'), internal_mode) is None: message = f"No charge_template available for chargepoint {chargepoint_id}" log.error(message) return False, message - # Modify the chargemode.selected value - charge_template['chargemode']['selected'] = internal_mode - - # Publish the modified template - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) log.info(f"Set chargemode to {mode} ({internal_mode}) for chargepoint {chargepoint_id}") return True, f"Chargemode set to {mode} ({internal_mode})" @@ -834,16 +765,12 @@ def _set_chargecurrent(self, chargepoint_id: str, current: str) -> Tuple[bool, s log.error(message) return False, message - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: + if self._set_charge_template_field( + chargepoint_id, ('chargemode', 'instant_charging', 'current'), current_value) is None: message = f"No charge_template available for chargepoint {chargepoint_id}" log.error(message) return False, message - charge_template['chargemode']['instant_charging']['current'] = current_value - - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) log.info(f"Set charge current to {current_value}A for chargepoint {chargepoint_id}") return True, f"Chargecurrent set to {current_value}A for chargepoint {chargepoint_id}" @@ -856,16 +783,12 @@ def _set_minimal_pv_soc(self, chargepoint_id: str, soc: str) -> Tuple[bool, str] log.error(message) return False, message - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: + if self._set_charge_template_field( + chargepoint_id, ('chargemode', 'pv_charging', 'min_soc'), soc_value) is None: message = f"No charge_template available for chargepoint {chargepoint_id}" log.error(message) return False, message - charge_template['chargemode']['pv_charging']['min_soc'] = soc_value - - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) log.info(f"Set minimal PV SoC to {soc_value}% for chargepoint {chargepoint_id}") return True, f"Minimal PV SoC set to {soc_value}% for chargepoint {chargepoint_id}" @@ -878,16 +801,12 @@ def _set_minimal_permanent_current(self, chargepoint_id: str, current: str) -> T log.error(message) return False, message - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: + if self._set_charge_template_field( + chargepoint_id, ('chargemode', 'pv_charging', 'min_current'), current_value) is None: message = f"No charge_template available for chargepoint {chargepoint_id}" log.error(message) return False, message - charge_template['chargemode']['pv_charging']['min_current'] = current_value - - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) log.info(f"Set minimal permanent current to {current_value}A for chargepoint {chargepoint_id}") return True, f"Minimal permanent current set to {current_value}A for chargepoint {chargepoint_id}" @@ -900,16 +819,12 @@ def _set_max_price_eco(self, chargepoint_id: str, price: str) -> Tuple[bool, str log.error(message) return False, message - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: + if self._set_charge_template_field( + chargepoint_id, ('chargemode', 'eco_charging', 'max_price'), price_value) is None: message = f"No charge_template available for chargepoint {chargepoint_id}" log.error(message) return False, message - charge_template['chargemode']['eco_charging']['max_price'] = price_value - - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) log.info(f"Set max ECO price to {price_value} for chargepoint {chargepoint_id}") return True, f"Max price eco set to {price_value} for chargepoint {chargepoint_id}" @@ -975,38 +890,38 @@ def _resolve_chargepoint_id(self, chargepoint_id: Optional[str]) -> Optional[str return str(self.lowest_ids['chargepoint']) return None - def _handle_instant_charging_limit_operation( - self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: - """Handle instant charging limit type operation.""" + def _set_charging_limit_type( + self, mode_key: str, label: str, payload: str, + chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: + """Shared body for instant_charging_limit / pv_charging_limit (the + chargemode..limit.selected field).""" valid_limits = ['none', 'soc', 'amount'] if payload not in valid_limits: - message = f"Invalid instant_charging_limit: {payload}. Valid values: {valid_limits}" + message = f"Invalid {mode_key}_limit: {payload}. Valid values: {valid_limits}" log.error(message) return False, message chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) if chargepoint_id is None: - message = "No chargepoint ID found for instant_charging_limit operation" + message = f"No chargepoint ID found for {mode_key}_limit operation" log.error(message) return False, message - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: + if self._set_charge_template_field( + chargepoint_id, ('chargemode', mode_key, 'limit', 'selected'), payload) is None: message = f"No charge_template available for chargepoint {chargepoint_id}" log.error(message) return False, message - charge_template['chargemode']['instant_charging']['limit']['selected'] = payload - - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) - log.info(f"Set instant_charging_limit to {payload} for chargepoint {chargepoint_id}") - return True, f"Instant charging limit set to {payload} for chargepoint {chargepoint_id}" + log.info(f"Set {mode_key}_limit to {payload} for chargepoint {chargepoint_id}") + return True, f"{label} limit set to {payload} for chargepoint {chargepoint_id}" - def _handle_instant_charging_limit_soc_operation( - self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: - """Handle instant charging limit SoC operation.""" + def _set_charging_limit_soc( + self, mode_key: str, label: str, payload: str, + chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: + """Shared body for instant_charging_limit_soc / pv_charging_limit_soc + (the chargemode..limit.soc field).""" try: soc_value = int(payload) if soc_value < 0 or soc_value > 100: @@ -1020,26 +935,24 @@ def _handle_instant_charging_limit_soc_operation( chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) if chargepoint_id is None: - message = "No chargepoint ID found for instant_charging_limit_soc operation" + message = f"No chargepoint ID found for {mode_key}_limit_soc operation" log.error(message) return False, message - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: + if self._set_charge_template_field( + chargepoint_id, ('chargemode', mode_key, 'limit', 'soc'), soc_value) is None: message = f"No charge_template available for chargepoint {chargepoint_id}" log.error(message) return False, message - charge_template['chargemode']['instant_charging']['limit']['soc'] = soc_value + log.info(f"Set {mode_key}_limit_soc to {soc_value}% for chargepoint {chargepoint_id}") + return True, f"{label} SoC set to {soc_value}% for chargepoint {chargepoint_id}" - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) - log.info(f"Set instant_charging_limit_soc to {soc_value}% for chargepoint {chargepoint_id}") - return True, f"Instant charging SoC set to {soc_value}% for chargepoint {chargepoint_id}" - - def _handle_instant_charging_limit_amount_operation( - self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: - """Handle instant charging limit amount operation.""" + def _set_charging_limit_amount( + self, mode_key: str, label: str, payload: str, + chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: + """Shared body for instant_charging_limit_amount / pv_charging_limit_amount + (the chargemode..limit.amount field, stored internally in Wh).""" try: amount_value = int(payload) if amount_value < 1 or amount_value > 50: @@ -1054,132 +967,55 @@ def _handle_instant_charging_limit_amount_operation( chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) if chargepoint_id is None: - message = "No chargepoint ID found for instant_charging_limit_amount operation" + message = f"No chargepoint ID found for {mode_key}_limit_amount operation" log.error(message) return False, message - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: + if self._set_charge_template_field( + chargepoint_id, ('chargemode', mode_key, 'limit', 'amount'), internal_amount) is None: message = f"No charge_template available for chargepoint {chargepoint_id}" log.error(message) return False, message - charge_template['chargemode']['instant_charging']['limit']['amount'] = internal_amount - - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) log.info( - f"Set instant_charging_limit_amount to {amount_value} kWh " + f"Set {mode_key}_limit_amount to {amount_value} kWh " f"({internal_amount} Wh) for chargepoint {chargepoint_id}" ) message = ( - f"Instant charging amount set to {amount_value}kWh ({internal_amount}Wh) " + f"{label} amount set to {amount_value}kWh ({internal_amount}Wh) " f"for chargepoint {chargepoint_id}" ) return True, message - def _handle_pv_charging_limit_operation( + def _handle_instant_charging_limit_operation( self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: - """Handle PV charging limit type operation.""" - valid_limits = ['none', 'soc', 'amount'] - - if payload not in valid_limits: - message = f"Invalid pv_charging_limit: {payload}. Valid values: {valid_limits}" - log.error(message) - return False, message - - chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) - if chargepoint_id is None: - message = "No chargepoint ID found for pv_charging_limit operation" - log.error(message) - return False, message + """Handle instant charging limit type operation.""" + return self._set_charging_limit_type('instant_charging', 'Instant charging', payload, chargepoint_id) - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: - message = f"No charge_template available for chargepoint {chargepoint_id}" - log.error(message) - return False, message + def _handle_instant_charging_limit_soc_operation( + self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: + """Handle instant charging limit SoC operation.""" + return self._set_charging_limit_soc('instant_charging', 'Instant charging', payload, chargepoint_id) - charge_template['chargemode']['pv_charging']['limit']['selected'] = payload + def _handle_instant_charging_limit_amount_operation( + self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: + """Handle instant charging limit amount operation.""" + return self._set_charging_limit_amount('instant_charging', 'Instant charging', payload, chargepoint_id) - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) - log.info(f"Set pv_charging_limit to {payload} for chargepoint {chargepoint_id}") - return True, f"PV charging limit set to {payload} for chargepoint {chargepoint_id}" + def _handle_pv_charging_limit_operation( + self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: + """Handle PV charging limit type operation.""" + return self._set_charging_limit_type('pv_charging', 'PV charging', payload, chargepoint_id) def _handle_pv_charging_limit_soc_operation( self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: """Handle PV charging limit SoC operation.""" - try: - soc_value = int(payload) - if soc_value < 0 or soc_value > 100: - message = f"Invalid SoC value: {soc_value}. Must be between 0 and 100" - log.error(message) - return False, message - except ValueError: - message = f"Invalid SoC value: {payload}. Must be an integer" - log.error(message) - return False, message - - chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) - if chargepoint_id is None: - message = "No chargepoint ID found for pv_charging_limit_soc operation" - log.error(message) - return False, message - - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: - message = f"No charge_template available for chargepoint {chargepoint_id}" - log.error(message) - return False, message - - charge_template['chargemode']['pv_charging']['limit']['soc'] = soc_value - - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) - log.info(f"Set pv_charging_limit_soc to {soc_value}% for chargepoint {chargepoint_id}") - return True, f"PV charging SoC set to {soc_value}% for chargepoint {chargepoint_id}" + return self._set_charging_limit_soc('pv_charging', 'PV charging', payload, chargepoint_id) def _handle_pv_charging_limit_amount_operation( self, payload: str, chargepoint_id: Optional[str] = None) -> Tuple[bool, str]: """Handle PV charging limit amount operation.""" - try: - amount_value = int(payload) - if amount_value < 1 or amount_value > 50: - message = f"Invalid amount value: {amount_value}. Must be between 1 and 50" - log.error(message) - return False, message - internal_amount = amount_value * 1000 - except ValueError: - message = f"Invalid amount value: {payload}. Must be an integer" - log.error(message) - return False, message - - chargepoint_id = self._resolve_chargepoint_id(chargepoint_id) - if chargepoint_id is None: - message = "No chargepoint ID found for pv_charging_limit_amount operation" - log.error(message) - return False, message - - charge_template = self._get_charge_template(chargepoint_id) - if charge_template is None: - message = f"No charge_template available for chargepoint {chargepoint_id}" - log.error(message) - return False, message - - charge_template['chargemode']['pv_charging']['limit']['amount'] = internal_amount - - target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" - self._publish_json(target_topic, charge_template) - log.info( - f"Set pv_charging_limit_amount to {amount_value} kWh " - f"({internal_amount} Wh) for chargepoint {chargepoint_id}" - ) - message = ( - f"PV charging amount set to {amount_value}kWh ({internal_amount}Wh) " - f"for chargepoint {chargepoint_id}" - ) - return True, message + return self._set_charging_limit_amount('pv_charging', 'PV charging', payload, chargepoint_id) def _publish_json(self, topic: str, data: Dict[str, Any]): """Publish JSON data to a topic.""" diff --git a/simpleAPI/src/http_server.py b/simpleAPI/src/http_server.py index 3a46636722..4bc552241e 100644 --- a/simpleAPI/src/http_server.py +++ b/simpleAPI/src/http_server.py @@ -133,9 +133,7 @@ def _handle(self, params: Dict[str, str]): return self._send(200, format_raw_output(result, param_name), raw=True) else: - if len(read_params) == 1 and next(iter(read_params)) == 'get_lastlivevaluesjson': - self._send(200, format_raw_output(result, 'get_lastlivevaluesjson'), raw=True) - elif not result: + if not result: self._send(200, []) else: self._send(200, result) diff --git a/simpleAPI/src/parameter_handler.py b/simpleAPI/src/parameter_handler.py index d9815b7082..bb4aec9857 100644 --- a/simpleAPI/src/parameter_handler.py +++ b/simpleAPI/src/parameter_handler.py @@ -40,10 +40,7 @@ 'pv_charging_limit', 'pv_charging_amount', 'pv_charging_soc', 'vehicle', 'manual_soc', } -# Full read whitelist (what counts as a recognized read parameter). Only the -# chargepoint entries and get_lastlivevaluesjson have a handler in this phase; -# the rest (counter/battery/pv/consumer/io) are recognized but return nothing -# until a later phase adds their handlers. +# Full read whitelist (what counts as a recognized read parameter). READ_PARAMETER_ORDER: List[str] = [ 'get_chargepoint_all', 'get_chargepoint_voltage_p1', 'get_chargepoint_voltage_p2', 'get_chargepoint_voltage_p3', @@ -86,7 +83,6 @@ 'get_consumer_phases_in_use', 'get_consumer_charge_state', 'get_consumer_state_str', 'get_consumer_fault_str', 'get_consumer_fault_state', 'get_consumer_usage_type', 'get_io_output_all', 'get_io_output', - 'get_lastlivevaluesjson', ] COMPLEX_PARAMETERS = { @@ -157,8 +153,6 @@ def format_raw_output(data: Any, param_name: Optional[str] = None) -> Any: if isinstance(data, dict) and data: first_key = next(iter(data)) first_value = data[first_key] - if param_name == 'get_lastlivevaluesjson' and isinstance(first_value, str): - return first_value if isinstance(first_value, str): return first_value if isinstance(first_value, dict) and len(first_value) == 1: @@ -181,7 +175,10 @@ def _as_int(value: Any, default: int = 0) -> int: return int(default) -def _as_bool(value: Any) -> bool: +def _as_bool(value: Any, default: Any = None) -> bool: + # default is unused (bool has no sensible "missing" distinct from False) - + # accepted only so this coercer has the same (value, default) signature as + # the others and can sit in the same dispatch table. if isinstance(value, bool): return value if isinstance(value, str): @@ -195,7 +192,9 @@ def _as_str(value: Any, default: Any = '') -> Any: return value if isinstance(value, str) else str(value) -def _phases(value: Any) -> List[float]: +def _phases(value: Any, default: Any = None) -> List[float]: + # default is unused (each slot already defaults to 0.0) - accepted only for + # a uniform (value, default) coercer signature, see _as_bool. if not isinstance(value, (list, tuple)): return [0.0, 0.0, 0.0] return [_as_float(value[i]) if i < len(value) else 0.0 for i in range(3)] @@ -209,8 +208,8 @@ def _raw(daemon: Any, topic: str) -> Any: return daemon.raw_value_cache.get(topic) -def _scalar(id_: Any, field: str, value: Any) -> Dict[str, Any]: - return {f"chargepoint_{id_}": {field: value}} +def _comp_scalar(component: str, id_: Any, field: str, value: Any) -> Dict[str, Any]: + return {f"{component}_{id_}": {field: value}} def _get_chargepoint_all(daemon: Any, id_: Any) -> Dict[str, Any]: @@ -287,156 +286,163 @@ def _get_chargepoint_all(daemon: Any, id_: Any) -> Dict[str, Any]: } -def _get_chargepoint_voltage(daemon, id_, phase): - return _scalar(id_, f'voltage_p{phase}', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/voltages"))[phase - 1]) - - -def _get_chargepoint_voltages(daemon, id_): - return _scalar(id_, 'voltages', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/voltages"))) - - -def _get_chargepoint_current(daemon, id_, phase): - return _scalar(id_, f'current_p{phase}', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/currents"))[phase - 1]) - - -def _get_chargepoint_currents(daemon, id_): - return _scalar(id_, 'currents', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/currents"))) - - -def _get_chargepoint_power(daemon, id_): - if _is_total(id_): - return {'chargepoint_total': {'power': daemon.total_sums['chargepoint']['power']}} - return _scalar(id_, 'power', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/power"))) - - -def _get_chargepoint_powers(daemon, id_): - return _scalar(id_, 'powers', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/powers"))) - - -def _get_chargepoint_imported(daemon, id_): - if _is_total(id_): - return {'chargepoint_total': {'imported': daemon.total_sums['chargepoint']['imported']}} - return _scalar(id_, 'imported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/imported"))) - - -def _get_chargepoint_exported(daemon, id_): - if _is_total(id_): - return {'chargepoint_total': {'exported': daemon.total_sums['chargepoint']['exported']}} - return _scalar(id_, 'exported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/exported"))) - - -def _get_chargepoint_daily_imported(daemon, id_): - return _scalar(id_, 'daily_imported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/daily_imported"))) - - -def _get_chargepoint_daily_exported(daemon, id_): - return _scalar(id_, 'daily_exported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/daily_exported"))) - - -def _get_chargepoint_monthly_exported(daemon, id_): - if _is_total(id_): - return {'chargepoint_total': {'monthly_exported': daemon.total_sums['chargepoint']['monthly_exported']}} - return _scalar(id_, 'monthly_exported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/monthly_exported"))) - - -def _get_chargepoint_yearly_exported(daemon, id_): - if _is_total(id_): - return {'chargepoint_total': {'yearly_exported': daemon.total_sums['chargepoint']['yearly_exported']}} - return _scalar(id_, 'yearly_exported', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/yearly_exported"))) - - -def _get_chargepoint_frequency(daemon, id_): - return _scalar(id_, 'frequency', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/frequency"))) - - -def _get_chargepoint_rfid(daemon, id_): - return _scalar(id_, 'rfid', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/rfid"))) +def _make_scalar_handler( + component: str, mqtt_component: str, suffix: str, field: str, + coercer: Callable[[Any, Any], Any], default: Any, total_aware: bool) -> Callable[[Any, Any], Dict[str, Any]]: + """Build a read handler for the common "look up one get/ topic, coerce + it, wrap it as {component_id: {field: value}}" shape - optionally serving + id="total" from daemon.total_sums instead, for the handful of metrics that are + continuously aggregated across all known IDs of a component type.""" + def handler(daemon: Any, id_: Any) -> Dict[str, Any]: + if total_aware and _is_total(id_): + return {f"{component}_total": {field: daemon.total_sums[mqtt_component][field]}} + raw = _raw(daemon, f"openWB/{mqtt_component}/{id_}/get/{suffix}") + return _comp_scalar(component, id_, field, coercer(raw, default)) + return handler + + +# Scalar read-parameter table: one row per `get__` handler that +# reduces to "read openWB///get/, coerce, wrap". +# total_aware marks the metrics that are also continuously aggregated across all +# known IDs and so additionally serve id="total" from daemon.total_sums. +# +# (component, mqtt_component, topic_suffix, field, coercer, default, total_aware) +SCALAR_READ_TABLE: List[Tuple[str, str, str, str, Callable[[Any, Any], Any], Any, bool]] = [ + # chargepoint + ('chargepoint', 'chargepoint', 'power', 'power', _as_float, 0.0, True), + ('chargepoint', 'chargepoint', 'powers', 'powers', _phases, None, False), + ('chargepoint', 'chargepoint', 'imported', 'imported', _as_float, 0.0, True), + ('chargepoint', 'chargepoint', 'exported', 'exported', _as_float, 0.0, True), + ('chargepoint', 'chargepoint', 'daily_imported', 'daily_imported', _as_float, 0.0, False), + ('chargepoint', 'chargepoint', 'daily_exported', 'daily_exported', _as_float, 0.0, False), + ('chargepoint', 'chargepoint', 'monthly_exported', 'monthly_exported', _as_float, 0.0, True), + ('chargepoint', 'chargepoint', 'yearly_exported', 'yearly_exported', _as_float, 0.0, True), + ('chargepoint', 'chargepoint', 'frequency', 'frequency', _as_float, 0.0, False), + ('chargepoint', 'chargepoint', 'rfid', 'rfid', _as_str, '', False), + ('chargepoint', 'chargepoint', 'rfid_timestamp', 'rfid_timestamp', _as_int, 0, False), + ('chargepoint', 'chargepoint', 'evse_current', 'evse_current', _as_float, 0.0, False), + ('chargepoint', 'chargepoint', 'soc', 'soc', _as_int, 0, False), + ('chargepoint', 'chargepoint', 'state_str', 'state_str', _as_str, '', False), + ('chargepoint', 'chargepoint', 'fault_str', 'fault_str', _as_str, '', False), + ('chargepoint', 'chargepoint', 'fault_state', 'fault_state', _as_int, 0, False), + ('chargepoint', 'chargepoint', 'phases_in_use', 'phases_in_use', _as_int, 0, False), + ('chargepoint', 'chargepoint', 'plug_state', 'plug_state', _as_bool, None, False), + ('chargepoint', 'chargepoint', 'charge_state', 'charge_state', _as_bool, None, False), + ('chargepoint', 'chargepoint', 'chargemode', 'chargemode', _as_str, '', False), + # counter + ('counter', 'counter', 'power', 'power', _as_float, 0.0, False), + ('counter', 'counter', 'powers', 'powers', _phases, None, False), + ('counter', 'counter', 'power_factors', 'power_factors', _phases, None, False), + ('counter', 'counter', 'imported', 'imported', _as_float, 0.0, False), + ('counter', 'counter', 'exported', 'exported', _as_float, 0.0, False), + ('counter', 'counter', 'daily_imported', 'daily_imported', _as_float, 0.0, False), + ('counter', 'counter', 'daily_exported', 'daily_exported', _as_float, 0.0, False), + ('counter', 'counter', 'frequency', 'frequency', _as_float, 50.0, False), + ('counter', 'counter', 'fault_str', 'fault_str', _as_str, 'Kein Fehler', False), + ('counter', 'counter', 'fault_state', 'fault_state', _as_int, 0, False), + # battery (mqtt topic path is "bat", output/field prefix is "battery") + ('battery', 'bat', 'power', 'power', _as_float, 0.0, False), + ('battery', 'bat', 'soc', 'soc', _as_int, 0, False), + ('battery', 'bat', 'currents', 'currents', _phases, None, False), + ('battery', 'bat', 'imported', 'imported', _as_float, 0.0, False), + ('battery', 'bat', 'exported', 'exported', _as_float, 0.0, False), + ('battery', 'bat', 'daily_imported', 'daily_imported', _as_float, 0.0, False), + ('battery', 'bat', 'daily_exported', 'daily_exported', _as_float, 0.0, False), + ('battery', 'bat', 'fault_str', 'fault_str', _as_str, 'Kein Fehler', False), + ('battery', 'bat', 'fault_state', 'fault_state', _as_int, 0, False), + ('battery', 'bat', 'power_limit_controllable', 'power_limit_controllable', _as_bool, None, False), + # pv + ('pv', 'pv', 'power', 'power', _as_float, 0.0, True), + ('pv', 'pv', 'currents', 'currents', _phases, None, False), + ('pv', 'pv', 'imported', 'imported', _as_float, 0.0, True), + ('pv', 'pv', 'exported', 'exported', _as_float, 0.0, True), + ('pv', 'pv', 'daily_exported', 'daily_exported', _as_float, 0.0, False), + ('pv', 'pv', 'monthly_exported', 'monthly_exported', _as_float, 0.0, True), + ('pv', 'pv', 'yearly_exported', 'yearly_exported', _as_float, 0.0, True), + ('pv', 'pv', 'fault_str', 'fault_str', _as_str, 'Kein Fehler', False), + ('pv', 'pv', 'fault_state', 'fault_state', _as_int, 0, False), + # consumer + ('consumer', 'consumer', 'power', 'power', _as_float, 0.0, False), + ('consumer', 'consumer', 'currents', 'currents', _phases, None, False), + ('consumer', 'consumer', 'voltages', 'voltages', _phases, None, False), + ('consumer', 'consumer', 'powers', 'powers', _phases, None, False), + ('consumer', 'consumer', 'imported', 'imported', _as_float, 0.0, False), + ('consumer', 'consumer', 'exported', 'exported', _as_float, 0.0, False), + ('consumer', 'consumer', 'daily_imported', 'daily_imported', _as_float, 0.0, False), + ('consumer', 'consumer', 'phases_in_use', 'phases_in_use', _as_int, 0, False), + ('consumer', 'consumer', 'charge_state', 'charge_state', _as_bool, None, False), + ('consumer', 'consumer', 'state_str', 'state_str', _as_str, None, False), + ('consumer', 'consumer', 'fault_str', 'fault_str', _as_str, 'Kein Fehler', False), + ('consumer', 'consumer', 'fault_state', 'fault_state', _as_int, 0, False), +] -def _get_chargepoint_rfid_timestamp(daemon, id_): - return _scalar(id_, 'rfid_timestamp', _as_int(_raw(daemon, f"openWB/chargepoint/{id_}/get/rfid_timestamp"))) +def _make_phase_handlers(component: str, mqtt_component: str, suffix: str) -> Dict[str, Callable[..., Any]]: + """Build the 4 read handlers (plural array + 3 per-phase scalars) that share + one underlying openWB///get/ topic, e.g. suffix + "voltages" yields get__voltages plus _voltage_p1/_p2/_p3.""" + singular = suffix[:-1] # 'voltages' -> 'voltage', 'power_factors' -> 'power_factor' + def topic(id_: Any) -> str: + return f"openWB/{mqtt_component}/{id_}/get/{suffix}" -def _get_chargepoint_evse_current(daemon, id_): - return _scalar(id_, 'evse_current', _as_float(_raw(daemon, f"openWB/chargepoint/{id_}/get/evse_current"))) + def plural_handler(daemon: Any, id_: Any) -> Dict[str, Any]: + return _comp_scalar(component, id_, suffix, _phases(_raw(daemon, topic(id_)))) + def make_phase_handler(phase: int) -> Callable[[Any, Any], Dict[str, Any]]: + def handler(daemon: Any, id_: Any) -> Dict[str, Any]: + values = _phases(_raw(daemon, topic(id_))) + return _comp_scalar(component, id_, f"{singular}_p{phase}", values[phase - 1]) + return handler -def _get_chargepoint_power_factors(daemon, id_): - return _scalar(id_, 'power_factors', _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/power_factors"))) + handlers: Dict[str, Callable[..., Any]] = {f"get_{component}_{suffix}": plural_handler} + for phase in (1, 2, 3): + handlers[f"get_{component}_{singular}_p{phase}"] = make_phase_handler(phase) + return handlers -def _get_chargepoint_power_factor(daemon, id_, phase): - factors = _phases(_raw(daemon, f"openWB/chargepoint/{id_}/get/power_factors")) - return _scalar(id_, f'power_factor_p{phase}', factors[phase - 1]) +# (component, mqtt_component, topic_suffix) for the phase-indexed getters that +# have both a plural array endpoint and 3 individual per-phase endpoints. +PHASE_READ_TABLE: List[Tuple[str, str, str]] = [ + ('chargepoint', 'chargepoint', 'voltages'), + ('chargepoint', 'chargepoint', 'currents'), + ('chargepoint', 'chargepoint', 'power_factors'), + ('counter', 'counter', 'voltages'), + ('counter', 'counter', 'currents'), +] def _get_chargepoint_config_name(daemon, id_): config = _raw(daemon, f"openWB/chargepoint/{id_}/config") or {} - return _scalar(id_, 'config_name', _as_str(config.get('name'), '')) + return _comp_scalar('chargepoint', id_, 'config_name', _as_str(config.get('name'), '')) def _get_chargepoint_connected_vehicle_name(daemon, id_): info = _raw(daemon, f"openWB/chargepoint/{id_}/get/connected_vehicle/info") or {} - return _scalar(id_, 'connected_vehicle_name', _as_str(info.get('name'), '')) + return _comp_scalar('chargepoint', id_, 'connected_vehicle_name', _as_str(info.get('name'), '')) def _get_chargepoint_charge_template_name(daemon, id_): template = daemon.charge_template_cache.get(str(id_)) or {} - return _scalar(id_, 'charge_template_name', _as_str(template.get('name'), '')) + return _comp_scalar('chargepoint', id_, 'charge_template_name', _as_str(template.get('name'), '')) def _get_chargepoint_charge_template_min_current(daemon, id_): template = daemon.charge_template_cache.get(str(id_)) or {} min_current = template.get('chargemode', {}).get('pv_charging', {}).get('min_current', 0) - return _scalar(id_, 'charge_template_min_current', _as_float(min_current)) + return _comp_scalar('chargepoint', id_, 'charge_template_min_current', _as_float(min_current)) def _get_chargepoint_instant_charging_current(daemon, id_): template = daemon.charge_template_cache.get(str(id_)) or {} current = template.get('chargemode', {}).get('instant_charging', {}).get('current', 0) - return _scalar(id_, 'instant_charging_current', _as_float(current)) + return _comp_scalar('chargepoint', id_, 'instant_charging_current', _as_float(current)) def _get_chargepoint_pv_charging_min_current(daemon, id_): template = daemon.charge_template_cache.get(str(id_)) or {} min_current = template.get('chargemode', {}).get('pv_charging', {}).get('min_current', 0) - return _scalar(id_, 'pv_charging_min_current', _as_float(min_current)) - - -def _get_chargepoint_soc(daemon, id_): - return _scalar(id_, 'soc', _as_int(_raw(daemon, f"openWB/chargepoint/{id_}/get/soc"))) - - -def _get_chargepoint_state_str(daemon, id_): - return _scalar(id_, 'state_str', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/state_str"), '')) - - -def _get_chargepoint_fault_str(daemon, id_): - return _scalar(id_, 'fault_str', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/fault_str"), '')) - - -def _get_chargepoint_fault_state(daemon, id_): - return _scalar(id_, 'fault_state', _as_int(_raw(daemon, f"openWB/chargepoint/{id_}/get/fault_state"))) - - -def _get_chargepoint_phases_in_use(daemon, id_): - return _scalar(id_, 'phases_in_use', _as_int(_raw(daemon, f"openWB/chargepoint/{id_}/get/phases_in_use"))) - - -def _get_chargepoint_plug_state(daemon, id_): - return _scalar(id_, 'plug_state', _as_bool(_raw(daemon, f"openWB/chargepoint/{id_}/get/plug_state"))) - - -def _get_chargepoint_charge_state(daemon, id_): - return _scalar(id_, 'charge_state', _as_bool(_raw(daemon, f"openWB/chargepoint/{id_}/get/charge_state"))) - - -def _get_chargepoint_chargemode(daemon, id_): - return _scalar(id_, 'chargemode', _as_str(_raw(daemon, f"openWB/chargepoint/{id_}/get/chargemode"), '')) - - -def _comp_scalar(component: str, id_: Any, field: str, value: Any) -> Dict[str, Any]: - return {f"{component}_{id_}": {field: value}} + return _comp_scalar('chargepoint', id_, 'pv_charging_min_current', _as_float(min_current)) def _get_counter(daemon, id_): @@ -459,69 +465,6 @@ def _get_counter(daemon, id_): } -def _get_counter_voltage(daemon, id_, phase): - return _comp_scalar( - 'counter', id_, f'voltage_p{phase}', _phases(_raw(daemon, f"openWB/counter/{id_}/get/voltages"))[phase - 1]) - - -def _get_counter_voltages(daemon, id_): - return _comp_scalar('counter', id_, 'voltages', _phases(_raw(daemon, f"openWB/counter/{id_}/get/voltages"))) - - -def _get_counter_current(daemon, id_, phase): - return _comp_scalar( - 'counter', id_, f'current_p{phase}', _phases(_raw(daemon, f"openWB/counter/{id_}/get/currents"))[phase - 1]) - - -def _get_counter_currents(daemon, id_): - return _comp_scalar('counter', id_, 'currents', _phases(_raw(daemon, f"openWB/counter/{id_}/get/currents"))) - - -def _get_counter_power(daemon, id_): - return _comp_scalar('counter', id_, 'power', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/power"))) - - -def _get_counter_powers(daemon, id_): - return _comp_scalar('counter', id_, 'powers', _phases(_raw(daemon, f"openWB/counter/{id_}/get/powers"))) - - -def _get_counter_power_factors(daemon, id_): - return _comp_scalar( - 'counter', id_, 'power_factors', _phases(_raw(daemon, f"openWB/counter/{id_}/get/power_factors"))) - - -def _get_counter_imported(daemon, id_): - return _comp_scalar('counter', id_, 'imported', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/imported"))) - - -def _get_counter_exported(daemon, id_): - return _comp_scalar('counter', id_, 'exported', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/exported"))) - - -def _get_counter_daily_imported(daemon, id_): - return _comp_scalar( - 'counter', id_, 'daily_imported', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/daily_imported"))) - - -def _get_counter_daily_exported(daemon, id_): - return _comp_scalar( - 'counter', id_, 'daily_exported', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/daily_exported"))) - - -def _get_counter_frequency(daemon, id_): - return _comp_scalar( - 'counter', id_, 'frequency', _as_float(_raw(daemon, f"openWB/counter/{id_}/get/frequency"), 50.0)) - - -def _get_counter_fault_str(daemon, id_): - return _comp_scalar( - 'counter', id_, 'fault_str', _as_str(_raw(daemon, f"openWB/counter/{id_}/get/fault_str"), 'Kein Fehler')) - - -def _get_counter_fault_state(daemon, id_): - return _comp_scalar('counter', id_, 'fault_state', _as_int(_raw(daemon, f"openWB/counter/{id_}/get/fault_state"))) - - def _get_battery(daemon, id_): prefix = f"openWB/bat/{id_}/get/" return { @@ -540,51 +483,6 @@ def _get_battery(daemon, id_): } -def _get_battery_power(daemon, id_): - return _comp_scalar('battery', id_, 'power', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/power"))) - - -def _get_battery_soc(daemon, id_): - return _comp_scalar('battery', id_, 'soc', _as_int(_raw(daemon, f"openWB/bat/{id_}/get/soc"))) - - -def _get_battery_currents(daemon, id_): - return _comp_scalar('battery', id_, 'currents', _phases(_raw(daemon, f"openWB/bat/{id_}/get/currents"))) - - -def _get_battery_imported(daemon, id_): - return _comp_scalar('battery', id_, 'imported', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/imported"))) - - -def _get_battery_exported(daemon, id_): - return _comp_scalar('battery', id_, 'exported', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/exported"))) - - -def _get_battery_daily_imported(daemon, id_): - return _comp_scalar( - 'battery', id_, 'daily_imported', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/daily_imported"))) - - -def _get_battery_daily_exported(daemon, id_): - return _comp_scalar( - 'battery', id_, 'daily_exported', _as_float(_raw(daemon, f"openWB/bat/{id_}/get/daily_exported"))) - - -def _get_battery_fault_str(daemon, id_): - return _comp_scalar( - 'battery', id_, 'fault_str', _as_str(_raw(daemon, f"openWB/bat/{id_}/get/fault_str"), 'Kein Fehler')) - - -def _get_battery_fault_state(daemon, id_): - return _comp_scalar('battery', id_, 'fault_state', _as_int(_raw(daemon, f"openWB/bat/{id_}/get/fault_state"))) - - -def _get_battery_power_limit_controllable(daemon, id_): - return _comp_scalar( - 'battery', id_, 'power_limit_controllable', - _as_bool(_raw(daemon, f"openWB/bat/{id_}/get/power_limit_controllable"))) - - def _get_pv(daemon, id_): prefix = f"openWB/pv/{id_}/get/" return { @@ -601,55 +499,6 @@ def _get_pv(daemon, id_): } -def _get_pv_power(daemon, id_): - if _is_total(id_): - return {'pv_total': {'power': daemon.total_sums['pv']['power']}} - return _comp_scalar('pv', id_, 'power', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/power"))) - - -def _get_pv_currents(daemon, id_): - return _comp_scalar('pv', id_, 'currents', _phases(_raw(daemon, f"openWB/pv/{id_}/get/currents"))) - - -def _get_pv_imported(daemon, id_): - if _is_total(id_): - return {'pv_total': {'imported': daemon.total_sums['pv']['imported']}} - return _comp_scalar('pv', id_, 'imported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/imported"))) - - -def _get_pv_exported(daemon, id_): - if _is_total(id_): - return {'pv_total': {'exported': daemon.total_sums['pv']['exported']}} - return _comp_scalar('pv', id_, 'exported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/exported"))) - - -def _get_pv_daily_exported(daemon, id_): - return _comp_scalar('pv', id_, 'daily_exported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/daily_exported"))) - - -def _get_pv_monthly_exported(daemon, id_): - if _is_total(id_): - return {'pv_total': {'monthly_exported': daemon.total_sums['pv']['monthly_exported']}} - return _comp_scalar( - 'pv', id_, 'monthly_exported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/monthly_exported"))) - - -def _get_pv_yearly_exported(daemon, id_): - if _is_total(id_): - return {'pv_total': {'yearly_exported': daemon.total_sums['pv']['yearly_exported']}} - return _comp_scalar( - 'pv', id_, 'yearly_exported', _as_float(_raw(daemon, f"openWB/pv/{id_}/get/yearly_exported"))) - - -def _get_pv_fault_str(daemon, id_): - return _comp_scalar( - 'pv', id_, 'fault_str', _as_str(_raw(daemon, f"openWB/pv/{id_}/get/fault_str"), 'Kein Fehler')) - - -def _get_pv_fault_state(daemon, id_): - return _comp_scalar('pv', id_, 'fault_state', _as_int(_raw(daemon, f"openWB/pv/{id_}/get/fault_state"))) - - def _get_consumer_all(daemon, id_): prefix = f"openWB/consumer/{id_}/get/" usage = _raw(daemon, f"openWB/consumer/{id_}/usage") @@ -673,60 +522,6 @@ def _get_consumer_all(daemon, id_): } -def _get_consumer_power(daemon, id_): - return _comp_scalar('consumer', id_, 'power', _as_float(_raw(daemon, f"openWB/consumer/{id_}/get/power"))) - - -def _get_consumer_currents(daemon, id_): - return _comp_scalar('consumer', id_, 'currents', _phases(_raw(daemon, f"openWB/consumer/{id_}/get/currents"))) - - -def _get_consumer_voltages(daemon, id_): - return _comp_scalar('consumer', id_, 'voltages', _phases(_raw(daemon, f"openWB/consumer/{id_}/get/voltages"))) - - -def _get_consumer_powers(daemon, id_): - return _comp_scalar('consumer', id_, 'powers', _phases(_raw(daemon, f"openWB/consumer/{id_}/get/powers"))) - - -def _get_consumer_imported(daemon, id_): - return _comp_scalar('consumer', id_, 'imported', _as_float(_raw(daemon, f"openWB/consumer/{id_}/get/imported"))) - - -def _get_consumer_exported(daemon, id_): - return _comp_scalar('consumer', id_, 'exported', _as_float(_raw(daemon, f"openWB/consumer/{id_}/get/exported"))) - - -def _get_consumer_daily_imported(daemon, id_): - return _comp_scalar( - 'consumer', id_, 'daily_imported', _as_float(_raw(daemon, f"openWB/consumer/{id_}/get/daily_imported"))) - - -def _get_consumer_phases_in_use(daemon, id_): - return _comp_scalar( - 'consumer', id_, 'phases_in_use', _as_int(_raw(daemon, f"openWB/consumer/{id_}/get/phases_in_use"))) - - -def _get_consumer_charge_state(daemon, id_): - return _comp_scalar( - 'consumer', id_, 'charge_state', _as_bool(_raw(daemon, f"openWB/consumer/{id_}/get/charge_state"))) - - -def _get_consumer_state_str(daemon, id_): - return _comp_scalar( - 'consumer', id_, 'state_str', _as_str(_raw(daemon, f"openWB/consumer/{id_}/get/state_str"), None)) - - -def _get_consumer_fault_str(daemon, id_): - return _comp_scalar( - 'consumer', id_, 'fault_str', _as_str(_raw(daemon, f"openWB/consumer/{id_}/get/fault_str"), 'Kein Fehler')) - - -def _get_consumer_fault_state(daemon, id_): - return _comp_scalar( - 'consumer', id_, 'fault_state', _as_int(_raw(daemon, f"openWB/consumer/{id_}/get/fault_state"))) - - def _get_consumer_usage_type(daemon, id_): usage = _raw(daemon, f"openWB/consumer/{id_}/usage") usage_type = usage.get('type') if isinstance(usage, dict) else None @@ -766,111 +561,36 @@ def _get_io_output(daemon, id_, options): return {f"io_{id_}": {'output': output_name, 'output_type': output_type, 'value': result}} -def _get_last_live_values_json(daemon, id_=None): - return {'get_lastlivevaluesjson': daemon.last_live_values_json} +def _build_scalar_read_dispatch() -> Dict[str, Callable[..., Dict[str, Any]]]: + dispatch: Dict[str, Callable[..., Dict[str, Any]]] = {} + for component, mqtt_component, suffix, field, coercer, default, total_aware in SCALAR_READ_TABLE: + dispatch[f"get_{component}_{field}"] = _make_scalar_handler( + component, mqtt_component, suffix, field, coercer, default, total_aware) + for component, mqtt_component, suffix in PHASE_READ_TABLE: + dispatch.update(_make_phase_handlers(component, mqtt_component, suffix)) + return dispatch READ_DISPATCH: Dict[str, Callable[..., Dict[str, Any]]] = { 'get_chargepoint_all': _get_chargepoint_all, - 'get_chargepoint_voltage_p1': lambda d, i: _get_chargepoint_voltage(d, i, 1), - 'get_chargepoint_voltage_p2': lambda d, i: _get_chargepoint_voltage(d, i, 2), - 'get_chargepoint_voltage_p3': lambda d, i: _get_chargepoint_voltage(d, i, 3), - 'get_chargepoint_voltages': _get_chargepoint_voltages, - 'get_chargepoint_current_p1': lambda d, i: _get_chargepoint_current(d, i, 1), - 'get_chargepoint_current_p2': lambda d, i: _get_chargepoint_current(d, i, 2), - 'get_chargepoint_current_p3': lambda d, i: _get_chargepoint_current(d, i, 3), - 'get_chargepoint_currents': _get_chargepoint_currents, - 'get_chargepoint_power': _get_chargepoint_power, - 'get_chargepoint_powers': _get_chargepoint_powers, - 'get_chargepoint_imported': _get_chargepoint_imported, - 'get_chargepoint_exported': _get_chargepoint_exported, - 'get_chargepoint_daily_imported': _get_chargepoint_daily_imported, - 'get_chargepoint_daily_exported': _get_chargepoint_daily_exported, - 'get_chargepoint_monthly_exported': _get_chargepoint_monthly_exported, - 'get_chargepoint_yearly_exported': _get_chargepoint_yearly_exported, - 'get_chargepoint_frequency': _get_chargepoint_frequency, - 'get_chargepoint_rfid': _get_chargepoint_rfid, - 'get_chargepoint_rfid_timestamp': _get_chargepoint_rfid_timestamp, - 'get_chargepoint_evse_current': _get_chargepoint_evse_current, - 'get_chargepoint_power_factors': _get_chargepoint_power_factors, - 'get_chargepoint_power_factor_p1': lambda d, i: _get_chargepoint_power_factor(d, i, 1), - 'get_chargepoint_power_factor_p2': lambda d, i: _get_chargepoint_power_factor(d, i, 2), - 'get_chargepoint_power_factor_p3': lambda d, i: _get_chargepoint_power_factor(d, i, 3), + **_build_scalar_read_dispatch(), 'get_chargepoint_config_name': _get_chargepoint_config_name, 'get_chargepoint_connected_vehicle_name': _get_chargepoint_connected_vehicle_name, 'get_chargepoint_charge_template_name': _get_chargepoint_charge_template_name, 'get_chargepoint_charge_template_min_current': _get_chargepoint_charge_template_min_current, 'get_chargepoint_instant_charging_current': _get_chargepoint_instant_charging_current, 'get_chargepoint_pv_charging_min_current': _get_chargepoint_pv_charging_min_current, - 'get_chargepoint_soc': _get_chargepoint_soc, - 'get_chargepoint_state_str': _get_chargepoint_state_str, - 'get_chargepoint_fault_str': _get_chargepoint_fault_str, - 'get_chargepoint_fault_state': _get_chargepoint_fault_state, - 'get_chargepoint_phases_in_use': _get_chargepoint_phases_in_use, - 'get_chargepoint_plug_state': _get_chargepoint_plug_state, - 'get_chargepoint_charge_state': _get_chargepoint_charge_state, - 'get_chargepoint_chargemode': _get_chargepoint_chargemode, 'get_counter': _get_counter, - 'get_counter_voltage_p1': lambda d, i: _get_counter_voltage(d, i, 1), - 'get_counter_voltage_p2': lambda d, i: _get_counter_voltage(d, i, 2), - 'get_counter_voltage_p3': lambda d, i: _get_counter_voltage(d, i, 3), - 'get_counter_voltages': _get_counter_voltages, - 'get_counter_current_p1': lambda d, i: _get_counter_current(d, i, 1), - 'get_counter_current_p2': lambda d, i: _get_counter_current(d, i, 2), - 'get_counter_current_p3': lambda d, i: _get_counter_current(d, i, 3), - 'get_counter_currents': _get_counter_currents, - 'get_counter_power': _get_counter_power, - 'get_counter_powers': _get_counter_powers, - 'get_counter_power_factors': _get_counter_power_factors, - 'get_counter_imported': _get_counter_imported, - 'get_counter_exported': _get_counter_exported, - 'get_counter_daily_imported': _get_counter_daily_imported, - 'get_counter_daily_exported': _get_counter_daily_exported, - 'get_counter_frequency': _get_counter_frequency, - 'get_counter_fault_str': _get_counter_fault_str, - 'get_counter_fault_state': _get_counter_fault_state, 'battery': _get_battery, 'get_battery': _get_battery, - 'get_battery_power': _get_battery_power, - 'get_battery_soc': _get_battery_soc, - 'get_battery_currents': _get_battery_currents, - 'get_battery_imported': _get_battery_imported, - 'get_battery_exported': _get_battery_exported, - 'get_battery_daily_imported': _get_battery_daily_imported, - 'get_battery_daily_exported': _get_battery_daily_exported, - 'get_battery_fault_str': _get_battery_fault_str, - 'get_battery_fault_state': _get_battery_fault_state, - 'get_battery_power_limit_controllable': _get_battery_power_limit_controllable, 'pv': _get_pv, 'get_pv': _get_pv, - 'get_pv_power': _get_pv_power, - 'get_pv_currents': _get_pv_currents, - 'get_pv_imported': _get_pv_imported, - 'get_pv_exported': _get_pv_exported, - 'get_pv_daily_exported': _get_pv_daily_exported, - 'get_pv_monthly_exported': _get_pv_monthly_exported, - 'get_pv_yearly_exported': _get_pv_yearly_exported, - 'get_pv_fault_str': _get_pv_fault_str, - 'get_pv_fault_state': _get_pv_fault_state, 'consumer': _get_consumer_all, 'get_consumer': _get_consumer_all, 'get_consumer_all': _get_consumer_all, - 'get_consumer_power': _get_consumer_power, - 'get_consumer_currents': _get_consumer_currents, - 'get_consumer_voltages': _get_consumer_voltages, - 'get_consumer_powers': _get_consumer_powers, - 'get_consumer_imported': _get_consumer_imported, - 'get_consumer_exported': _get_consumer_exported, - 'get_consumer_daily_imported': _get_consumer_daily_imported, - 'get_consumer_phases_in_use': _get_consumer_phases_in_use, - 'get_consumer_charge_state': _get_consumer_charge_state, - 'get_consumer_state_str': _get_consumer_state_str, - 'get_consumer_fault_str': _get_consumer_fault_str, - 'get_consumer_fault_state': _get_consumer_fault_state, 'get_consumer_usage_type': _get_consumer_usage_type, 'get_io_output_all': _get_io_output_all, 'get_io_output': _get_io_output, - 'get_lastlivevaluesjson': _get_last_live_values_json, } # Read handlers that need the request's io_output/io_output_type options in @@ -942,6 +662,10 @@ def extract_read_params(all_params: Dict[str, Any]) -> Dict[str, Any]: return {k: all_params[k] for k in READ_PARAMETER_ORDER if k in all_params} +def _io_options(all_params: Dict[str, Any]) -> Dict[str, Any]: + return {'io_output': all_params.get('io_output'), 'io_output_type': all_params.get('io_output_type')} + + def dispatch_write(ph: ParameterHandler, write_params: Dict[str, Any], all_params: Dict[str, Any]) -> Dict[str, Any]: daemon = ph.daemon first_target_id = None @@ -962,8 +686,7 @@ def dispatch_write(ph: ParameterHandler, write_params: Dict[str, Any], all_param return {'success': False, 'message': f"No {id_type} devices available for auto-ID"} target_id = resolved - options = {'io_output': all_params.get('io_output'), 'io_output_type': all_params.get('io_output_type')} - result = ph.write_parameter(param, value, target_id, options) + result = ph.write_parameter(param, value, target_id, _io_options(all_params)) if first_target_id is None: first_target_id = target_id @@ -988,21 +711,21 @@ def dispatch_read( debug: bool) -> Tuple[Dict[str, Any], List[str]]: result: Dict[str, Any] = {} debug_info: List[str] = [] + options = _io_options(all_params) for param, id_ in read_params.items(): resolved_id = id_ - if param != 'get_lastlivevaluesjson' and id_ in ('auto', ''): + if id_ in ('auto', ''): type_ = get_type_from_parameter(param) resolved_id = ph.daemon.lowest_ids.get(type_) if resolved_id is None: if debug: debug_info.append(f"Auto-ID failed for {param}: no {type_} devices known") - if 'chargepoint' in param: + if type_ == 'chargepoint': resolved_id = 0 else: continue - options = {'io_output': all_params.get('io_output'), 'io_output_type': all_params.get('io_output_type')} data = ph.read_parameter(param, resolved_id, options) if data is not None: result.update(data) From f2b2670dfa383a7991732b9854735104be5564d1 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Fri, 23 Oct 2026 22:25:54 +0200 Subject: [PATCH 8/8] simpleAPI: rename config file to simpleapi_config.json --- data/config/apache/000-default.conf | 2 +- data/config/apache/apache-openwb-ssl.conf | 2 +- data/config/apache/localhost.conf | 2 +- .../{simpleAPI_mqtt_config.json => simpleapi_config.json} | 0 simpleAPI/simpleapi.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename data/config/{simpleAPI_mqtt_config.json => simpleapi_config.json} (100%) diff --git a/data/config/apache/000-default.conf b/data/config/apache/000-default.conf index de6aff8aad..f84a368a78 100644 --- a/data/config/apache/000-default.conf +++ b/data/config/apache/000-default.conf @@ -42,7 +42,7 @@ ProxyPassMatch "^/(ws|mqtt)(/|$)" "ws://127.0.0.1:9003/" # Proxy simpleAPI requests to the Python HTTP server in simpleAPI_mqtt.py - # (port from data/config/simpleAPI_mqtt_config.json's "http" section). + # (port from data/config/simpleapi_config.json's "http" section). ProxyPassMatch "^/openWB/simpleAPI(/|$)" "http://127.0.0.1:8099/" ProxyPassReverse "/openWB/simpleAPI/" "http://127.0.0.1:8099/" diff --git a/data/config/apache/apache-openwb-ssl.conf b/data/config/apache/apache-openwb-ssl.conf index 6ae98975a5..c3b3930b4d 100644 --- a/data/config/apache/apache-openwb-ssl.conf +++ b/data/config/apache/apache-openwb-ssl.conf @@ -144,7 +144,7 @@ ProxyPassMatch "^/(ws|mqtt)(/|$)" "ws://127.0.0.1:9003/" # Proxy simpleAPI requests to the Python HTTP server in simpleAPI_mqtt.py - # (port from data/config/simpleAPI_mqtt_config.json's "http" section). + # (port from data/config/simpleapi_config.json's "http" section). ProxyPassMatch "^/openWB/simpleAPI(/|$)" "http://127.0.0.1:8099/" ProxyPassReverse "/openWB/simpleAPI/" "http://127.0.0.1:8099/" diff --git a/data/config/apache/localhost.conf b/data/config/apache/localhost.conf index 85e2eeb384..4d06ab119d 100644 --- a/data/config/apache/localhost.conf +++ b/data/config/apache/localhost.conf @@ -44,7 +44,7 @@ Listen 127.0.0.1:81 ProxyPassMatch "^/(ws|mqtt)(/|$)" "ws://127.0.0.1:9003/" # Proxy simpleAPI requests to the Python HTTP server in simpleAPI_mqtt.py - # (port from data/config/simpleAPI_mqtt_config.json's "http" section). + # (port from data/config/simpleapi_config.json's "http" section). ProxyPassMatch "^/openWB/simpleAPI(/|$)" "http://127.0.0.1:8099/" ProxyPassReverse "/openWB/simpleAPI/" "http://127.0.0.1:8099/" diff --git a/data/config/simpleAPI_mqtt_config.json b/data/config/simpleapi_config.json similarity index 100% rename from data/config/simpleAPI_mqtt_config.json rename to data/config/simpleapi_config.json diff --git a/simpleAPI/simpleapi.py b/simpleAPI/simpleapi.py index a4feb1bf92..3c99203076 100755 --- a/simpleAPI/simpleapi.py +++ b/simpleAPI/simpleapi.py @@ -31,7 +31,7 @@ file_handler.setFormatter(logging.Formatter(FORMAT_STR_SHORT)) log.addHandler(file_handler) -CONFIG_FILE_PATH = "/var/www/html/openWB/data/config/simpleAPI_mqtt_config.json" +CONFIG_FILE_PATH = "/var/www/html/openWB/data/config/simpleapi_config.json" class SimpleMQTTDaemon: