Skip to content
18 changes: 18 additions & 0 deletions data/config/mosquitto/public/default-dynamic-security.json
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,12 @@
"topic": "openWB/vehicle/+/name",
"priority": 0,
"allow": true
},
{
"acltype": "publishClientReceive",
"topic": "openWB/vehicle/+/color",
"priority": 0,
"allow": true
}
]
},
Expand Down Expand Up @@ -1768,6 +1774,12 @@
"priority": 0,
"allow": true
},
{
"acltype": "publishClientSend",
"topic": "openWB/set/vehicle/+/color",
"priority": 0,
"allow": true
},
{
"acltype": "publishClientSend",
"topic": "openWB/set/vehicle/+/charge_template",
Expand Down Expand Up @@ -1846,6 +1858,12 @@
"priority": 0,
"allow": true
},
{
"acltype": "publishClientReceive",
"topic": "openWB/vehicle/+/color",
"priority": 0,
"allow": true
},
{
"acltype": "publishClientReceive",
"topic": "openWB/vehicle/+/charge_template",
Expand Down
1 change: 1 addition & 0 deletions packages/control/chargepoint/chargepoint_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ class Config:
configuration: Dict = field(default_factory=empty_dict_factory)
ev: int = 0
name: str = "neuer Ladepunkt"
color: str = "#007bff"
type: Optional[str] = None
template: int = 0
connected_phases: int = 3
Expand Down
1 change: 1 addition & 0 deletions packages/control/ev/ev.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class EvData:
charge_template: int = field(default=0, metadata={"topic": "charge_template"})
ev_template: int = field(default=0, metadata={"topic": "ev_template"})
name: str = field(default="neues Fahrzeug", metadata={"topic": "name"})
color: str = field(default="#17a2b8", metadata={"topic": "color"})
tag_id: List[str] = field(default_factory=empty_list_factory, metadata={
"topic": "tag_id"})
get: Get = field(default_factory=get_factory)
Expand Down
161 changes: 96 additions & 65 deletions packages/helpermodules/measurement_logging/write_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,83 +14,86 @@
from helpermodules import timecheck
from helpermodules.utils.json_file_handler import write_and_check
from helpermodules.utils.topic_parser import decode_payload, get_index
from modules.common.utils.component_parser import get_component_name_by_id
from modules.common.utils.component_parser import get_component_name_by_id, get_component_color_by_id

log = logging.getLogger(__name__)

# erstellt für jeden Tag eine Datei, die die Daten für den Langzeitgraph enthält.
# Dazu werden alle 5 Min folgende Daten als json-Liste gespeichert:
# {"entries": [
# {
# "timestamp": int,
# "date": str,
# "prices": {
# "grid": Preis für Netzbezug,
# "pv": Preis für PV-Strom,
# "bat": Preis für Speicherstrom
# }
# "cp": {
# "cp1": {
# "imported": Zählerstand in Wh,
# "exported": Zählerstand in Wh
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# "all": {
# "imported": Zählerstand in Wh,
# "exported": Zählerstand in Wh
# }
# }
# "ev": {
# "ev1": {
# "soc": int in %
# {
# "entries": [
# {
# "timestamp": int,
# "date": str,
# "prices": {
# "grid": Preis für Netzbezug,
# "pv": Preis für PV-Strom,
# "bat": Preis für Speicherstrom
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# }
# "counter": {
# "counter0": {
# "grid": bool,
# "imported": Wh,
# "exported": Wh
# "cp": {
# "cp1": {
# "imported": Zählerstand in Wh,
# "exported": Zählerstand in Wh
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# "all": {
# "imported": Zählerstand in Wh,
# "exported": Zählerstand in Wh
# }
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# }
# "pv": {
# "all": {
# "exported": Wh
# "ev": {
# "ev1": {
# "soc": int in %
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# }
# "pv0": {
# "exported": Wh
# "counter": {
# "counter0": {
# "grid": bool,
# "imported": Wh,
# "exported": Wh
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# }
# "bat": {
# "all": {
# "imported": Wh,
# "exported": Wh,
# "soc": int in %
# "pv": {
# "all": {
# "exported": Wh
# }
# "pv0": {
# "exported": Wh
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# }
# "bat0": {
# "imported": Wh,
# "exported": Wh,
# "soc": int in %
# "bat": {
# "all": {
# "imported": Wh,
# "exported": Wh,
# "soc": int in %
# }
# "bat0": {
# "imported": Wh,
# "exported": Wh,
# "soc": int in %
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# }
# ... (dynamisch, je nach konfigurierter Anzahl)
# }
# "sh": {
# "sh1": {
# "exported": Wh,
# "imported": Wh,
# wenn konfiguriert:
# "temp1": int in °C,
# "temp2": int in °C,
# "temp3": int in °C
# "sh": {
# "sh1": {
# "exported": Wh,
# "imported": Wh,
# wenn konfiguriert:
# "temp1": int in °C,
# "temp2": int in °C,
# "temp3": int in °C
# },
# ... (dynamisch, je nach Anzahl konfigurierter Geräte)
# },
# ... (dynamisch, je nach Anzahl konfigurierter Geräte)
# },
# "hc": {"all": {"imported": Wh # Hausverbrauch}}
# }],
# "names": "names": {"sh1": "", "cp1": "", "counter2": "", "pv3": ""}
# }
# "hc": {"all": {"imported": Wh # Hausverbrauch}}
# }
# ],
# "names": {"cp1": "", "counter2": "", "pv3": ""},
# "colors": {"cp1": "", "counter2": "", "pv3": ""},
# }


class LogType(Enum):
Expand Down Expand Up @@ -165,6 +168,7 @@ def save_log(log_type: LogType):
entries = content["entries"]
entries.append(new_entry)
content["names"] = get_names(content["entries"][-1], sh_log_data.sh_names)
content["colors"] = get_colors(content["entries"][-1])
write_and_check(filepath, content)
return content["entries"]
except Exception:
Expand Down Expand Up @@ -357,3 +361,30 @@ def get_names(elements: Dict, sh_names: Dict, valid_names: Optional[Dict] = None
except (ValueError, KeyError, AttributeError):
names.update({entry: entry})
return names


def get_colors(elements: Dict) -> Dict:
""" Ermittelt die Farben der Fahrzeuge, Ladepunkte und Komponenten, welche
in elements vorhanden sind und gibt diese als Dictionary zurück.
Parameter
---------
elements: dict
Dictionary, das die Messwerte enthält.
"""
Comment on lines +366 to +373
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_colors() is new logic that affects the persisted measurement log format (content["colors"]). This module already has unit tests (write_log_test.py) for related helpers like get_names, but there are no tests for get_colors (including fallbacks to default color when data lookups fail). Add tests similar to test_get_names by monkeypatching get_component_color_by_id and/or data.data accessors.

Copilot uses AI. Check for mistakes.
colors = {}
for group in elements.items():
if group[0] not in ("ev", "cp", "counter", "pv", "bat"):
continue
for entry in group[1]:
if "all" != entry:
try:
if "ev" in entry:
colors.update({entry: data.data.ev_data[entry].data.color})
elif "cp" in entry:
colors.update({entry: data.data.cp_data[entry].data.config.color})
else:
id = entry.strip(string.ascii_letters)
colors.update({entry: get_component_color_by_id(int(id))})
except (ValueError, KeyError, AttributeError):
colors.update({entry: "#000000"})
return colors
2 changes: 2 additions & 0 deletions packages/helpermodules/setdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ def process_vehicle_topic(self, msg: mqtt.MQTTMessage):
try:
if "/name" in msg.topic:
self._validate_value(msg, str)
elif "/color" in msg.topic:
self._validate_value(msg, str)
elif "/info" in msg.topic:
self._validate_value(msg, "json")
elif "openWB/set/vehicle/set/vehicle_update_completed" in msg.topic:
Expand Down
86 changes: 85 additions & 1 deletion packages/helpermodules/update_config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from concurrent.futures import ProcessPoolExecutor
import copy
from dataclasses import asdict
import datetime
Expand Down Expand Up @@ -57,7 +58,7 @@

class UpdateConfig:

DATASTORE_VERSION = 116
DATASTORE_VERSION = 117

valid_topic = [
"^openWB/bat/config/bat_control_permitted$",
Expand Down Expand Up @@ -370,6 +371,7 @@ class UpdateConfig:
"^openWB/vehicle/[0-9]+/charge_template$",
"^openWB/vehicle/[0-9]+/ev_template$",
"^openWB/vehicle/[0-9]+/name$",
"^openWB/vehicle/[0-9]+/color$",
"^openWB/vehicle/[0-9]+/info$",
"^openWB/vehicle/[0-9]+/soc_module/calculated_soc_state$",
"^openWB/vehicle/[0-9]+/soc_module/config$",
Expand Down Expand Up @@ -571,6 +573,7 @@ class UpdateConfig:
("openWB/counter/config/consider_less_charging", counter_all.Config().consider_less_charging),
("openWB/counter/config/home_consumption_source_id", counter_all.Config().home_consumption_source_id),
("openWB/vehicle/0/name", "Standard-Fahrzeug"),
("openWB/vehicle/0/color", "#17a2b8"),
("openWB/vehicle/0/info", {"manufacturer": None, "model": None}),
("openWB/vehicle/0/charge_template", ev.Ev(0).charge_template.data.id),
("openWB/vehicle/0/soc_module/config", NO_MODULE),
Expand Down Expand Up @@ -3004,3 +3007,84 @@ def upgrade(topic: str, payload) -> Optional[dict]:
return {topic: payload}
self._loop_all_received_topics(upgrade)
self._append_datastore_version(116)

def upgrade_datastore_117(self) -> None:
DEFAULT_COLORS = {
"CHARGEPOINT": "#007bff",
"VEHICLE": "#17a2b8",
"INVERTER": "#28a745",
"COUNTER": "#dc3545",
Comment on lines +3011 to +3016
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior: upgrade_datastore_117 mutates topics and migrates log files, but there’s no unit test coverage for this upgrade (this repo already tests other datastore upgrades in update_config_test.py). Adding tests for the topic-migration logic (vehicle color topic creation, chargepoint/component config color injection, and handling of missing/None type) would help prevent regressions.

Copilot uses AI. Check for mistakes.
"BATTERY": "#ffc107",
"UNKNOWN": "#000000"
}

def _add_colors_to_log(file):
colors = {}
with open(file, "r+") as jsonFile:
content_raw = jsonFile.read()
content = json.loads(content_raw)
if "colors" in content:
return
for key in content["names"].keys():
if "bat" in key:
colors[key] = DEFAULT_COLORS["BATTERY"]
elif "counter" in key:
colors[key] = DEFAULT_COLORS["COUNTER"]
elif "cp" in key:
colors[key] = DEFAULT_COLORS["CHARGEPOINT"]
elif "ev" in key:
colors[key] = DEFAULT_COLORS["VEHICLE"]
elif "pv" in key:
colors[key] = DEFAULT_COLORS["INVERTER"]
else:
colors[key] = DEFAULT_COLORS["UNKNOWN"]
content["colors"] = colors
jsonFile.seek(0)
jsonFile.write(json.dumps(content))
jsonFile.truncate()

Comment on lines +3023 to +3045
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_add_colors_to_log assumes the log JSON is valid and always contains a names key. A corrupt/partial log file (or a file without names) will raise and can abort/derail the upgrade. Handle JSONDecodeError/KeyError gracefully (skip/rename file similar to write_log’s invalid-file handling) and guard content.get("names", {}).

Suggested change
with open(file, "r+") as jsonFile:
content_raw = jsonFile.read()
content = json.loads(content_raw)
if "colors" in content:
return
for key in content["names"].keys():
if "bat" in key:
colors[key] = DEFAULT_COLORS["BATTERY"]
elif "counter" in key:
colors[key] = DEFAULT_COLORS["COUNTER"]
elif "cp" in key:
colors[key] = DEFAULT_COLORS["CHARGEPOINT"]
elif "ev" in key:
colors[key] = DEFAULT_COLORS["VEHICLE"]
elif "inverter" in key:
colors[key] = DEFAULT_COLORS["INVERTER"]
else:
colors[key] = DEFAULT_COLORS["UNKNOWN"]
content["colors"] = colors
jsonFile.seek(0)
jsonFile.write(json.dumps(content))
jsonFile.truncate()
try:
with open(file, "r+") as jsonFile:
content_raw = jsonFile.read()
try:
content = json.loads(content_raw)
except json.JSONDecodeError:
log.warning("Skipping invalid log file (JSON decode failed): %s", file)
return
if "colors" in content:
return
names = content.get("names", {})
if not isinstance(names, dict):
log.warning("Skipping log file without valid 'names' mapping: %s", file)
return
for key in names.keys():
if "bat" in key:
colors[key] = DEFAULT_COLORS["BATTERY"]
elif "counter" in key:
colors[key] = DEFAULT_COLORS["COUNTER"]
elif "cp" in key:
colors[key] = DEFAULT_COLORS["CHARGEPOINT"]
elif "ev" in key:
colors[key] = DEFAULT_COLORS["VEHICLE"]
elif "inverter" in key:
colors[key] = DEFAULT_COLORS["INVERTER"]
else:
colors[key] = DEFAULT_COLORS["UNKNOWN"]
content["colors"] = colors
jsonFile.seek(0)
jsonFile.write(json.dumps(content))
jsonFile.truncate()
except OSError:
# If the file cannot be opened or written, skip it without aborting the upgrade.
log.warning("Skipping log file due to I/O error: %s", file)

Copilot uses AI. Check for mistakes.
def add_colors_to_logs():
files = glob.glob(str(self.base_path / "data" / "daily_log") + "/*")
files.extend(glob.glob(str(self.base_path / "data" / "monthly_log") + "/*"))
files.sort()
with ProcessPoolExecutor() as executor:
executor.map(_add_colors_to_log, files)
Comment on lines +3049 to +3051
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using ProcessPoolExecutor here has a few operational risks: the worker function is nested (can’t be pickled under the spawn start method), and exceptions from executor.map(...) won’t be surfaced unless the returned iterator is consumed. Consider switching to ThreadPoolExecutor (I/O-bound work) or moving the worker to module scope and explicitly consuming the results (e.g., for _ in executor.map(...): pass) so failures aren’t silently ignored.

Copilot uses AI. Check for mistakes.

def upgrade(topic: str, payload) -> Optional[dict]:
# add vehicle color to vehicle topics
if re.search("^openWB/vehicle/[0-9]+/name$", topic) is not None:
log.debug(f"Received vehicle name topic '{topic}'")
vehicle_color_topic = topic.replace("/name", "/color")
log.debug(f"Checking for vehicle color topic '{vehicle_color_topic}'")
if vehicle_color_topic not in self.all_received_topics:
log.debug(f"Adding vehicle color topic '{vehicle_color_topic}'"
f" with value: '{DEFAULT_COLORS['VEHICLE']}'")
return {vehicle_color_topic: DEFAULT_COLORS['VEHICLE']}
# add property "color" to charge points
if re.search("^openWB/chargepoint/[0-9]+/config$", topic) is not None:
config = decode_payload(payload)
log.debug(f"Received charge point config topic '{topic}' with payload: {payload}")
if "color" not in config:
config.update({"color": DEFAULT_COLORS['CHARGEPOINT']})
log.debug(f"Added color to charge point config: {config}")
return {topic: config}
# add property "color" to components
if re.search("^openWB/system/device/[0-9]+/component/[0-9]+/config$", topic) is not None:
config = decode_payload(payload)
log.debug(f"Received component config topic '{topic}' with payload: {payload}")
if "color" not in config:
component_type = (config.get("type") or "").lower()
if "counter" in component_type:
config.update({"color": DEFAULT_COLORS['COUNTER']})
elif "bat" in component_type:
config.update({"color": DEFAULT_COLORS['BATTERY']})
elif "inverter" in component_type:
config.update({"color": DEFAULT_COLORS['INVERTER']})
else:
log.warning(f"Unknown component type '{config.get('type')}' for topic '{topic}'.")
config.update({"color": DEFAULT_COLORS['UNKNOWN']})
log.debug(f"Updated component config with color: {config}")
return {topic: config}
self._loop_all_received_topics(upgrade)
add_colors_to_logs()
self._append_datastore_version(117)
9 changes: 9 additions & 0 deletions packages/modules/common/component_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,12 @@ def __init__(self, name: str, type: str, id: int, configuration: T) -> None:
self.type = type
self.id = id
self.configuration = configuration
if "counter" in type.lower():
self.color = "#dc3545"
elif "bat" in type.lower():
self.color = "#ffc107"
elif "inverter" in type.lower():
self.color = "#28a745"
else:
# Default color for other types
self.color = "#000000"
10 changes: 10 additions & 0 deletions packages/modules/common/utils/component_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ def get_component_name_by_id(id: int):
raise ValueError(f"Element {id} konnte keinem Gerät zugeordnet werden.")


def get_component_color_by_id(id: int):
for item in data.data.system_data.values():
if isinstance(item, AbstractDevice):
for comp in item.components.values():
if comp.component_config.id == id:
return comp.component_config.color
else:
raise ValueError(f"Element {id} konnte keinem Gerät zugeordnet werden.")


def get_io_name_by_id(id: int):
for item in data.data.system_data.values():
if isinstance(item, AbstractIoDevice):
Expand Down
Loading