diff --git a/simpleAPI/simpleAPI_mqtt.py b/simpleAPI/simpleAPI_mqtt.py index 2882fcfd9e..7ab8449f32 100755 --- a/simpleAPI/simpleAPI_mqtt.py +++ b/simpleAPI/simpleAPI_mqtt.py @@ -13,6 +13,7 @@ import time import sys import ssl +import math from typing import Dict, Any, Optional from pathlib import Path import paho.mqtt.client as mqtt @@ -46,6 +47,20 @@ def __init__(self, config_file: str): # Cache for storing current charge_template configurations self.charge_template_cache: Dict[str, Dict[str, Any]] = {} + # Track per-ID component values for efficient total topic aggregation + self.total_metrics = { + 'pv': {'exported', 'monthly_exported', 'yearly_exported', 'imported', 'power'}, + 'chargepoint': {'exported', 'monthly_exported', 'yearly_exported', 'imported', 'power'} + } + self.total_value_cache: Dict[str, Dict[str, Dict[int, float]]] = { + component: {metric: {} for metric in metrics} + for component, metrics in self.total_metrics.items() + } + self.total_sums: Dict[str, Dict[str, float]] = { + component: {metric: 0.0 for metric in metrics} + for component, metrics in self.total_metrics.items() + } + # MQTT client setup self.client = mqtt.Client() self.client.on_connect = self._on_connect @@ -161,19 +176,26 @@ def _on_message(self, client, userdata, msg): if '/set/charge_template' in topic: self._cache_charge_template(topic, payload) + # Parse payload once so we can reuse it for both total aggregation and normal transformation + parsed_payload = self._parse_payload(payload) + + # Update aggregated total topics from component ID metrics + self._update_total_topics(topic, parsed_payload) + # Transform and publish the message - self._transform_and_publish(topic, payload) + self._transform_and_publish(topic, payload, 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): + def _transform_and_publish(self, original_topic: str, payload: str, parsed_payload: Any = None): """Transform original topic to simpleAPI format and publish if value changed.""" try: log.debug(f"DEBUG: Processing original topic: {original_topic}") # Parse the payload - parsed_payload = self._parse_payload(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) @@ -190,6 +212,51 @@ def _transform_and_publish(self, original_topic: str, payload: str): except Exception as e: log.error(f"Error transforming topic {original_topic}: {e}") + def _update_total_topics(self, original_topic: str, parsed_value: Any): + """Maintain and publish aggregated total topics from all numeric component IDs.""" + # Match only per-ID get topics needed for totals + # Examples: + # openWB/pv/7/get/exported + # openWB/chargepoint/13/get/power + pattern = r'^openWB/(pv|chargepoint)/(\d+)/get/(exported|monthly_exported|yearly_exported|imported|power)$' + match = re.match(pattern, original_topic) + if not match: + return + + component_type = match.group(1) + component_id = int(match.group(2)) + metric = match.group(3) + + # Only numeric, finite values are aggregated + if isinstance(parsed_value, bool) or not isinstance(parsed_value, (int, float)): + return + + numeric_value = float(parsed_value) + if not math.isfinite(numeric_value): + return + + metric_cache = self.total_value_cache[component_type][metric] + previous_value = metric_cache.get(component_id, 0.0) + metric_cache[component_id] = numeric_value + + # O(1) sum update for performance with many IDs and frequent updates + self.total_sums[component_type][metric] += numeric_value - previous_value + total_value = self.total_sums[component_type][metric] + + total_topic = self._build_total_topic(component_type, metric) + if total_topic is not None: + self._publish_if_changed(total_topic, total_value) + + def _build_total_topic(self, component_type: str, metric: str) -> Optional[str]: + """Build destination topic for aggregated totals with component-consistent path style.""" + if component_type == 'pv': + # PV topics keep /get/ in simpleAPI + return f"openWB/simpleAPI/pv/total/get/{metric}" + if component_type == 'chargepoint': + # chargepoint topics are published without /get/ in simpleAPI + return f"openWB/simpleAPI/chargepoint/total/{metric}" + return None + def _parse_payload(self, payload: str) -> Any: """Parse payload as JSON, tuple, or raw value.""" payload = payload.strip() diff --git a/simpleAPI/simpleapi.php b/simpleAPI/simpleapi.php index 8deb63b2b5..6f7c446222 100644 --- a/simpleAPI/simpleapi.php +++ b/simpleAPI/simpleapi.php @@ -186,7 +186,8 @@ private function getWriteParameters($params) 'instant_charging_amount', 'instant_charging_soc', 'vehicle', - 'manual_soc' + 'manual_soc', + 'set_io_output' ]; foreach ($writeableKeys as $key) { @@ -225,6 +226,8 @@ private function getReadParameters($params) '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', @@ -291,12 +294,16 @@ private function getReadParameters($params) // 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', + // IO - Ausgaenge + 'get_io_output_all', + 'get_io_output', // System - Werte 'get_lastlivevaluesjson' ]; @@ -315,28 +322,47 @@ private function getReadParameters($params) */ private function handleWriteRequest($writeParams, $allParams) { + $firstTargetId = null; + foreach ($writeParams as $param => $value) { - $chargepointId = $allParams['chargepoint_nr'] ?? null; + $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'; + } - // Auto-ID Feature: Niedrigste ID finden wenn keine angegeben - if ($chargepointId === null && $this->isChargepointParameter($param)) { + if ($idType !== null && ($targetId === null || $targetId === '' || $targetId === 'auto')) { try { - $chargepointId = $this->mqttClient->getLowestId('chargepoint'); - if ($chargepointId === null) { + $targetId = $this->mqttClient->getLowestId($idType); + if ($targetId === null) { return [ 'success' => false, - 'message' => 'No chargepoints available for auto-ID' + 'message' => "No {$idType} devices available for auto-ID" ]; } } catch (Exception $e) { return [ 'success' => false, - 'message' => 'Auto-ID failed: ' . $e->getMessage() + 'message' => "Auto-ID failed for {$idType}: " . $e->getMessage() ]; } } - $result = $this->parameterHandler->writeParameter($param, $value, $chargepointId); + $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; @@ -349,9 +375,10 @@ private function handleWriteRequest($writeParams, $allParams) return [ 'success' => true, - 'message' => $this->getSuccessMessage($firstParam, $firstValue, $chargepointId ?? null), + 'message' => $this->getSuccessMessage($firstParam, $firstValue, $firstTargetId ?? null), 'data' => [ - 'chargepoint_nr' => $chargepointId, + 'chargepoint_nr' => $this->isChargepointParameter($firstParam) ? $firstTargetId : null, + 'io_nr' => $this->isIoParameter($firstParam) ? $firstTargetId : null, $firstParam => $firstValue ] ]; @@ -381,7 +408,12 @@ private function handleReadRequest($readParams, $allParams, $debugInfo = []) } } - $data = $this->parameterHandler->readParameter($param, $id); + $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); } @@ -417,6 +449,8 @@ 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) { @@ -449,7 +483,8 @@ private function isComplexParameter($param) 'get_counter_powers', 'get_counter_power_factors', 'get_battery_currents', - 'get_pv_currents' + 'get_pv_currents', + 'get_io_output_all' ]; return in_array($param, $complexParameters); @@ -478,6 +513,14 @@ private function isChargepointParameter($param) 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 */ @@ -498,6 +541,8 @@ private function getSuccessMessage($param, $value, $chargepointId) 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}."; } diff --git a/simpleAPI/src/MqttClient.php b/simpleAPI/src/MqttClient.php index c45f81cc1b..03fea79480 100644 --- a/simpleAPI/src/MqttClient.php +++ b/simpleAPI/src/MqttClient.php @@ -118,6 +118,29 @@ public function getMultipleValues($topics) 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 */ @@ -212,7 +235,21 @@ private function buildMosquittoCommandWithCredentials($type, $topics, $message = public function findAvailableIds($type) { // MQTT Wildcard verwenden um alle Topics zu finden - $pattern = "openWB/{$type}/+/get/imported"; + $scanConfig = [ + '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+(.+)/'], + 'io' => ['pattern' => 'openWB/io/states/+/get/digital_output', 'regex' => '/openWB\/io\/states\/(\d+)\/get\/digital_output\s+(.+)/'] + ]; + + $config = $scanConfig[$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'; @@ -223,12 +260,12 @@ public function findAvailableIds($type) if ($output) { $lines = explode("\n", trim($output)); foreach ($lines as $line) { - if (preg_match("/openWB\/{$type}\/(\d+)\/get\/imported\s+(.+)/", $line, $matches)) { + if (preg_match($matchRegex, $line, $matches)) { $id = intval($matches[1]); $value = trim($matches[2]); - // Nur IDs mit gültigen Werten (nicht null oder leer) - if ($value !== '' && $value !== 'null' && is_numeric($value)) { + // Nur IDs mit gültigen Werten (bei IO auch JSON erlaubt) + if ($value !== '' && $value !== 'null' && ($type === 'io' || is_numeric($value))) { $ids[] = $id; } } diff --git a/simpleAPI/src/ParameterHandler.php b/simpleAPI/src/ParameterHandler.php index f07af4f9bd..832bc8b053 100644 --- a/simpleAPI/src/ParameterHandler.php +++ b/simpleAPI/src/ParameterHandler.php @@ -19,7 +19,7 @@ public function __construct($mqttClient) /** * Parameter lesen */ - public function readParameter($param, $id) + public function readParameter($param, $id, $options = []) { switch ($param) { case 'get_chargepoint_all': @@ -73,6 +73,10 @@ public function readParameter($param, $id) 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': @@ -187,6 +191,8 @@ public function readParameter($param, $id) 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': @@ -200,6 +206,11 @@ public function readParameter($param, $id) case 'get_pv_fault_state': return $this->getPvFaultState($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(); @@ -211,34 +222,36 @@ public function readParameter($param, $id) /** * Parameter schreiben */ - public function writeParameter($param, $value, $chargepointId = null) + public function writeParameter($param, $value, $targetId = null, $options = []) { try { switch ($param) { case 'set_chargemode': - return $this->setChargemode($chargepointId, $value); + return $this->setChargemode($targetId, $value); case 'chargecurrent': - return $this->setChargecurrent($chargepointId, $value); + return $this->setChargecurrent($targetId, $value); case 'minimal_permanent_current': - return $this->setMinimalPermanentCurrent($chargepointId, $value); + return $this->setMinimalPermanentCurrent($targetId, $value); case 'minimal_pv_soc': - return $this->setMinimalPvSoc($chargepointId, $value); + return $this->setMinimalPvSoc($targetId, $value); case 'max_price_eco': - return $this->setMaxPriceEco($chargepointId, $value); + return $this->setMaxPriceEco($targetId, $value); case 'chargepoint_lock': - return $this->setChargepointLock($chargepointId, $value); + return $this->setChargepointLock($targetId, $value); case 'bat_mode': return $this->setBatMode($value); case 'instant_charging_limit': - return $this->setInstantChargingLimit($chargepointId, $value); + return $this->setInstantChargingLimit($targetId, $value); case 'instant_charging_amount': - return $this->setInstantChargingAmount($chargepointId, $value); + return $this->setInstantChargingAmount($targetId, $value); case 'instant_charging_soc': - return $this->setInstantChargingSoc($chargepointId, $value); + return $this->setInstantChargingSoc($targetId, $value); case 'vehicle': - return $this->setVehicle($chargepointId, $value); + return $this->setVehicle($targetId, $value); case 'manual_soc': - return $this->setManualSoc($chargepointId, $value); + 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']; } @@ -564,6 +577,11 @@ private function getChargepointCurrents($id) */ 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); @@ -1208,6 +1226,11 @@ private function getChargepointPvChargingMinCurrent($id) */ 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); @@ -1222,6 +1245,11 @@ private function getChargepointImported($id) */ 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); @@ -1231,6 +1259,44 @@ private function getChargepointExported($id) } } + /** + * 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 */ @@ -2027,6 +2093,11 @@ private function getBatteryPowerLimitControllable($id) */ 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); @@ -2064,11 +2135,35 @@ private function getPvCurrents($id) } } + /** + * 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); @@ -2097,6 +2192,11 @@ private function getPvDailyExported($id) */ 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); @@ -2111,6 +2211,11 @@ private function getPvMonthlyExported($id) */ 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); @@ -2160,4 +2265,221 @@ private function getNumericValue($topic) 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; + } + } }