Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 156 additions & 3 deletions simpleAPI/simpleAPI_mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,14 @@ 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)
client.subscribe("openWB/consumer/#", qos=0)

# Subscribe to simpleAPI set topics for write operations
client.subscribe("openWB/simpleAPI/set/#", qos=0)

log.info(
"Subscribed to openWB component topics (bat, pv, chargepoint, counter) and simpleAPI set topics")
"Subscribed to openWB component topics (bat, pv, chargepoint, counter, consumer) "
"and simpleAPI set topics")
else:
log.error(f"Failed to connect to MQTT broker. Return code: {rc}")

Expand Down Expand Up @@ -261,8 +263,11 @@ 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 to parse as JSON. openWB JSON-encodes every payload it publishes, including plain
# strings (e.g. fault_str arrives on the wire as '"Kein Fehler."', not 'Kein Fehler.') -
# without also matching a leading '"' here, those quote characters would be republished
# literally as part of the simpleAPI value.
if payload.startswith('{') or payload.startswith('[') or payload.startswith('"'):
try:
return json.loads(payload)
except json.JSONDecodeError as e:
Expand Down Expand Up @@ -585,6 +590,38 @@ def _publish_charge_template_read_topics(self, chargepoint_id: str, charge_templ
simple_topic = "openWB/simpleAPI/chargepoint/instant_charging_limit_soc"
self._publish_if_changed(simple_topic, limit['soc'])

# Extract pv_charging_limit values
pv_limit = pv_charging.get('limit', {})

if 'selected' in pv_limit:
topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/pv_charging_limit"
self._publish_if_changed(topic, pv_limit['selected'])

# Also publish for lowest ID if this is it
if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id):
simple_topic = "openWB/simpleAPI/chargepoint/pv_charging_limit"
self._publish_if_changed(simple_topic, pv_limit['selected'])

if 'amount' in pv_limit:
# Convert from internal value (Wh) to kWh for display
amount_kwh = pv_limit['amount'] / 1000
topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/pv_charging_limit_amount"
self._publish_if_changed(topic, amount_kwh)

# Also publish for lowest ID if this is it
if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id):
simple_topic = "openWB/simpleAPI/chargepoint/pv_charging_limit_amount"
self._publish_if_changed(simple_topic, amount_kwh)

if 'soc' in pv_limit:
topic = f"openWB/simpleAPI/chargepoint/{chargepoint_id}/pv_charging_limit_soc"
self._publish_if_changed(topic, pv_limit['soc'])

# Also publish for lowest ID if this is it
if 'chargepoint' in self.lowest_ids and self.lowest_ids['chargepoint'] == int(chargepoint_id):
simple_topic = "openWB/simpleAPI/chargepoint/pv_charging_limit_soc"
self._publish_if_changed(simple_topic, pv_limit['soc'])

except Exception as e:
log.error(f"Error publishing charge_template read topics: {e}")

Expand Down Expand Up @@ -618,6 +655,23 @@ def _handle_write_operation(self, topic: str, payload: str):
self._clear_set_topic(topic)
return

# Check for pv_charging_limit operations first (can be with or without chargepoint ID)
if 'pv_charging_limit_soc' in topic_remainder:
success = self._handle_pv_charging_limit_soc_operation(payload)
if success:
self._clear_set_topic(topic)
return
elif 'pv_charging_limit_amount' in topic_remainder:
success = self._handle_pv_charging_limit_amount_operation(payload)
if success:
self._clear_set_topic(topic)
return
elif 'pv_charging_limit' in topic_remainder:
success = self._handle_pv_charging_limit_operation(payload)
if success:
self._clear_set_topic(topic)
return

topic_parts = topic_remainder.split('/')

if len(topic_parts) < 1:
Expand Down Expand Up @@ -954,6 +1008,105 @@ def _handle_instant_charging_limit_amount_operation(self, payload: str) -> bool:
)
return True

def _handle_pv_charging_limit_operation(self, payload: str) -> bool:
"""Handle PV charging limit type operation."""
valid_limits = ['none', 'soc', 'amount']

if payload not in valid_limits:
log.error(f"Invalid pv_charging_limit: {payload}. Valid values: {valid_limits}")
return False

# Get chargepoint ID (use lowest if not specified)
if 'chargepoint' in self.lowest_ids:
chargepoint_id = str(self.lowest_ids['chargepoint'])
else:
log.error("No chargepoint ID found for pv_charging_limit operation")
return False

charge_template = self._get_charge_template(chargepoint_id)
if charge_template is None:
log.error(f"No charge_template available for chargepoint {chargepoint_id}")
return False

# Modify the pv_charging.limit.selected value
charge_template['chargemode']['pv_charging']['limit']['selected'] = payload

# Publish the modified template
target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template"
self._publish_json(target_topic, charge_template)
log.info(f"Set pv_charging_limit to {payload} for chargepoint {chargepoint_id}")
return True

def _handle_pv_charging_limit_soc_operation(self, payload: str) -> bool:
"""Handle PV charging limit SoC operation."""
try:
soc_value = int(payload)
if soc_value < 0 or soc_value > 100:
log.error(f"Invalid SoC value: {soc_value}. Must be between 0 and 100")
return False
except ValueError:
log.error(f"Invalid SoC value: {payload}. Must be an integer")
return False

# Get chargepoint ID (use lowest if not specified)
if 'chargepoint' in self.lowest_ids:
chargepoint_id = str(self.lowest_ids['chargepoint'])
else:
log.error("No chargepoint ID found for pv_charging_limit_soc operation")
return False

charge_template = self._get_charge_template(chargepoint_id)
if charge_template is None:
log.error(f"No charge_template available for chargepoint {chargepoint_id}")
return False

# Modify the pv_charging.limit.soc value
charge_template['chargemode']['pv_charging']['limit']['soc'] = soc_value

# Publish the modified template
target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template"
self._publish_json(target_topic, charge_template)
log.info(f"Set pv_charging_limit_soc to {soc_value}% for chargepoint {chargepoint_id}")
return True

def _handle_pv_charging_limit_amount_operation(self, payload: str) -> bool:
"""Handle PV charging limit amount operation."""
try:
amount_value = int(payload)
if amount_value < 1 or amount_value > 50:
log.error(f"Invalid amount value: {amount_value}. Must be between 1 and 50")
return False

# Convert to internal value (multiply by 1000)
internal_amount = amount_value * 1000
except ValueError:
log.error(f"Invalid amount value: {payload}. Must be an integer")
return False

# Get chargepoint ID (use lowest if not specified)
if 'chargepoint' in self.lowest_ids:
chargepoint_id = str(self.lowest_ids['chargepoint'])
else:
log.error("No chargepoint ID found for pv_charging_limit_amount operation")
return False

charge_template = self._get_charge_template(chargepoint_id)
if charge_template is None:
log.error(f"No charge_template available for chargepoint {chargepoint_id}")
return False

# Modify the pv_charging.limit.amount value
charge_template['chargemode']['pv_charging']['limit']['amount'] = internal_amount

# Publish the modified template
target_topic = f"openWB/set/chargepoint/{chargepoint_id}/set/charge_template"
self._publish_json(target_topic, charge_template)
log.info(
f"Set pv_charging_limit_amount to {amount_value} kWh "
f"({internal_amount} Wh) for chargepoint {chargepoint_id}"
)
return True

def _publish_json(self, topic: str, data: Dict[str, Any]):
"""Publish JSON data to a topic."""
try:
Expand Down
46 changes: 45 additions & 1 deletion simpleAPI/simpleapi.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ public function handleRequest()
}
}

// Komponenten-Übersicht: alle verfügbaren IDs je Typ auflisten
if (isset($params['list_components'])) {
echo json_encode($this->mqttClient->findAllAvailableIds());
return;
}

// Schreibvorgänge prüfen
$writeParams = $this->getWriteParameters($params);
if (!empty($writeParams)) {
Expand Down Expand Up @@ -186,6 +192,9 @@ private function getWriteParameters($params)
'instant_charging_limit',
'instant_charging_amount',
'instant_charging_soc',
'pv_charging_limit',
'pv_charging_amount',
'pv_charging_soc',
'vehicle',
'manual_soc',
'set_io_output'
Expand Down Expand Up @@ -302,6 +311,24 @@ private function getReadParameters($params)
'get_pv_yearly_exported',
'get_pv_fault_str',
'get_pv_fault_state',
// Consumer - Alle Daten
'consumer',
'get_consumer',
'get_consumer_all',
// Consumer - Einzelwerte
'get_consumer_power',
'get_consumer_currents',
'get_consumer_voltages',
'get_consumer_powers',
'get_consumer_imported',
'get_consumer_exported',
'get_consumer_daily_imported',
'get_consumer_phases_in_use',
'get_consumer_charge_state',
'get_consumer_state_str',
'get_consumer_fault_str',
'get_consumer_fault_state',
'get_consumer_usage_type',
// IO - Ausgaenge
'get_io_output_all',
'get_io_output',
Expand Down Expand Up @@ -458,6 +485,8 @@ private function getTypeFromParameter($param)
return 'pv';
} elseif (strpos($param, 'counter') !== false) {
return 'counter';
} elseif (strpos($param, 'consumer') !== false) {
return 'consumer';
}

return 'chargepoint'; // Fallback
Expand Down Expand Up @@ -485,7 +514,13 @@ private function isComplexParameter($param)
'get_counter_power_factors',
'get_battery_currents',
'get_pv_currents',
'get_io_output_all'
'get_io_output_all',
'get_consumer_all',
'get_consumer',
'consumer',
'get_consumer_currents',
'get_consumer_voltages',
'get_consumer_powers'
];

return in_array($param, $complexParameters);
Expand All @@ -508,6 +543,9 @@ private function isChargepointParameter($param)
'instant_charging_limit',
'instant_charging_amount',
'instant_charging_soc',
'pv_charging_limit',
'pv_charging_amount',
'pv_charging_soc',
'vehicle',
'manual_soc'
];
Expand Down Expand Up @@ -539,6 +577,12 @@ private function getSuccessMessage($param, $value, $chargepointId)
return "Instant charging amount for chargepoint {$chargepointId} set to {$value}kWh.";
case 'instant_charging_soc':
return "Instant charging SoC for chargepoint {$chargepointId} set to {$value}%.";
case 'pv_charging_limit':
return "PV charging limit for chargepoint {$chargepointId} set to {$value}.";
case 'pv_charging_amount':
return "PV charging amount for chargepoint {$chargepointId} set to {$value}kWh.";
case 'pv_charging_soc':
return "PV charging SoC for chargepoint {$chargepointId} set to {$value}%.";
case 'vehicle':
return "Vehicle {$value} assigned to chargepoint {$chargepointId}.";
case 'manual_soc':
Expand Down
61 changes: 50 additions & 11 deletions simpleAPI/src/MqttClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,20 +230,44 @@ private function buildMosquittoCommandWithCredentials($type, $topics, $message =
}

/**
* Alle verfügbaren IDs für einen bestimmten Typ finden (MQTT Wildcard Scan)
* Scan-Konfiguration je Typ: welches Wildcard-Topic zuverlässig für jedes konfigurierte
* Gerät dieses Typs publiziert wird, und wie die ID daraus extrahiert wird.
*/
public function findAvailableIds($type)
private function getScanConfig()
{
// MQTT Wildcard verwenden um alle Topics zu finden
$scanConfig = [
return [
'chargepoint' => ['pattern' => 'openWB/chargepoint/+/get/imported', 'regex' => '/openWB\/chargepoint\/(\d+)\/get\/imported\s+(.+)/'],
'bat' => ['pattern' => 'openWB/bat/+/get/imported', 'regex' => '/openWB\/bat\/(\d+)\/get\/imported\s+(.+)/'],
'pv' => ['pattern' => 'openWB/pv/+/get/exported', 'regex' => '/openWB\/pv\/(\d+)\/get\/exported\s+(.+)/'],
'counter' => ['pattern' => 'openWB/counter/+/get/imported', 'regex' => '/openWB\/counter\/(\d+)\/get\/imported\s+(.+)/'],
'consumer' => ['pattern' => 'openWB/consumer/+/module', 'regex' => '/openWB\/consumer\/(\d+)\/module\s+(.+)/'],
'io' => ['pattern' => 'openWB/io/states/+/get/digital_output', 'regex' => '/openWB\/io\/states\/(\d+)\/get\/digital_output\s+(.+)/']
];
}

/**
* Alle verfügbaren IDs für einen bestimmten Typ finden (MQTT Wildcard Scan).
* Wirft eine Exception, wenn keine Geräte gefunden wurden (genutzt von getLowestId, wo ein
* leeres Ergebnis ein echter Fehlerfall ist).
*/
public function findAvailableIds($type)
{
$ids = $this->scanAvailableIds($type);

if (empty($ids)) {
throw new \Exception("No {$type} devices found via MQTT wildcard scan");
}

return $ids;
}

$config = $scanConfig[$type] ?? null;
/**
* Wie findAvailableIds(), aber ohne Exception bei leerem Ergebnis - für list_components, wo
* ein Typ ohne konfigurierte Geräte kein Fehler, sondern schlicht eine leere Liste ist.
*/
private function scanAvailableIds($type)
{
$config = $this->getScanConfig()[$type] ?? null;
if ($config === null) {
throw new \Exception("Unsupported type for ID scan: {$type}");
}
Expand All @@ -264,21 +288,36 @@ public function findAvailableIds($type)
$id = intval($matches[1]);
$value = trim($matches[2]);

// Nur IDs mit gültigen Werten (bei IO auch JSON erlaubt)
if ($value !== '' && $value !== 'null' && ($type === 'io' || is_numeric($value))) {
// Nur IDs mit gültigen Werten (bei IO/Consumer auch JSON erlaubt)
if ($value !== '' && $value !== 'null' &&
($type === 'io' || $type === 'consumer' || is_numeric($value))) {
$ids[] = $id;
}
}
}
}

// array_unique() zuerst (behält Original-Keys, ggf. lückenhaft), dann sort() zum
// Schluss, da sort() die Keys wieder lückenlos ab 0 durchnummeriert - sonst würde
// json_encode() bei entfernten Duplikaten ein Objekt statt ein Array ausgeben.
$ids = array_unique($ids);
sort($ids, SORT_NUMERIC);
return $ids;
}

if (empty($ids)) {
throw new \Exception("No {$type} devices found via MQTT wildcard scan");
/**
* Alle verfügbaren IDs für alle bekannten Typen auf einmal finden (für list_components).
* Typen ohne konfigurierte Geräte liefern eine leere Liste statt einer Exception.
*/
public function findAllAvailableIds()
{
$result = [];
foreach (array_keys($this->getScanConfig()) as $type) {
// Öffentlicher Name: 'bat' intern, aber 'battery' in Parametern/Antworten üblich.
$publicName = $type === 'bat' ? 'battery' : $type;
$result[$publicName] = $this->scanAvailableIds($type);
}

return $ids;
return $result;
}

/**
Expand Down
Loading
Loading