From b3e01665ea7b019001e86dc5e80becd857df2b31 Mon Sep 17 00:00:00 2001 From: kevinwieland Date: Tue, 28 Oct 2025 07:49:52 +0100 Subject: [PATCH 01/10] bat_mode korrigiert --- simpleAPI/simpleapi.php | 415 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 simpleAPI/simpleapi.php diff --git a/simpleAPI/simpleapi.php b/simpleAPI/simpleapi.php new file mode 100644 index 0000000000..5998d60679 --- /dev/null +++ b/simpleAPI/simpleapi.php @@ -0,0 +1,415 @@ +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 + $this->authenticator = new Authenticator($this->config); + } + + /** + * 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 + 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; + } + + // 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); + + // 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); + } 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' + ]; + + 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_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_exported', 'get_pv_daily_exported', + 'get_pv_monthly_exported', 'get_pv_yearly_exported', 'get_pv_fault_str', 'get_pv_fault_state' + ]; + + foreach ($readableKeys as $key) { + if (isset($params[$key])) { + $readParams[$key] = $params[$key]; + } + } + + return $readParams; + } + + /** + * Schreibanfrage verarbeiten + */ + private function handleWriteRequest($writeParams, $allParams) + { + foreach ($writeParams as $param => $value) { + $chargepointId = $allParams['chargepoint_nr'] ?? null; + + // Auto-ID Feature: Niedrigste ID finden wenn keine angegeben + if ($chargepointId === null && $this->isChargepointParameter($param)) { + try { + $chargepointId = $this->mqttClient->getLowestId('chargepoint'); + if ($chargepointId === null) { + return [ + 'success' => false, + 'message' => 'No chargepoints available for auto-ID' + ]; + } + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => 'Auto-ID failed: ' . $e->getMessage() + ]; + } + } + + $result = $this->parameterHandler->writeParameter($param, $value, $chargepointId); + + 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, $chargepointId ?? null), + 'data' => [ + 'chargepoint_nr' => $chargepointId, + $firstParam => $firstValue + ] + ]; + } + + /** + * Leseanfrage verarbeiten + */ + private function handleReadRequest($readParams, $allParams) + { + $result = []; + + 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 + } + } + + $data = $this->parameterHandler->readParameter($param, $id); + 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, 'battery') !== false) { + return 'bat'; // MQTT Topic verwendet 'bat' nicht 'battery' + } elseif (strpos($param, 'pv') !== false) { + return 'pv'; + } elseif (strpos($param, 'counter') !== false) { + return 'counter'; + } + + 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_counter_voltages', + 'get_counter_currents', + 'get_counter_powers', + 'get_counter_power_factors', + 'get_battery_currents', + 'get_pv_currents' + ]; + + 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' + ]; + + return in_array($param, $chargepointParameters) || strpos($param, 'chargepoint') !== false; + } + + /** + * 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"; + default: + return "Parameter {$param} set to {$value}."; + } + } + + /** + * Raw-Ausgabe formatieren + */ + private function formatRawOutput($data) + { + if (is_array($data)) { + $firstKey = array_keys($data)[0]; + $firstValue = $data[$firstKey]; + + if (is_array($firstValue) && count($firstValue) === 1) { + return array_values($firstValue)[0]; + } + } + + return $data; + } +} + +// API instanziieren und Request verarbeiten +$api = new SimpleAPI(); +$api->handleRequest(); \ No newline at end of file From c8e3c32d64cce99485a2930c0657a1272d6a663f Mon Sep 17 00:00:00 2001 From: kevinwieland Date: Tue, 28 Oct 2025 07:50:12 +0100 Subject: [PATCH 02/10] bat_mode korrigiert --- simpleAPI/src/ParameterHandler.php | 834 +++++++++++++++++++++++++++++ 1 file changed, 834 insertions(+) create mode 100644 simpleAPI/src/ParameterHandler.php diff --git a/simpleAPI/src/ParameterHandler.php b/simpleAPI/src/ParameterHandler.php new file mode 100644 index 0000000000..3865e2b1f6 --- /dev/null +++ b/simpleAPI/src/ParameterHandler.php @@ -0,0 +1,834 @@ +mqttClient = $mqttClient; + } + + /** + * Parameter lesen + */ + public function readParameter($param, $id) + { + 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_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_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); + + default: + return null; + } + } + + /** + * Parameter schreiben + */ + public function writeParameter($param, $value, $chargepointId = null) + { + try { + switch ($param) { + case 'set_chargemode': + return $this->setChargemode($chargepointId, $value); + case 'chargecurrent': + return $this->setChargecurrent($chargepointId, $value); + case 'minimal_permanent_current': + return $this->setMinimalPermanentCurrent($chargepointId, $value); + case 'minimal_pv_soc': + return $this->setMinimalPvSoc($chargepointId, $value); + case 'max_price_eco': + return $this->setMaxPriceEco($chargepointId, $value); + case 'chargepoint_lock': + return $this->setChargepointLock($chargepointId, $value); + case 'bat_mode': + return $this->setBatMode($value); + 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 . 'phases_in_use', + $prefix . 'plug_state', + $prefix . 'charge_state', + $prefix . 'soc', + $prefix . 'soc_timestamp', + $prefix . 'vehicle_id', + $prefix . 'evse_current', + "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]; + } catch (Exception $e) { + $voltages = [0, 0, 0]; + $currents = [0, 0, 0]; + $powers = [0, 0, 0]; + } + + // Chargemode aus Template extrahieren + $chargemode = 'stop'; + try { + $template = json_decode($values["openWB/chargepoint/{$id}/set/charge_template"] ?? '{}', true); + $chargemode = $template['chargemode']['selected'] ?? 'stop'; + } 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' => $values[$prefix . 'state_str'] ?? 'Unbekannt', + 'fault_str' => $values[$prefix . 'fault_str'] ?? 'Kein Fehler', + 'fault_state' => intval($values[$prefix . 'fault_state'] ?? 0), + 'imported' => floatval($values[$prefix . 'imported'] ?? 0), + 'exported' => floatval($values[$prefix . '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'), + '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), + 'chargemode' => $chargemode + ] + ]; + + // manual_lock Status auslesen + $manualLockTopic = "openWB/chargepoint/{$id}/set/manual_lock"; + $manualLock = $this->mqttClient->getValue($manualLockTopic); + $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']); + } + + /** + * 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) + { + $topic = "openWB/chargepoint/{$id}/get/power"; + $power = $this->getNumericValue($topic); + + return [ + "chargepoint_{$id}" => [ + 'power' => $power + ] + ]; + } + + /** + * 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' => $values[$prefix . 'fault_str'] ?? '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' => $values[$prefix . 'fault_str'] ?? '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' => $values[$prefix . 'fault_str'] ?? 'Kein Fehler', + 'fault_state' => intval($values[$prefix . 'fault_state'] ?? 0) + ] + ]; + } + + /** + * Lademodus setzen (OpenWB Template-System) + */ + private function setChargemode($chargepointId, $mode) + { + // Gültige Modi mapping + $validModes = [ + 'instant' => 'instant_charging', + 'pv' => 'pv_charging', + 'eco' => 'eco_charging', + 'stop' => 'stop', + 'target' => '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 set topic']; + } + + $template = json_decode($templateJson, true); + if (!$template) { + return ['success' => false, 'message' => 'Invalid charge template format']; + } + + // 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 { + $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']; + } + + /** + * 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/pv_charging/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()]; + } + } +} \ No newline at end of file From a235df7b2879e3003edcb653675fd157a71630fd Mon Sep 17 00:00:00 2001 From: kevinwieland Date: Tue, 28 Oct 2025 07:51:40 +0100 Subject: [PATCH 03/10] versionmatch angepasst --- data/config/mosquitto/mosquitto.acl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/data/config/mosquitto/mosquitto.acl b/data/config/mosquitto/mosquitto.acl index c99f151a50..18221aa66e 100644 --- a/data/config/mosquitto/mosquitto.acl +++ b/data/config/mosquitto/mosquitto.acl @@ -1,4 +1,4 @@ -# openwb-version:2 +# openwb-version:3 # allow publishing set topics topic write openWB/set/# # allow clearing system messages @@ -11,3 +11,5 @@ topic read openWB/# topic read openWB-remote/# # allow brach "others" for devices other than openWB topic readwrite others/# +# allow read write access for simpleAPI +topic readwrite openWB/simpleAPI/# From 2799ffd6583e2e45bf52c6ea76fbcfe2e4889a6c Mon Sep 17 00:00:00 2001 From: kevinwieland Date: Tue, 28 Oct 2025 07:52:47 +0100 Subject: [PATCH 04/10] simpleAPI --- data/config/simpleAPI_mqtt_config.json | 11 + packages/helpermodules/simpleAPI_mqtt.py | 604 +++++++++++++++++++++++ simpleAPI/config/config.php | 38 ++ simpleAPI/src/Authenticator.php | 184 +++++++ simpleAPI/src/MqttClient.php | 237 +++++++++ simpleAPI/src/MqttClientSimple.php | 159 ++++++ 6 files changed, 1233 insertions(+) create mode 100644 data/config/simpleAPI_mqtt_config.json create mode 100644 packages/helpermodules/simpleAPI_mqtt.py create mode 100644 simpleAPI/config/config.php create mode 100644 simpleAPI/src/Authenticator.php create mode 100644 simpleAPI/src/MqttClient.php create mode 100644 simpleAPI/src/MqttClientSimple.php diff --git a/data/config/simpleAPI_mqtt_config.json b/data/config/simpleAPI_mqtt_config.json new file mode 100644 index 0000000000..9a7de748ec --- /dev/null +++ b/data/config/simpleAPI_mqtt_config.json @@ -0,0 +1,11 @@ +{ + "host": "localhost", + "port": 1883, + "username": null, + "password": null, + "use_tls": false, + "qos": 0, + "retain": true, + "reconnect_delay": 10, + "log_level": "INFO" +} \ No newline at end of file diff --git a/packages/helpermodules/simpleAPI_mqtt.py b/packages/helpermodules/simpleAPI_mqtt.py new file mode 100644 index 0000000000..5934edffec --- /dev/null +++ b/packages/helpermodules/simpleAPI_mqtt.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python3 +""" +SimpleMQTT API Daemon + +Transforms complex MQTT topic structures into simplified API topics. +Listens to openWB/* topics and republishes them under openWB/simpleAPI/* +with JSON/tuple expansion and ID simplification. +""" + +import argparse +import json +import logging +import time +import sys +import ssl +from typing import Dict, Any, Optional, Set +from pathlib import Path +import paho.mqtt.client as mqtt # type: ignore +import re + + +class SimpleMQTTDaemon: + """Main daemon class for SimpleMQTT API transformation.""" + + def __init__(self, host: str = "localhost", port: int = 1883, + username: Optional[str] = None, password: Optional[str] = None, + use_tls: bool = False, config_file: Optional[str] = None): + """Initialize the daemon with MQTT connection parameters.""" + self.host = host + self.port = port + self.username = username + self.password = password + self.use_tls = use_tls + + # Cache for tracking value changes + self.value_cache: Dict[str, Any] = {} + + # Cache for tracking lowest IDs per component type + self.lowest_ids: Dict[str, int] = {} + + # Cache for storing current charge_template configurations + self.charge_template_cache: Dict[str, Dict[str, Any]] = {} + + # MQTT client setup + self.client = mqtt.Client() + self.client.on_connect = self._on_connect + self.client.on_message = self._on_message + self.client.on_disconnect = self._on_disconnect + + # Setup logging + self._setup_logging() + + # Load additional config from file if provided + if config_file: + self._load_config_file(config_file) + + def _setup_logging(self): + """Configure logging for the daemon.""" + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[logging.StreamHandler(sys.stdout)] + ) + self.logger = logging.getLogger(__name__) + + def _load_config_file(self, config_file: str): + """Load configuration from JSON file.""" + try: + config_path = Path(config_file) + if not config_path.exists(): + self.logger.error(f"Configuration file not found: {config_file}") + sys.exit(1) + + with open(config_file, 'r') as f: + config = json.load(f) + + # Override instance variables with config file values + self.host = config.get('host', self.host) + self.port = config.get('port', self.port) + self.username = config.get('username', self.username) + self.password = config.get('password', self.password) + self.use_tls = config.get('use_tls', self.use_tls) + + # Set log level if specified in config + log_level = config.get('log_level', 'INFO') + numeric_level = getattr(logging, log_level.upper(), logging.INFO) + logging.getLogger().setLevel(numeric_level) + + self.logger.info(f"Loaded configuration from {config_file}") + except json.JSONDecodeError as e: + self.logger.error(f"Invalid JSON in config file {config_file}: {e}") + sys.exit(1) + except Exception as e: + self.logger.error(f"Failed to load config file {config_file}: {e}") + sys.exit(1) + + def _on_connect(self, client, userdata, flags, rc): + """Callback for successful MQTT connection.""" + if rc == 0: + self.logger.info(f"Connected to MQTT broker at {self.host}:{self.port}") + # Subscribe to specific openWB component topics + client.subscribe("openWB/bat/#", qos=0) + client.subscribe("openWB/pv/#", qos=0) + client.subscribe("openWB/chargepoint/#", qos=0) + client.subscribe("openWB/counter/#", qos=0) + + # Subscribe to simpleAPI set topics for write operations + client.subscribe("openWB/simpleAPI/set/#", qos=0) + + self.logger.info("Subscribed to openWB component topics (bat, pv, chargepoint, counter) and simpleAPI set topics") + else: + self.logger.error(f"Failed to connect to MQTT broker. Return code: {rc}") + + def _on_disconnect(self, client, userdata, rc): + """Callback for MQTT disconnection.""" + if rc != 0: + self.logger.warning(f"Unexpected MQTT disconnection (code: {rc}). Attempting to reconnect...") + self._reconnect() + else: + self.logger.info("Clean MQTT disconnection") + + def _reconnect(self): + """Attempt to reconnect to MQTT broker with delay.""" + reconnect_attempts = 0 + max_attempts = 100 # Prevent infinite loops + + while reconnect_attempts < max_attempts: + try: + reconnect_attempts += 1 + self.logger.info(f"Reconnection attempt {reconnect_attempts}/{max_attempts} in 10 seconds...") + time.sleep(10) + + self.client.reconnect() + self.logger.info("Successfully reconnected to MQTT broker") + break + + except Exception as e: + self.logger.error(f"Reconnection attempt {reconnect_attempts} failed: {e}") + + if reconnect_attempts >= max_attempts: + self.logger.critical("Maximum reconnection attempts exceeded. Exiting.") + sys.exit(1) + + def _on_message(self, client, userdata, msg): + """Process incoming MQTT messages.""" + try: + topic = msg.topic + payload = msg.payload.decode('utf-8') + + self.logger.debug(f"Received: {topic} = {payload}") + + # Handle write operations (simpleAPI set topics) + if topic.startswith('openWB/simpleAPI/set/'): + self._handle_write_operation(topic, payload) + return + + # Skip if this is already a simpleAPI topic to avoid loops + if '/simpleAPI/' in topic: + return + + # Cache charge_template configurations for later use + if '/set/charge_template' in topic: + self._cache_charge_template(topic, payload) + + # Transform and publish the message + self._transform_and_publish(topic, payload) + + except Exception as e: + self.logger.error(f"Error processing message {msg.topic}: {e}") + + def _transform_and_publish(self, original_topic: str, payload: str): + """Transform original topic to simpleAPI format and publish if value changed.""" + try: + # Parse the payload + parsed_payload = self._parse_payload(payload) + + # Generate transformed topics + transformed_topics = self._generate_simple_topics(original_topic, parsed_payload) + + # Publish each transformed topic if value changed + for topic, value in transformed_topics.items(): + self._publish_if_changed(topic, value) + + except Exception as e: + self.logger.error(f"Error transforming topic {original_topic}: {e}") + + def _parse_payload(self, payload: str) -> Any: + """Parse payload as JSON, tuple, or raw value.""" + payload = payload.strip() + + # Try to parse as JSON + if payload.startswith('{') or payload.startswith('['): + try: + return json.loads(payload) + except json.JSONDecodeError as e: + self.logger.warning(f"Invalid JSON payload: {payload} - {e}") + return payload + + # Try to parse as Python literal (for tuples/lists) + if payload.startswith('(') or payload.startswith('['): + try: + import ast + return ast.literal_eval(payload) + except (ValueError, SyntaxError) as e: + self.logger.warning(f"Invalid literal payload: {payload} - {e}") + return payload + + # Try to parse as number + try: + if '.' in payload: + return float(payload) + else: + return int(payload) + except ValueError: + pass + + # Try to parse boolean + if payload.lower() in ('true', 'false'): + return payload.lower() == 'true' + + # Return as string if null + if payload.lower() == 'null': + return None + + # Return as raw string + return payload + + def _generate_simple_topics(self, original_topic: str, parsed_value: Any) -> Dict[str, Any]: + """Generate simpleAPI topics from original topic and parsed value.""" + result = {} + + # Convert original topic to simpleAPI base + simple_base = original_topic.replace('openWB/', 'openWB/simpleAPI/') + + # Extract component info for ID tracking + self._track_component_ids(original_topic) + + # Handle different value types + self._expand_value_to_topics(simple_base, parsed_value, result) + + # Generate simplified topics for lowest IDs + simplified_topics = self._generate_simplified_topics(simple_base, parsed_value) + result.update(simplified_topics) + + return result + + 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) + + if match: + component_type = match.group(1) + component_id = int(match.group(2)) + + 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.""" + if isinstance(value, dict): + # Handle JSON objects + for key, val in value.items(): + new_topic = f"{base_topic}/{key}" + self._expand_value_to_topics(new_topic, val, result) + elif isinstance(value, (list, tuple)): + # Handle arrays/tuples with 1-based indexing + for i, val in enumerate(value, 1): + new_topic = f"{base_topic}/{i}" + self._expand_value_to_topics(new_topic, val, result) + else: + # Raw value + result[base_topic] = value + + def _generate_simplified_topics(self, simple_topic: str, parsed_value: Any) -> Dict[str, Any]: + """Generate topics without IDs for components with lowest IDs.""" + result = {} + + # Pattern to match simpleAPI topics with numeric IDs + pattern = r'openWB/simpleAPI/(\w+)/(\d+)/(.*)' + match = re.match(pattern, simple_topic) + + if match: + component_type = match.group(1) + component_id = int(match.group(2)) + remaining_path = match.group(3) + + # Check if this is the lowest ID for this component type + if component_type in self.lowest_ids and self.lowest_ids[component_type] == component_id: + simplified_base = f"openWB/simpleAPI/{component_type}/{remaining_path}" + self._expand_value_to_topics(simplified_base, parsed_value, result) + + return result + + def _publish_if_changed(self, topic: str, value: Any): + """Publish topic only if value has changed.""" + # Convert value to string for comparison and publishing + str_value = str(value) if value is not None else "null" + + # Check if value changed + if topic in self.value_cache and self.value_cache[topic] == str_value: + return # No change, don't publish + + # Update cache and publish + self.value_cache[topic] = str_value + + try: + self.client.publish(topic, str_value, qos=0, retain=True) + self.logger.debug(f"Published: {topic} = {str_value}") + except Exception as e: + self.logger.error(f"Failed to publish {topic}: {e}") + + def _cache_charge_template(self, topic: str, payload: str): + """Cache charge_template configurations for write operations.""" + try: + # Extract chargepoint ID from topic + pattern = r'openWB/chargepoint/(\d+)/set/charge_template' + match = re.match(pattern, topic) + + if match: + chargepoint_id = match.group(1) + charge_template = json.loads(payload) + self.charge_template_cache[chargepoint_id] = charge_template + self.logger.debug(f"Cached charge_template for chargepoint {chargepoint_id}") + + except json.JSONDecodeError as e: + self.logger.warning(f"Failed to parse charge_template JSON for {topic}: {e}") + except Exception as e: + self.logger.error(f"Error caching charge_template for {topic}: {e}") + + def _handle_write_operation(self, topic: str, payload: str): + """Handle write operations from simpleAPI set topics.""" + try: + self.logger.info(f"Write operation: {topic} = {payload}") + + # Parse the set topic to extract operation details + topic_parts = topic.replace('openWB/simpleAPI/set/', '').split('/') + + if len(topic_parts) < 2: + self.logger.error(f"Invalid set topic format: {topic}") + return + + operation = topic_parts[0] + + if operation == 'chargepoint': + self._handle_chargepoint_operation(topic_parts, payload) + elif operation == 'bat_mode': + self._handle_bat_mode_operation(payload) + else: + self.logger.error(f"Unknown operation: {operation}") + + except Exception as e: + self.logger.error(f"Error handling write operation {topic}: {e}") + + def _handle_chargepoint_operation(self, topic_parts: list, payload: str): + """Handle chargepoint-specific write operations.""" + # Determine chargepoint ID + if len(topic_parts) >= 3 and topic_parts[1].isdigit(): + # Explicit ID provided: chargepoint/3/parameter + chargepoint_id = topic_parts[1] + parameter = topic_parts[2] + elif len(topic_parts) >= 2: + # No ID provided: chargepoint/parameter - use lowest ID + if 'chargepoint' in self.lowest_ids: + chargepoint_id = str(self.lowest_ids['chargepoint']) + parameter = topic_parts[1] + else: + self.logger.error("No chargepoint ID found and no lowest ID available") + return + else: + self.logger.error(f"Invalid chargepoint topic format: {'/'.join(topic_parts)}") + return + + self.logger.debug(f"Chargepoint operation: ID={chargepoint_id}, parameter={parameter}, value={payload}") + + if parameter == 'chargemode': + self._set_chargemode(chargepoint_id, payload) + elif parameter == 'chargecurrent': + self._set_chargecurrent(chargepoint_id, payload) + elif parameter == 'minimal_pv_soc': + self._set_minimal_pv_soc(chargepoint_id, payload) + elif parameter == 'minimal_permanent_current': + self._set_minimal_permanent_current(chargepoint_id, payload) + elif parameter == 'max_price_eco': + self._set_max_price_eco(chargepoint_id, payload) + elif parameter == 'chargepoint_lock': + self._set_chargepoint_lock(chargepoint_id, payload) + else: + self.logger.error(f"Unknown chargepoint parameter: {parameter}") + + def _get_charge_template(self, chargepoint_id: str) -> Optional[Dict[str, Any]]: + """Get charge_template for a chargepoint, either from cache or request it.""" + if chargepoint_id in self.charge_template_cache: + return self.charge_template_cache[chargepoint_id].copy() + + self.logger.warning(f"No cached charge_template for chargepoint {chargepoint_id}") + return None + + def _set_chargemode(self, chargepoint_id: str, mode: str): + """Set chargemode for a chargepoint.""" + # Mapping from simple values to internal values + mode_mapping = { + 'instant': 'instant_charging', + 'pv': 'pv_charging', + 'eco': 'eco_charging', + 'stop': 'stop', + 'target': 'scheduled_charging' + } + + if mode not in mode_mapping: + self.logger.error(f"Invalid chargemode: {mode}") + return + + internal_mode = mode_mapping[mode] + charge_template = self._get_charge_template(chargepoint_id) + + if charge_template is None: + self.logger.error(f"No charge_template available for chargepoint {chargepoint_id}") + return + + # 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) + self.logger.info(f"Set chargemode to {mode} ({internal_mode}) for chargepoint {chargepoint_id}") + + def _set_chargecurrent(self, chargepoint_id: str, current: 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 + + 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) + self.logger.info(f"Set charge current to {current_value}A for chargepoint {chargepoint_id}") + + except ValueError: + self.logger.error(f"Invalid current value: {current}") + + def _set_minimal_pv_soc(self, chargepoint_id: str, soc: 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 + + 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) + self.logger.info(f"Set minimal PV SoC to {soc_value}% for chargepoint {chargepoint_id}") + + except ValueError: + self.logger.error(f"Invalid SoC value: {soc}") + + def _set_minimal_permanent_current(self, chargepoint_id: str, current: 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 + + 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) + self.logger.info(f"Set minimal permanent current to {current_value}A for chargepoint {chargepoint_id}") + + except ValueError: + self.logger.error(f"Invalid current value: {current}") + + def _set_max_price_eco(self, chargepoint_id: str, price: str): + """Set maximum price for ECO charging.""" + try: + price_value = float(price) + charge_template = self._get_charge_template(chargepoint_id) + + if charge_template is None: + return + + 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) + self.logger.info(f"Set max ECO price to {price_value} for chargepoint {chargepoint_id}") + + except ValueError: + self.logger.error(f"Invalid price value: {price}") + + def _set_chargepoint_lock(self, chargepoint_id: str, lock_state: str): + """Set chargepoint lock state.""" + try: + lock_value = 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) + self.logger.info(f"Set chargepoint {chargepoint_id} lock to {lock_value}") + + except Exception as e: + self.logger.error(f"Error setting chargepoint lock: {e}") + + def _handle_bat_mode_operation(self, payload: str): + """Handle battery mode operation.""" + valid_modes = ['min_soc_bat_mode', 'ev_mode', 'bat_mode'] + + if payload not in valid_modes: + self.logger.error(f"Invalid bat_mode: {payload}. Valid values: {valid_modes}") + return + + target_topic = "openWB/set/general/chargemode_config/pv_charging/bat_mode" + self.client.publish(target_topic, payload, qos=0, retain=True) + self.logger.info(f"Set bat_mode to {payload}") + + def _publish_json(self, topic: str, data: Dict[str, Any]): + """Publish JSON data to a topic.""" + try: + json_payload = json.dumps(data) + self.client.publish(topic, json_payload, qos=0, retain=True) + self.logger.debug(f"Published JSON to {topic}") + except Exception as e: + self.logger.error(f"Failed to publish JSON to {topic}: {e}") + + def connect(self): + """Establish connection to MQTT broker.""" + try: + # Setup TLS if requested + if self.use_tls: + self.client.tls_set(ca_certs=None, certfile=None, keyfile=None, + cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS, + ciphers=None) + + # Setup authentication if provided + if self.username and self.password: + self.client.username_pw_set(self.username, self.password) + + # Connect to broker + self.client.connect(self.host, self.port, 60) + return True + + except Exception as e: + self.logger.error(f"Failed to connect to MQTT broker: {e}") + return False + + def run(self): + """Start the daemon main loop.""" + if not self.connect(): + sys.exit(1) + + self.logger.info("SimpleMQTT API Daemon started") + + try: + self.client.loop_forever() + except KeyboardInterrupt: + self.logger.info("Received interrupt signal, shutting down...") + self.client.disconnect() + sys.exit(0) + + +def main(): + """Main entry point with command line argument parsing.""" + parser = argparse.ArgumentParser(description="SimpleMQTT API Daemon") + parser.add_argument("--host", default="localhost", help="MQTT broker host") + parser.add_argument("--port", type=int, default=1883, help="MQTT broker port") + parser.add_argument("--username", help="MQTT username") + parser.add_argument("--password", help="MQTT password") + parser.add_argument("--tls", action="store_true", help="Use TLS/SSL") + parser.add_argument("--config", help="Configuration file path") + parser.add_argument("--debug", action="store_true", help="Enable debug logging") + + args = parser.parse_args() + + # Set debug logging if requested + if args.debug: + logging.getLogger().setLevel(logging.DEBUG) + + # Create and run daemon + daemon = SimpleMQTTDaemon( + host=args.host, + port=args.port, + username=args.username, + password=args.password, + use_tls=args.tls, + config_file=args.config + ) + + daemon.run() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/simpleAPI/config/config.php b/simpleAPI/config/config.php new file mode 100644 index 0000000000..e60fef968b --- /dev/null +++ b/simpleAPI/config/config.php @@ -0,0 +1,38 @@ + [ + 'server' => 'localhost', + 'port' => 1883, + 'username' => '', + 'password' => '', + 'clientid' => 'SimpleAPI_' . uniqid() + ], + + // API Konfiguration + 'api' => [ + 'cors_enabled' => true, + 'max_request_size' => '10M' + ], + + // Authentifizierung + 'auth' => [ + 'enabled' => false, + '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 +]; \ No newline at end of file diff --git a/simpleAPI/src/Authenticator.php b/simpleAPI/src/Authenticator.php new file mode 100644 index 0000000000..0be8542d01 --- /dev/null +++ b/simpleAPI/src/Authenticator.php @@ -0,0 +1,184 @@ +config = $config; + } + + /** + * 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; + } + + 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'] ?? []; + + if (!isset($users[$username])) { + return false; + } + + $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); + } + } + + /** + * 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'); + } +} \ No newline at end of file diff --git a/simpleAPI/src/MqttClient.php b/simpleAPI/src/MqttClient.php new file mode 100644 index 0000000000..455a39bc55 --- /dev/null +++ b/simpleAPI/src/MqttClient.php @@ -0,0 +1,237 @@ +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', '1']); // Timeout auf 1 Sekunde reduziert + $output = shell_exec($cmd . ' 2>/dev/null'); + $value = trim($output ?? ''); + + if ($value === '') { + throw new \Exception("No data received for topic: $topic"); + } + + return $value; + } + + /** + * Mehrere Topics gleichzeitig abfragen (Performance-Optimierung) + */ + public function getMultipleValues($topics) + { + $results = []; + + // Alle Topics in einem mosquitto_sub Aufruf mit kurzem Timeout + $topicList = implode(' -t ', array_map('escapeshellarg', $topics)); + + $cmd = sprintf( + "mosquitto_sub -h %s -p %d -t %s -C %d -W 1 -v 2>/dev/null", + escapeshellarg($this->server), + $this->port, + $topicList, + count($topics) + ); + + // 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)); + } + + $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; + } + + /** + * 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 (MQTT Wildcard Scan) + */ + public function findAvailableIds($type) + { + // MQTT Wildcard verwenden um alle Topics zu finden + $pattern = "openWB/{$type}/+/get/imported"; + + $cmd = sprintf( + "mosquitto_sub -h %s -p %d -t %s -C 100 -W 1 -v 2>/dev/null", + escapeshellarg($this->server), + $this->port, + escapeshellarg($pattern) + ); + + if (!empty($this->username)) { + $cmd .= sprintf(" -u %s", escapeshellarg($this->username)); + } + if (!empty($this->password)) { + $cmd .= sprintf(" -P %s", escapeshellarg($this->password)); + } + + $output = shell_exec($cmd); + $ids = []; + + if ($output) { + $lines = explode("\n", trim($output)); + foreach ($lines as $line) { + if (preg_match("/openWB\/{$type}\/(\d+)\/get\/imported\s+(.+)/", $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)) { + $ids[] = $id; + } + } + } + } + + $ids = array_unique($ids); + + if (empty($ids)) { + throw new \Exception("No {$type} devices found via MQTT wildcard scan"); + } + + return $ids; + } + + /** + * 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(); + } +} \ No newline at end of file diff --git a/simpleAPI/src/MqttClientSimple.php b/simpleAPI/src/MqttClientSimple.php new file mode 100644 index 0000000000..0de044cbc2 --- /dev/null +++ b/simpleAPI/src/MqttClientSimple.php @@ -0,0 +1,159 @@ +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(); + } +} \ No newline at end of file From 953a36e793621edbfed0d3a3727e0bbd28a25eb4 Mon Sep 17 00:00:00 2001 From: kevinwieland Date: Tue, 28 Oct 2025 08:56:46 +0100 Subject: [PATCH 05/10] chmod +x --- packages/helpermodules/simpleAPI_mqtt.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 packages/helpermodules/simpleAPI_mqtt.py diff --git a/packages/helpermodules/simpleAPI_mqtt.py b/packages/helpermodules/simpleAPI_mqtt.py old mode 100644 new mode 100755 From ea1bffb6763579a089d211adce640ae9c826e04c Mon Sep 17 00:00:00 2001 From: kevinwieland Date: Tue, 28 Oct 2025 09:00:14 +0100 Subject: [PATCH 06/10] add service --- openwb-install.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openwb-install.sh b/openwb-install.sh index 7933f35249..3ed8cc4f0a 100755 --- a/openwb-install.sh +++ b/openwb-install.sh @@ -122,6 +122,11 @@ ln -s "${OPENWBBASEDIR}/data/config/openwb2.service" /etc/systemd/system/openwb2 systemctl daemon-reload systemctl enable openwb2 +echo "installing openwb2-simpleAPI service..." +ln -s "${OPENWBBASEDIR}/data/config/openwb-simpleAPI.service" /etc/systemd/system/openwb-simpleAPI.service +systemctl daemon-reload +systemctl enable openwb-simpleAPI + echo "installing openwb2 remote support service..." cp "${OPENWBBASEDIR}/data/config/openwbRemoteSupport.service" /etc/systemd/system/openwbRemoteSupport.service systemctl daemon-reload From 3ad82cb42c442eb377a491f234ea9cc4fad8633a Mon Sep 17 00:00:00 2001 From: kevinwieland Date: Tue, 28 Oct 2025 09:03:20 +0100 Subject: [PATCH 07/10] enable service --- runs/atreboot.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/runs/atreboot.sh b/runs/atreboot.sh index f99e312b05..849e6f0161 100755 --- a/runs/atreboot.sh +++ b/runs/atreboot.sh @@ -211,6 +211,20 @@ chmod 666 "$LOGFILE" sudo reboot now & fi + # check for openwb-simpleAPI service definition + if find /etc/systemd/system/ -maxdepth 1 -name openwb-simpleAPI.service -type l | grep -q "."; then + echo "openwb-simpleAPI.service definition is already a symlink" + else + if find /etc/systemd/system/ -maxdepth 1 -name openwb-simpleAPI.service -type f | grep -q "."; then + echo "openwb-simpleAPI.service definition is a regular file, deleting file" + sudo rm "/etc/systemd/system/openwb-simpleAPI.service" + fi + sudo ln -s "${OPENWBBASEDIR}/data/config/openwb-simpleAPI.service" /etc/systemd/system/openwb-simpleAPI.service + sudo systemctl daemon-reload + sudo systemctl enable openwb-simpleAPI + echo "openwb-simpleAPI.service definition updated." + fi + # check for remote support service definition if [ ! -f "/etc/systemd/system/openwbRemoteSupport.service" ]; then echo "openwbRemoteSupport service missing, installing service" From 21757f40da18cad7773c77f682b02e3170571301 Mon Sep 17 00:00:00 2001 From: kevinwieland Date: Tue, 28 Oct 2025 09:25:02 +0100 Subject: [PATCH 08/10] add service --- data/config/openwb-simpleAPI.service | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 data/config/openwb-simpleAPI.service diff --git a/data/config/openwb-simpleAPI.service b/data/config/openwb-simpleAPI.service new file mode 100644 index 0000000000..570a52068f --- /dev/null +++ b/data/config/openwb-simpleAPI.service @@ -0,0 +1,15 @@ +# openwb-version:1 +[Unit] +Description="openWB mqtt simpleAPI" +After=mosquitto.service + +[Service] +User=openwb +WorkingDirectory=/var/www/html/openWB +ExecStart=/var/www/html/openWB/packages/helpermodules/simpleAPI_mqtt.py --config /var/www/html/openWB/data/config/simpleAPI_mqtt_config.json +Restart=always +# extend timeout to 15min for long running atreboot +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target From f76e3df5a2db8948bb5f522219c4e3101a933641 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 28 Oct 2025 10:01:29 +0100 Subject: [PATCH 09/10] flake8 --- packages/helpermodules/simpleAPI_mqtt.py | 253 ++++++++++++----------- 1 file changed, 127 insertions(+), 126 deletions(-) diff --git a/packages/helpermodules/simpleAPI_mqtt.py b/packages/helpermodules/simpleAPI_mqtt.py index 5934edffec..366d341ad3 100755 --- a/packages/helpermodules/simpleAPI_mqtt.py +++ b/packages/helpermodules/simpleAPI_mqtt.py @@ -3,7 +3,7 @@ SimpleMQTT API Daemon Transforms complex MQTT topic structures into simplified API topics. -Listens to openWB/* topics and republishes them under openWB/simpleAPI/* +Listens to openWB/* topics and republishes them under openWB/simpleAPI/* with JSON/tuple expansion and ID simplification. """ @@ -13,16 +13,16 @@ import time import sys import ssl -from typing import Dict, Any, Optional, Set +from typing import Dict, Any, Optional from pathlib import Path -import paho.mqtt.client as mqtt # type: ignore +import paho.mqtt.client as mqtt # type: ignore import re class SimpleMQTTDaemon: """Main daemon class for SimpleMQTT API transformation.""" - - def __init__(self, host: str = "localhost", port: int = 1883, + + def __init__(self, host: str = "localhost", port: int = 1883, username: Optional[str] = None, password: Optional[str] = None, use_tls: bool = False, config_file: Optional[str] = None): """Initialize the daemon with MQTT connection parameters.""" @@ -31,29 +31,29 @@ def __init__(self, host: str = "localhost", port: int = 1883, self.username = username self.password = password self.use_tls = use_tls - + # Cache for tracking value changes self.value_cache: Dict[str, Any] = {} - + # Cache for tracking lowest IDs per component type self.lowest_ids: Dict[str, int] = {} - + # Cache for storing current charge_template configurations self.charge_template_cache: Dict[str, Dict[str, Any]] = {} - + # MQTT client setup self.client = mqtt.Client() self.client.on_connect = self._on_connect self.client.on_message = self._on_message self.client.on_disconnect = self._on_disconnect - + # Setup logging self._setup_logging() - + # Load additional config from file if provided if config_file: self._load_config_file(config_file) - + def _setup_logging(self): """Configure logging for the daemon.""" logging.basicConfig( @@ -62,7 +62,7 @@ def _setup_logging(self): handlers=[logging.StreamHandler(sys.stdout)] ) self.logger = logging.getLogger(__name__) - + def _load_config_file(self, config_file: str): """Load configuration from JSON file.""" try: @@ -70,22 +70,22 @@ def _load_config_file(self, config_file: str): if not config_path.exists(): self.logger.error(f"Configuration file not found: {config_file}") sys.exit(1) - + with open(config_file, 'r') as f: config = json.load(f) - + # Override instance variables with config file values self.host = config.get('host', self.host) self.port = config.get('port', self.port) self.username = config.get('username', self.username) self.password = config.get('password', self.password) self.use_tls = config.get('use_tls', self.use_tls) - + # Set log level if specified in config log_level = config.get('log_level', 'INFO') numeric_level = getattr(logging, log_level.upper(), logging.INFO) logging.getLogger().setLevel(numeric_level) - + self.logger.info(f"Loaded configuration from {config_file}") except json.JSONDecodeError as e: self.logger.error(f"Invalid JSON in config file {config_file}: {e}") @@ -93,7 +93,7 @@ def _load_config_file(self, config_file: str): except Exception as e: self.logger.error(f"Failed to load config file {config_file}: {e}") sys.exit(1) - + def _on_connect(self, client, userdata, flags, rc): """Callback for successful MQTT connection.""" if rc == 0: @@ -103,14 +103,15 @@ def _on_connect(self, client, userdata, flags, rc): client.subscribe("openWB/pv/#", qos=0) client.subscribe("openWB/chargepoint/#", qos=0) client.subscribe("openWB/counter/#", qos=0) - + # Subscribe to simpleAPI set topics for write operations client.subscribe("openWB/simpleAPI/set/#", qos=0) - - self.logger.info("Subscribed to openWB component topics (bat, pv, chargepoint, counter) and simpleAPI set topics") + + self.logger.info( + "Subscribed to openWB component topics (bat, pv, chargepoint, counter) and simpleAPI set topics") else: self.logger.error(f"Failed to connect to MQTT broker. Return code: {rc}") - + def _on_disconnect(self, client, userdata, rc): """Callback for MQTT disconnection.""" if rc != 0: @@ -118,76 +119,76 @@ def _on_disconnect(self, client, userdata, rc): self._reconnect() else: self.logger.info("Clean MQTT disconnection") - + def _reconnect(self): """Attempt to reconnect to MQTT broker with delay.""" reconnect_attempts = 0 max_attempts = 100 # Prevent infinite loops - + while reconnect_attempts < max_attempts: try: reconnect_attempts += 1 self.logger.info(f"Reconnection attempt {reconnect_attempts}/{max_attempts} in 10 seconds...") time.sleep(10) - + self.client.reconnect() self.logger.info("Successfully reconnected to MQTT broker") break - + except Exception as e: self.logger.error(f"Reconnection attempt {reconnect_attempts} failed: {e}") - + if reconnect_attempts >= max_attempts: self.logger.critical("Maximum reconnection attempts exceeded. Exiting.") sys.exit(1) - + def _on_message(self, client, userdata, msg): """Process incoming MQTT messages.""" try: topic = msg.topic payload = msg.payload.decode('utf-8') - + self.logger.debug(f"Received: {topic} = {payload}") - + # Handle write operations (simpleAPI set topics) if topic.startswith('openWB/simpleAPI/set/'): self._handle_write_operation(topic, payload) return - + # Skip if this is already a simpleAPI topic to avoid loops if '/simpleAPI/' in topic: return - + # Cache charge_template configurations for later use if '/set/charge_template' in topic: self._cache_charge_template(topic, payload) - + # Transform and publish the message self._transform_and_publish(topic, payload) - + except Exception as e: self.logger.error(f"Error processing message {msg.topic}: {e}") - + def _transform_and_publish(self, original_topic: str, payload: str): """Transform original topic to simpleAPI format and publish if value changed.""" try: # Parse the payload parsed_payload = self._parse_payload(payload) - + # Generate transformed topics transformed_topics = self._generate_simple_topics(original_topic, parsed_payload) - + # Publish each transformed topic if value changed for topic, value in transformed_topics.items(): self._publish_if_changed(topic, value) - + except Exception as e: self.logger.error(f"Error transforming topic {original_topic}: {e}") - + def _parse_payload(self, payload: str) -> Any: """Parse payload as JSON, tuple, or raw value.""" payload = payload.strip() - + # Try to parse as JSON if payload.startswith('{') or payload.startswith('['): try: @@ -195,7 +196,7 @@ def _parse_payload(self, payload: str) -> Any: except json.JSONDecodeError as e: self.logger.warning(f"Invalid JSON payload: {payload} - {e}") return payload - + # Try to parse as Python literal (for tuples/lists) if payload.startswith('(') or payload.startswith('['): try: @@ -204,7 +205,7 @@ def _parse_payload(self, payload: str) -> Any: except (ValueError, SyntaxError) as e: self.logger.warning(f"Invalid literal payload: {payload} - {e}") return payload - + # Try to parse as number try: if '.' in payload: @@ -213,52 +214,52 @@ def _parse_payload(self, payload: str) -> Any: return int(payload) except ValueError: pass - + # Try to parse boolean if payload.lower() in ('true', 'false'): return payload.lower() == 'true' - + # Return as string if null if payload.lower() == 'null': return None - + # Return as raw string return payload - + def _generate_simple_topics(self, original_topic: str, parsed_value: Any) -> Dict[str, Any]: """Generate simpleAPI topics from original topic and parsed value.""" result = {} - + # Convert original topic to simpleAPI base simple_base = original_topic.replace('openWB/', 'openWB/simpleAPI/') - + # Extract component info for ID tracking self._track_component_ids(original_topic) - + # Handle different value types self._expand_value_to_topics(simple_base, parsed_value, result) - + # Generate simplified topics for lowest IDs simplified_topics = self._generate_simplified_topics(simple_base, parsed_value) result.update(simplified_topics) - + return result - + 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) - + if match: component_type = match.group(1) component_id = int(match.group(2)) - + 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.""" if isinstance(value, dict): @@ -274,87 +275,87 @@ def _expand_value_to_topics(self, base_topic: str, value: Any, result: Dict[str, else: # Raw value result[base_topic] = value - + def _generate_simplified_topics(self, simple_topic: str, parsed_value: Any) -> Dict[str, Any]: """Generate topics without IDs for components with lowest IDs.""" result = {} - + # Pattern to match simpleAPI topics with numeric IDs pattern = r'openWB/simpleAPI/(\w+)/(\d+)/(.*)' match = re.match(pattern, simple_topic) - + if match: component_type = match.group(1) component_id = int(match.group(2)) remaining_path = match.group(3) - + # Check if this is the lowest ID for this component type if component_type in self.lowest_ids and self.lowest_ids[component_type] == component_id: simplified_base = f"openWB/simpleAPI/{component_type}/{remaining_path}" self._expand_value_to_topics(simplified_base, parsed_value, result) - + return result - + def _publish_if_changed(self, topic: str, value: Any): """Publish topic only if value has changed.""" # Convert value to string for comparison and publishing str_value = str(value) if value is not None else "null" - + # Check if value changed if topic in self.value_cache and self.value_cache[topic] == str_value: return # No change, don't publish - + # Update cache and publish self.value_cache[topic] = str_value - + try: self.client.publish(topic, str_value, qos=0, retain=True) self.logger.debug(f"Published: {topic} = {str_value}") except Exception as e: self.logger.error(f"Failed to publish {topic}: {e}") - + def _cache_charge_template(self, topic: str, payload: str): """Cache charge_template configurations for write operations.""" try: # Extract chargepoint ID from topic pattern = r'openWB/chargepoint/(\d+)/set/charge_template' match = re.match(pattern, topic) - + if match: chargepoint_id = match.group(1) charge_template = json.loads(payload) self.charge_template_cache[chargepoint_id] = charge_template self.logger.debug(f"Cached charge_template for chargepoint {chargepoint_id}") - + except json.JSONDecodeError as e: self.logger.warning(f"Failed to parse charge_template JSON for {topic}: {e}") except Exception as e: self.logger.error(f"Error caching charge_template for {topic}: {e}") - + def _handle_write_operation(self, topic: str, payload: str): """Handle write operations from simpleAPI set topics.""" try: self.logger.info(f"Write operation: {topic} = {payload}") - + # Parse the set topic to extract operation details topic_parts = topic.replace('openWB/simpleAPI/set/', '').split('/') - + if len(topic_parts) < 2: self.logger.error(f"Invalid set topic format: {topic}") return - + operation = topic_parts[0] - + if operation == 'chargepoint': self._handle_chargepoint_operation(topic_parts, payload) elif operation == 'bat_mode': self._handle_bat_mode_operation(payload) else: self.logger.error(f"Unknown operation: {operation}") - + except Exception as e: self.logger.error(f"Error handling write operation {topic}: {e}") - + def _handle_chargepoint_operation(self, topic_parts: list, payload: str): """Handle chargepoint-specific write operations.""" # Determine chargepoint ID @@ -373,9 +374,9 @@ def _handle_chargepoint_operation(self, topic_parts: list, payload: str): else: self.logger.error(f"Invalid chargepoint topic format: {'/'.join(topic_parts)}") return - + self.logger.debug(f"Chargepoint operation: ID={chargepoint_id}, parameter={parameter}, value={payload}") - + if parameter == 'chargemode': self._set_chargemode(chargepoint_id, payload) elif parameter == 'chargecurrent': @@ -390,15 +391,15 @@ def _handle_chargepoint_operation(self, topic_parts: list, payload: str): self._set_chargepoint_lock(chargepoint_id, payload) else: self.logger.error(f"Unknown chargepoint parameter: {parameter}") - + def _get_charge_template(self, chargepoint_id: str) -> Optional[Dict[str, Any]]: """Get charge_template for a chargepoint, either from cache or request it.""" if chargepoint_id in self.charge_template_cache: return self.charge_template_cache[chargepoint_id].copy() - + self.logger.warning(f"No cached charge_template for chargepoint {chargepoint_id}") return None - + def _set_chargemode(self, chargepoint_id: str, mode: str): """Set chargemode for a chargepoint.""" # Mapping from simple values to internal values @@ -409,122 +410,122 @@ def _set_chargemode(self, chargepoint_id: str, mode: str): 'stop': 'stop', 'target': 'scheduled_charging' } - + if mode not in mode_mapping: self.logger.error(f"Invalid chargemode: {mode}") return - + internal_mode = mode_mapping[mode] charge_template = self._get_charge_template(chargepoint_id) - + if charge_template is None: self.logger.error(f"No charge_template available for chargepoint {chargepoint_id}") return - + # 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) self.logger.info(f"Set chargemode to {mode} ({internal_mode}) for chargepoint {chargepoint_id}") - + def _set_chargecurrent(self, chargepoint_id: str, current: 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 - + 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) self.logger.info(f"Set charge current to {current_value}A for chargepoint {chargepoint_id}") - + except ValueError: self.logger.error(f"Invalid current value: {current}") - + def _set_minimal_pv_soc(self, chargepoint_id: str, soc: 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 - + 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) self.logger.info(f"Set minimal PV SoC to {soc_value}% for chargepoint {chargepoint_id}") - + except ValueError: self.logger.error(f"Invalid SoC value: {soc}") - + def _set_minimal_permanent_current(self, chargepoint_id: str, current: 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 - + 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) self.logger.info(f"Set minimal permanent current to {current_value}A for chargepoint {chargepoint_id}") - + except ValueError: self.logger.error(f"Invalid current value: {current}") - + def _set_max_price_eco(self, chargepoint_id: str, price: str): """Set maximum price for ECO charging.""" try: price_value = float(price) charge_template = self._get_charge_template(chargepoint_id) - + if charge_template is None: return - + 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) self.logger.info(f"Set max ECO price to {price_value} for chargepoint {chargepoint_id}") - + except ValueError: self.logger.error(f"Invalid price value: {price}") - + def _set_chargepoint_lock(self, chargepoint_id: str, lock_state: str): """Set chargepoint lock state.""" try: lock_value = 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) self.logger.info(f"Set chargepoint {chargepoint_id} lock to {lock_value}") - + except Exception as e: self.logger.error(f"Error setting chargepoint lock: {e}") - + def _handle_bat_mode_operation(self, payload: str): """Handle battery mode operation.""" valid_modes = ['min_soc_bat_mode', 'ev_mode', 'bat_mode'] - + if payload not in valid_modes: self.logger.error(f"Invalid bat_mode: {payload}. Valid values: {valid_modes}") return - + target_topic = "openWB/set/general/chargemode_config/pv_charging/bat_mode" self.client.publish(target_topic, payload, qos=0, retain=True) self.logger.info(f"Set bat_mode to {payload}") - + def _publish_json(self, topic: str, data: Dict[str, Any]): """Publish JSON data to a topic.""" try: @@ -533,35 +534,35 @@ def _publish_json(self, topic: str, data: Dict[str, Any]): self.logger.debug(f"Published JSON to {topic}") except Exception as e: self.logger.error(f"Failed to publish JSON to {topic}: {e}") - + def connect(self): """Establish connection to MQTT broker.""" try: # Setup TLS if requested if self.use_tls: - self.client.tls_set(ca_certs=None, certfile=None, keyfile=None, - cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS, - ciphers=None) - + self.client.tls_set(ca_certs=None, certfile=None, keyfile=None, + cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS, + ciphers=None) + # Setup authentication if provided if self.username and self.password: self.client.username_pw_set(self.username, self.password) - + # Connect to broker self.client.connect(self.host, self.port, 60) return True - + except Exception as e: self.logger.error(f"Failed to connect to MQTT broker: {e}") return False - + def run(self): """Start the daemon main loop.""" if not self.connect(): sys.exit(1) - + self.logger.info("SimpleMQTT API Daemon started") - + try: self.client.loop_forever() except KeyboardInterrupt: @@ -580,13 +581,13 @@ def main(): parser.add_argument("--tls", action="store_true", help="Use TLS/SSL") parser.add_argument("--config", help="Configuration file path") parser.add_argument("--debug", action="store_true", help="Enable debug logging") - + args = parser.parse_args() - + # Set debug logging if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) - + # Create and run daemon daemon = SimpleMQTTDaemon( host=args.host, @@ -596,9 +597,9 @@ def main(): use_tls=args.tls, config_file=args.config ) - + daemon.run() if __name__ == "__main__": - main() \ No newline at end of file + main() From 940a94d5271656da2d7adc28d51e2d5e79983356 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 28 Oct 2025 12:12:13 +0100 Subject: [PATCH 10/10] improvements --- data/config/openwb-simpleAPI.service | 2 +- runs/atreboot.sh | 1 + .../simpleAPI_mqtt.py | 188 ++++++++---------- 3 files changed, 80 insertions(+), 111 deletions(-) rename {packages/helpermodules => simpleAPI}/simpleAPI_mqtt.py (73%) diff --git a/data/config/openwb-simpleAPI.service b/data/config/openwb-simpleAPI.service index 570a52068f..b549c9223b 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/packages/helpermodules/simpleAPI_mqtt.py --config /var/www/html/openWB/data/config/simpleAPI_mqtt_config.json +ExecStart=/var/www/html/openWB/simpleAPI/simpleAPI_mqtt.py Restart=always # extend timeout to 15min for long running atreboot TimeoutStartSec=900 diff --git a/runs/atreboot.sh b/runs/atreboot.sh index 849e6f0161..e9d355fff9 100755 --- a/runs/atreboot.sh +++ b/runs/atreboot.sh @@ -222,6 +222,7 @@ chmod 666 "$LOGFILE" sudo ln -s "${OPENWBBASEDIR}/data/config/openwb-simpleAPI.service" /etc/systemd/system/openwb-simpleAPI.service sudo systemctl daemon-reload sudo systemctl enable openwb-simpleAPI + sudo systemctl restart openwb-simpleAPI echo "openwb-simpleAPI.service definition updated." fi diff --git a/packages/helpermodules/simpleAPI_mqtt.py b/simpleAPI/simpleAPI_mqtt.py similarity index 73% rename from packages/helpermodules/simpleAPI_mqtt.py rename to simpleAPI/simpleAPI_mqtt.py index 366d341ad3..781bb3ae0c 100755 --- a/packages/helpermodules/simpleAPI_mqtt.py +++ b/simpleAPI/simpleAPI_mqtt.py @@ -7,30 +7,35 @@ with JSON/tuple expansion and ID simplification. """ -import argparse import json import logging +from logging.handlers import RotatingFileHandler import time import sys import ssl from typing import Dict, Any, Optional from pathlib import Path -import paho.mqtt.client as mqtt # type: ignore +import paho.mqtt.client as mqtt import re +FORMAT_STR_SHORT = '%(asctime)s - %(message)s' +RAMDISK_PATH = str(Path(__file__).resolve().parents[1]) + '/ramdisk/' + +log = logging.getLogger("simpleAPI") +log.propagate = False +file_handler = RotatingFileHandler(RAMDISK_PATH + 'simple_api.log', maxBytes=500000, backupCount=1) # 0.5 MB +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" + class SimpleMQTTDaemon: """Main daemon class for SimpleMQTT API transformation.""" - def __init__(self, host: str = "localhost", port: int = 1883, - username: Optional[str] = None, password: Optional[str] = None, - use_tls: bool = False, config_file: Optional[str] = None): + def __init__(self, config_file: str): """Initialize the daemon with MQTT connection parameters.""" - self.host = host - self.port = port - self.username = username - self.password = password - self.use_tls = use_tls + self._load_config_file(config_file) # Cache for tracking value changes self.value_cache: Dict[str, Any] = {} @@ -47,57 +52,45 @@ def __init__(self, host: str = "localhost", port: int = 1883, self.client.on_message = self._on_message self.client.on_disconnect = self._on_disconnect - # Setup logging - self._setup_logging() - - # Load additional config from file if provided - if config_file: - self._load_config_file(config_file) - - def _setup_logging(self): - """Configure logging for the daemon.""" - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[logging.StreamHandler(sys.stdout)] - ) - self.logger = logging.getLogger(__name__) - def _load_config_file(self, config_file: str): """Load configuration from JSON file.""" try: config_path = Path(config_file) if not config_path.exists(): - self.logger.error(f"Configuration file not found: {config_file}") + log.error(f"Configuration file not found: {config_file}") sys.exit(1) with open(config_file, 'r') as f: config = json.load(f) # Override instance variables with config file values - self.host = config.get('host', self.host) - self.port = config.get('port', self.port) - self.username = config.get('username', self.username) - self.password = config.get('password', self.password) - self.use_tls = config.get('use_tls', self.use_tls) + try: + self.host = config['host'] + self.port = config['port'] + self.username = config['username'] + self.password = config['password'] + self.use_tls = config['use_tls'] + except KeyError as e: + log.exception(f"Missing required config parameter: {e}") + sys.exit(1) # Set log level if specified in config log_level = config.get('log_level', 'INFO') numeric_level = getattr(logging, log_level.upper(), logging.INFO) - logging.getLogger().setLevel(numeric_level) + log.setLevel(numeric_level) - self.logger.info(f"Loaded configuration from {config_file}") + log.info(f"Loaded configuration from {config_file}") except json.JSONDecodeError as e: - self.logger.error(f"Invalid JSON in config file {config_file}: {e}") + log.error(f"Invalid JSON in config file {config_file}: {e}") sys.exit(1) except Exception as e: - self.logger.error(f"Failed to load config file {config_file}: {e}") + log.error(f"Failed to load config file {config_file}: {e}") sys.exit(1) def _on_connect(self, client, userdata, flags, rc): """Callback for successful MQTT connection.""" if rc == 0: - self.logger.info(f"Connected to MQTT broker at {self.host}:{self.port}") + log.info(f"Connected to MQTT broker at {self.host}:{self.port}") # Subscribe to specific openWB component topics client.subscribe("openWB/bat/#", qos=0) client.subscribe("openWB/pv/#", qos=0) @@ -107,18 +100,18 @@ def _on_connect(self, client, userdata, flags, rc): # Subscribe to simpleAPI set topics for write operations client.subscribe("openWB/simpleAPI/set/#", qos=0) - self.logger.info( + log.info( "Subscribed to openWB component topics (bat, pv, chargepoint, counter) and simpleAPI set topics") else: - self.logger.error(f"Failed to connect to MQTT broker. Return code: {rc}") + log.error(f"Failed to connect to MQTT broker. Return code: {rc}") def _on_disconnect(self, client, userdata, rc): """Callback for MQTT disconnection.""" if rc != 0: - self.logger.warning(f"Unexpected MQTT disconnection (code: {rc}). Attempting to reconnect...") + log.warning(f"Unexpected MQTT disconnection (code: {rc}). Attempting to reconnect...") self._reconnect() else: - self.logger.info("Clean MQTT disconnection") + log.info("Clean MQTT disconnection") def _reconnect(self): """Attempt to reconnect to MQTT broker with delay.""" @@ -128,18 +121,18 @@ def _reconnect(self): while reconnect_attempts < max_attempts: try: reconnect_attempts += 1 - self.logger.info(f"Reconnection attempt {reconnect_attempts}/{max_attempts} in 10 seconds...") + log.info(f"Reconnection attempt {reconnect_attempts}/{max_attempts} in 10 seconds...") time.sleep(10) self.client.reconnect() - self.logger.info("Successfully reconnected to MQTT broker") + log.info("Successfully reconnected to MQTT broker") break except Exception as e: - self.logger.error(f"Reconnection attempt {reconnect_attempts} failed: {e}") + log.error(f"Reconnection attempt {reconnect_attempts} failed: {e}") if reconnect_attempts >= max_attempts: - self.logger.critical("Maximum reconnection attempts exceeded. Exiting.") + log.critical("Maximum reconnection attempts exceeded. Exiting.") sys.exit(1) def _on_message(self, client, userdata, msg): @@ -148,7 +141,7 @@ def _on_message(self, client, userdata, msg): topic = msg.topic payload = msg.payload.decode('utf-8') - self.logger.debug(f"Received: {topic} = {payload}") + log.debug(f"Received: {topic} = {payload}") # Handle write operations (simpleAPI set topics) if topic.startswith('openWB/simpleAPI/set/'): @@ -167,7 +160,7 @@ def _on_message(self, client, userdata, msg): self._transform_and_publish(topic, payload) except Exception as e: - self.logger.error(f"Error processing message {msg.topic}: {e}") + log.error(f"Error processing message {msg.topic}: {e}") def _transform_and_publish(self, original_topic: str, payload: str): """Transform original topic to simpleAPI format and publish if value changed.""" @@ -183,7 +176,7 @@ def _transform_and_publish(self, original_topic: str, payload: str): self._publish_if_changed(topic, value) except Exception as e: - self.logger.error(f"Error transforming topic {original_topic}: {e}") + log.error(f"Error transforming topic {original_topic}: {e}") def _parse_payload(self, payload: str) -> Any: """Parse payload as JSON, tuple, or raw value.""" @@ -194,7 +187,7 @@ def _parse_payload(self, payload: str) -> Any: try: return json.loads(payload) except json.JSONDecodeError as e: - self.logger.warning(f"Invalid JSON payload: {payload} - {e}") + log.warning(f"Invalid JSON payload: {payload} - {e}") return payload # Try to parse as Python literal (for tuples/lists) @@ -203,7 +196,7 @@ def _parse_payload(self, payload: str) -> Any: import ast return ast.literal_eval(payload) except (ValueError, SyntaxError) as e: - self.logger.warning(f"Invalid literal payload: {payload} - {e}") + log.warning(f"Invalid literal payload: {payload} - {e}") return payload # Try to parse as number @@ -310,9 +303,9 @@ def _publish_if_changed(self, topic: str, value: Any): try: self.client.publish(topic, str_value, qos=0, retain=True) - self.logger.debug(f"Published: {topic} = {str_value}") + log.debug(f"Published: {topic} = {str_value}") except Exception as e: - self.logger.error(f"Failed to publish {topic}: {e}") + log.error(f"Failed to publish {topic}: {e}") def _cache_charge_template(self, topic: str, payload: str): """Cache charge_template configurations for write operations.""" @@ -325,23 +318,23 @@ def _cache_charge_template(self, topic: str, payload: str): chargepoint_id = match.group(1) charge_template = json.loads(payload) self.charge_template_cache[chargepoint_id] = charge_template - self.logger.debug(f"Cached charge_template for chargepoint {chargepoint_id}") + log.debug(f"Cached charge_template for chargepoint {chargepoint_id}") except json.JSONDecodeError as e: - self.logger.warning(f"Failed to parse charge_template JSON for {topic}: {e}") + log.warning(f"Failed to parse charge_template JSON for {topic}: {e}") except Exception as e: - self.logger.error(f"Error caching charge_template for {topic}: {e}") + log.error(f"Error caching charge_template for {topic}: {e}") def _handle_write_operation(self, topic: str, payload: str): """Handle write operations from simpleAPI set topics.""" try: - self.logger.info(f"Write operation: {topic} = {payload}") + log.info(f"Write operation: {topic} = {payload}") # Parse the set topic to extract operation details topic_parts = topic.replace('openWB/simpleAPI/set/', '').split('/') if len(topic_parts) < 2: - self.logger.error(f"Invalid set topic format: {topic}") + log.error(f"Invalid set topic format: {topic}") return operation = topic_parts[0] @@ -351,10 +344,10 @@ def _handle_write_operation(self, topic: str, payload: str): elif operation == 'bat_mode': self._handle_bat_mode_operation(payload) else: - self.logger.error(f"Unknown operation: {operation}") + log.error(f"Unknown operation: {operation}") except Exception as e: - self.logger.error(f"Error handling write operation {topic}: {e}") + log.error(f"Error handling write operation {topic}: {e}") def _handle_chargepoint_operation(self, topic_parts: list, payload: str): """Handle chargepoint-specific write operations.""" @@ -369,13 +362,13 @@ def _handle_chargepoint_operation(self, topic_parts: list, payload: str): chargepoint_id = str(self.lowest_ids['chargepoint']) parameter = topic_parts[1] else: - self.logger.error("No chargepoint ID found and no lowest ID available") + log.error("No chargepoint ID found and no lowest ID available") return else: - self.logger.error(f"Invalid chargepoint topic format: {'/'.join(topic_parts)}") + log.error(f"Invalid chargepoint topic format: {'/'.join(topic_parts)}") return - self.logger.debug(f"Chargepoint operation: ID={chargepoint_id}, parameter={parameter}, value={payload}") + log.debug(f"Chargepoint operation: ID={chargepoint_id}, parameter={parameter}, value={payload}") if parameter == 'chargemode': self._set_chargemode(chargepoint_id, payload) @@ -390,14 +383,14 @@ def _handle_chargepoint_operation(self, topic_parts: list, payload: str): elif parameter == 'chargepoint_lock': self._set_chargepoint_lock(chargepoint_id, payload) else: - self.logger.error(f"Unknown chargepoint parameter: {parameter}") + log.error(f"Unknown chargepoint parameter: {parameter}") def _get_charge_template(self, chargepoint_id: str) -> Optional[Dict[str, Any]]: """Get charge_template for a chargepoint, either from cache or request it.""" if chargepoint_id in self.charge_template_cache: return self.charge_template_cache[chargepoint_id].copy() - self.logger.warning(f"No cached charge_template for chargepoint {chargepoint_id}") + log.warning(f"No cached charge_template for chargepoint {chargepoint_id}") return None def _set_chargemode(self, chargepoint_id: str, mode: str): @@ -412,14 +405,14 @@ def _set_chargemode(self, chargepoint_id: str, mode: str): } if mode not in mode_mapping: - self.logger.error(f"Invalid chargemode: {mode}") + log.error(f"Invalid chargemode: {mode}") return internal_mode = mode_mapping[mode] charge_template = self._get_charge_template(chargepoint_id) if charge_template is None: - self.logger.error(f"No charge_template available for chargepoint {chargepoint_id}") + log.error(f"No charge_template available for chargepoint {chargepoint_id}") return # Modify the chargemode.selected value @@ -428,7 +421,7 @@ def _set_chargemode(self, chargepoint_id: str, mode: str): # Publish the modified template target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) - self.logger.info(f"Set chargemode to {mode} ({internal_mode}) for chargepoint {chargepoint_id}") + log.info(f"Set chargemode to {mode} ({internal_mode}) for chargepoint {chargepoint_id}") def _set_chargecurrent(self, chargepoint_id: str, current: str): """Set charge current for instant charging.""" @@ -443,10 +436,10 @@ def _set_chargecurrent(self, chargepoint_id: str, current: str): target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) - self.logger.info(f"Set charge current to {current_value}A for chargepoint {chargepoint_id}") + log.info(f"Set charge current to {current_value}A for chargepoint {chargepoint_id}") except ValueError: - self.logger.error(f"Invalid current value: {current}") + log.error(f"Invalid current value: {current}") def _set_minimal_pv_soc(self, chargepoint_id: str, soc: str): """Set minimal EV SoC for PV charging.""" @@ -461,10 +454,10 @@ def _set_minimal_pv_soc(self, chargepoint_id: str, soc: str): target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) - self.logger.info(f"Set minimal PV SoC to {soc_value}% for chargepoint {chargepoint_id}") + log.info(f"Set minimal PV SoC to {soc_value}% for chargepoint {chargepoint_id}") except ValueError: - self.logger.error(f"Invalid SoC value: {soc}") + log.error(f"Invalid SoC value: {soc}") def _set_minimal_permanent_current(self, chargepoint_id: str, current: str): """Set minimal permanent current for PV charging.""" @@ -479,10 +472,10 @@ def _set_minimal_permanent_current(self, chargepoint_id: str, current: str): target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) - self.logger.info(f"Set minimal permanent current to {current_value}A for chargepoint {chargepoint_id}") + log.info(f"Set minimal permanent current to {current_value}A for chargepoint {chargepoint_id}") except ValueError: - self.logger.error(f"Invalid current value: {current}") + log.error(f"Invalid current value: {current}") def _set_max_price_eco(self, chargepoint_id: str, price: str): """Set maximum price for ECO charging.""" @@ -497,10 +490,10 @@ def _set_max_price_eco(self, chargepoint_id: str, price: str): target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template" self._publish_json(target_topic, charge_template) - self.logger.info(f"Set max ECO price to {price_value} for chargepoint {chargepoint_id}") + log.info(f"Set max ECO price to {price_value} for chargepoint {chargepoint_id}") except ValueError: - self.logger.error(f"Invalid price value: {price}") + log.error(f"Invalid price value: {price}") def _set_chargepoint_lock(self, chargepoint_id: str, lock_state: str): """Set chargepoint lock state.""" @@ -509,31 +502,31 @@ def _set_chargepoint_lock(self, chargepoint_id: str, lock_state: str): target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/manual_lock" self.client.publish(target_topic, str(lock_value).lower(), qos=0, retain=True) - self.logger.info(f"Set chargepoint {chargepoint_id} lock to {lock_value}") + log.info(f"Set chargepoint {chargepoint_id} lock to {lock_value}") except Exception as e: - self.logger.error(f"Error setting chargepoint lock: {e}") + log.error(f"Error setting chargepoint lock: {e}") def _handle_bat_mode_operation(self, payload: str): """Handle battery mode operation.""" valid_modes = ['min_soc_bat_mode', 'ev_mode', 'bat_mode'] if payload not in valid_modes: - self.logger.error(f"Invalid bat_mode: {payload}. Valid values: {valid_modes}") + log.error(f"Invalid bat_mode: {payload}. Valid values: {valid_modes}") return target_topic = "openWB/set/general/chargemode_config/pv_charging/bat_mode" self.client.publish(target_topic, payload, qos=0, retain=True) - self.logger.info(f"Set bat_mode to {payload}") + log.info(f"Set bat_mode to {payload}") def _publish_json(self, topic: str, data: Dict[str, Any]): """Publish JSON data to a topic.""" try: json_payload = json.dumps(data) self.client.publish(topic, json_payload, qos=0, retain=True) - self.logger.debug(f"Published JSON to {topic}") + log.debug(f"Published JSON to {topic}") except Exception as e: - self.logger.error(f"Failed to publish JSON to {topic}: {e}") + log.error(f"Failed to publish JSON to {topic}: {e}") def connect(self): """Establish connection to MQTT broker.""" @@ -553,7 +546,7 @@ def connect(self): return True except Exception as e: - self.logger.error(f"Failed to connect to MQTT broker: {e}") + log.error(f"Failed to connect to MQTT broker: {e}") return False def run(self): @@ -561,43 +554,18 @@ def run(self): if not self.connect(): sys.exit(1) - self.logger.info("SimpleMQTT API Daemon started") + log.info("SimpleMQTT API Daemon started") try: self.client.loop_forever() except KeyboardInterrupt: - self.logger.info("Received interrupt signal, shutting down...") + log.info("Received interrupt signal, shutting down...") self.client.disconnect() sys.exit(0) def main(): - """Main entry point with command line argument parsing.""" - parser = argparse.ArgumentParser(description="SimpleMQTT API Daemon") - parser.add_argument("--host", default="localhost", help="MQTT broker host") - parser.add_argument("--port", type=int, default=1883, help="MQTT broker port") - parser.add_argument("--username", help="MQTT username") - parser.add_argument("--password", help="MQTT password") - parser.add_argument("--tls", action="store_true", help="Use TLS/SSL") - parser.add_argument("--config", help="Configuration file path") - parser.add_argument("--debug", action="store_true", help="Enable debug logging") - - args = parser.parse_args() - - # Set debug logging if requested - if args.debug: - logging.getLogger().setLevel(logging.DEBUG) - - # Create and run daemon - daemon = SimpleMQTTDaemon( - host=args.host, - port=args.port, - username=args.username, - password=args.password, - use_tls=args.tls, - config_file=args.config - ) - + daemon = SimpleMQTTDaemon(config_file=CONFIG_FILE_PATH) daemon.run()