gridos 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. gridos/__init__.py +10 -0
  2. gridos/adapters/__init__.py +12 -0
  3. gridos/adapters/base.py +116 -0
  4. gridos/adapters/dnp3.py +97 -0
  5. gridos/adapters/iec61850.py +99 -0
  6. gridos/adapters/modbus.py +177 -0
  7. gridos/adapters/mqtt.py +178 -0
  8. gridos/adapters/opcua.py +139 -0
  9. gridos/api/__init__.py +6 -0
  10. gridos/api/dependencies.py +101 -0
  11. gridos/api/routes/__init__.py +3 -0
  12. gridos/api/routes/control.py +68 -0
  13. gridos/api/routes/devices.py +85 -0
  14. gridos/api/routes/forecast.py +77 -0
  15. gridos/api/routes/optimization.py +126 -0
  16. gridos/api/routes/telemetry.py +122 -0
  17. gridos/api/websocket_manager.py +120 -0
  18. gridos/config.py +170 -0
  19. gridos/digital_twin/__init__.py +10 -0
  20. gridos/digital_twin/engine.py +353 -0
  21. gridos/digital_twin/ml/__init__.py +11 -0
  22. gridos/digital_twin/ml/anomaly_detector.py +187 -0
  23. gridos/digital_twin/ml/forecaster.py +332 -0
  24. gridos/digital_twin/ml/trainer.py +190 -0
  25. gridos/digital_twin/models/__init__.py +25 -0
  26. gridos/digital_twin/models/battery.py +121 -0
  27. gridos/digital_twin/models/bus.py +80 -0
  28. gridos/digital_twin/models/ev_charger.py +136 -0
  29. gridos/digital_twin/models/line.py +107 -0
  30. gridos/digital_twin/models/load.py +94 -0
  31. gridos/digital_twin/models/pv.py +104 -0
  32. gridos/digital_twin/models/transformer.py +106 -0
  33. gridos/edge/__init__.py +6 -0
  34. gridos/edge/local_cache.py +234 -0
  35. gridos/edge/sync.py +114 -0
  36. gridos/main.py +130 -0
  37. gridos/models/__init__.py +30 -0
  38. gridos/models/common.py +318 -0
  39. gridos/models/iec61850.py +200 -0
  40. gridos/optimization/__init__.py +10 -0
  41. gridos/optimization/dispatch.py +149 -0
  42. gridos/optimization/scheduler.py +315 -0
  43. gridos/security/__init__.py +6 -0
  44. gridos/security/auth.py +222 -0
  45. gridos/storage/__init__.py +10 -0
  46. gridos/storage/base.py +127 -0
  47. gridos/storage/influxdb.py +201 -0
  48. gridos/storage/timescaledb.py +221 -0
  49. gridos/utils/__init__.py +5 -0
  50. gridos/utils/helpers.py +149 -0
  51. gridos/utils/logging.py +117 -0
  52. gridos/utils/metrics.py +176 -0
  53. gridos-0.1.0.dist-info/METADATA +270 -0
  54. gridos-0.1.0.dist-info/RECORD +57 -0
  55. gridos-0.1.0.dist-info/WHEEL +5 -0
  56. gridos-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. gridos-0.1.0.dist-info/top_level.txt +1 -0
gridos/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ GridOS — Open Energy Operating System.
3
+
4
+ Vendor-neutral middleware for managing Distributed Energy Resources (DERs)
5
+ through a unified, standards-based interface.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __author__ = "GridOS Contributors"
10
+ __license__ = "MIT"
@@ -0,0 +1,12 @@
1
+ """
2
+ GridOS protocol adapters.
3
+
4
+ Provides a unified interface for communicating with Distributed Energy
5
+ Resources over various industrial protocols (Modbus TCP/RTU, MQTT, DNP3,
6
+ IEC 61850, OPC-UA). All adapters inherit from :class:`BaseAdapter` and
7
+ expose the same async API.
8
+ """
9
+
10
+ from gridos.adapters.base import BaseAdapter
11
+
12
+ __all__ = ["BaseAdapter"]
@@ -0,0 +1,116 @@
1
+ """
2
+ Abstract base class for all GridOS protocol adapters.
3
+
4
+ Every concrete adapter (Modbus, MQTT, DNP3, …) must inherit from
5
+ :class:`BaseAdapter` and implement the four async lifecycle methods:
6
+ ``connect``, ``disconnect``, ``read_telemetry``, and ``write_command``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from abc import ABC, abstractmethod
13
+ from typing import Any
14
+
15
+ from gridos.models.common import ControlCommand, DERTelemetry
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class BaseAdapter(ABC):
21
+ """Unified async interface for DER communication protocols.
22
+
23
+ Parameters
24
+ ----------
25
+ device_id:
26
+ Identifier of the device this adapter instance manages.
27
+ config:
28
+ Protocol-specific configuration dictionary.
29
+ """
30
+
31
+ def __init__(self, device_id: str, config: dict[str, Any] | None = None) -> None:
32
+ self.device_id = device_id
33
+ self.config: dict[str, Any] = config or {}
34
+ self._connected: bool = False
35
+ self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
36
+
37
+ # ── Properties ───────────────────────────────────────────────────────
38
+
39
+ @property
40
+ def is_connected(self) -> bool:
41
+ """Return ``True`` if the adapter has an active connection."""
42
+ return self._connected
43
+
44
+ @property
45
+ def protocol_name(self) -> str:
46
+ """Human-readable protocol name (override in subclasses)."""
47
+ return "unknown"
48
+
49
+ # ── Abstract Methods ─────────────────────────────────────────────────
50
+
51
+ @abstractmethod
52
+ async def connect(self) -> None:
53
+ """Establish a connection to the device.
54
+
55
+ Raises
56
+ ------
57
+ ConnectionError
58
+ If the connection cannot be established.
59
+ """
60
+
61
+ @abstractmethod
62
+ async def disconnect(self) -> None:
63
+ """Gracefully close the connection."""
64
+
65
+ @abstractmethod
66
+ async def read_telemetry(self) -> DERTelemetry:
67
+ """Read the latest telemetry from the device.
68
+
69
+ Returns
70
+ -------
71
+ DERTelemetry
72
+ A validated telemetry snapshot.
73
+
74
+ Raises
75
+ ------
76
+ IOError
77
+ If the read operation fails.
78
+ """
79
+
80
+ @abstractmethod
81
+ async def write_command(self, command: ControlCommand) -> bool:
82
+ """Send a control command to the device.
83
+
84
+ Parameters
85
+ ----------
86
+ command:
87
+ The validated control command to execute.
88
+
89
+ Returns
90
+ -------
91
+ bool
92
+ ``True`` if the command was acknowledged by the device.
93
+
94
+ Raises
95
+ ------
96
+ IOError
97
+ If the write operation fails.
98
+ """
99
+
100
+ # ── Context Manager ──────────────────────────────────────────────────
101
+
102
+ async def __aenter__(self) -> BaseAdapter:
103
+ await self.connect()
104
+ return self
105
+
106
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
107
+ await self.disconnect()
108
+
109
+ # ── Helpers ──────────────────────────────────────────────────────────
110
+
111
+ def __repr__(self) -> str:
112
+ status = "connected" if self._connected else "disconnected"
113
+ return (
114
+ f"<{self.__class__.__name__} device_id={self.device_id!r} "
115
+ f"protocol={self.protocol_name!r} status={status}>"
116
+ )
@@ -0,0 +1,97 @@
1
+ """
2
+ DNP3 protocol adapter for GridOS (stub).
3
+
4
+ DNP3 (Distributed Network Protocol 3) is widely used in SCADA systems for
5
+ utility-scale DER communication. This module provides the adapter skeleton;
6
+ a full implementation requires a DNP3 master library such as ``opendnp3`` or
7
+ ``pydnp3``.
8
+
9
+ TODO:
10
+ - Integrate with ``opendnp3`` C++ bindings or ``pydnp3``.
11
+ - Implement integrity polls and event-driven reads.
12
+ - Support DNP3 Secure Authentication (SA).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from datetime import datetime
19
+ from typing import Any
20
+
21
+ from gridos.adapters.base import BaseAdapter
22
+ from gridos.models.common import ControlCommand, DERStatus, DERTelemetry
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class DNP3Adapter(BaseAdapter):
28
+ """DNP3 master adapter (stub implementation).
29
+
30
+ Parameters
31
+ ----------
32
+ device_id:
33
+ Unique device identifier.
34
+ config:
35
+ Expected keys: ``outstation_host``, ``outstation_port`` (default 20000),
36
+ ``master_address`` (default 1), ``outstation_address`` (default 10).
37
+ """
38
+
39
+ def __init__(self, device_id: str, config: dict[str, Any] | None = None) -> None:
40
+ super().__init__(device_id, config)
41
+ self._outstation_host: str = self.config.get("outstation_host", "localhost")
42
+ self._outstation_port: int = int(self.config.get("outstation_port", 20000))
43
+ self._master_address: int = int(self.config.get("master_address", 1))
44
+ self._outstation_address: int = int(self.config.get("outstation_address", 10))
45
+
46
+ @property
47
+ def protocol_name(self) -> str:
48
+ return "dnp3"
49
+
50
+ async def connect(self) -> None:
51
+ """Establish a DNP3 master session.
52
+
53
+ Raises
54
+ ------
55
+ NotImplementedError
56
+ Full DNP3 support is not yet implemented.
57
+ """
58
+ self.logger.warning(
59
+ "DNP3 adapter connect() called — stub implementation. "
60
+ "Install opendnp3 and implement the full master stack."
61
+ )
62
+ # Mark as connected so that the adapter can be used in test harnesses
63
+ # with simulated data.
64
+ self._connected = True
65
+
66
+ async def disconnect(self) -> None:
67
+ """Close the DNP3 session."""
68
+ self._connected = False
69
+ self.logger.info("DNP3 adapter disconnected (stub)")
70
+
71
+ async def read_telemetry(self) -> DERTelemetry:
72
+ """Perform an integrity poll and return telemetry.
73
+
74
+ Returns a placeholder telemetry object until the full DNP3 master
75
+ stack is integrated.
76
+ """
77
+ self.logger.debug("DNP3 read_telemetry() — returning placeholder data")
78
+ return DERTelemetry(
79
+ device_id=self.device_id,
80
+ timestamp=datetime.utcnow(),
81
+ power_kw=0.0,
82
+ reactive_power_kvar=0.0,
83
+ status=DERStatus.UNKNOWN,
84
+ metadata={"note": "DNP3 stub — replace with real implementation"},
85
+ )
86
+
87
+ async def write_command(self, command: ControlCommand) -> bool:
88
+ """Send a CROB or analog output to the outstation.
89
+
90
+ Returns ``False`` until the full DNP3 master stack is integrated.
91
+ """
92
+ self.logger.warning(
93
+ "DNP3 write_command() called — stub implementation. "
94
+ "Command %s was NOT dispatched.",
95
+ command.command_id,
96
+ )
97
+ return False
@@ -0,0 +1,99 @@
1
+ """
2
+ IEC 61850 MMS protocol adapter for GridOS (stub).
3
+
4
+ IEC 61850 is the international standard for communication in electrical
5
+ substations and DER integration. A full implementation requires an
6
+ MMS (Manufacturing Message Specification) client library such as
7
+ ``libiec61850`` with Python bindings.
8
+
9
+ TODO:
10
+ - Integrate with ``libiec61850`` or equivalent MMS client.
11
+ - Implement GOOSE subscriber for fast tripping signals.
12
+ - Map logical nodes (MMXU, DGEN, DSTO) to GridOS models.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from datetime import datetime
19
+ from typing import Any
20
+
21
+ from gridos.adapters.base import BaseAdapter
22
+ from gridos.models.common import ControlCommand, DERStatus, DERTelemetry
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class IEC61850Adapter(BaseAdapter):
28
+ """IEC 61850 MMS client adapter (stub implementation).
29
+
30
+ Parameters
31
+ ----------
32
+ device_id:
33
+ Unique device identifier.
34
+ config:
35
+ Expected keys: ``ied_host``, ``ied_port`` (default 102),
36
+ ``logical_device``, ``logical_node``.
37
+ """
38
+
39
+ def __init__(self, device_id: str, config: dict[str, Any] | None = None) -> None:
40
+ super().__init__(device_id, config)
41
+ self._ied_host: str = self.config.get("ied_host", "localhost")
42
+ self._ied_port: int = int(self.config.get("ied_port", 102))
43
+ self._logical_device: str = self.config.get("logical_device", "LD0")
44
+ self._logical_node: str = self.config.get("logical_node", "MMXU1")
45
+
46
+ @property
47
+ def protocol_name(self) -> str:
48
+ return "iec61850"
49
+
50
+ async def connect(self) -> None:
51
+ """Establish an MMS association with the IED.
52
+
53
+ Raises
54
+ ------
55
+ NotImplementedError
56
+ Full IEC 61850 support is not yet implemented.
57
+ """
58
+ self.logger.warning(
59
+ "IEC 61850 adapter connect() called — stub implementation. "
60
+ "Integrate libiec61850 for production use."
61
+ )
62
+ self._connected = True
63
+
64
+ async def disconnect(self) -> None:
65
+ """Release the MMS association."""
66
+ self._connected = False
67
+ self.logger.info("IEC 61850 adapter disconnected (stub)")
68
+
69
+ async def read_telemetry(self) -> DERTelemetry:
70
+ """Read data attributes from the configured logical node.
71
+
72
+ Returns a placeholder telemetry object until the full MMS client
73
+ is integrated.
74
+ """
75
+ self.logger.debug("IEC 61850 read_telemetry() — returning placeholder data")
76
+ return DERTelemetry(
77
+ device_id=self.device_id,
78
+ timestamp=datetime.utcnow(),
79
+ power_kw=0.0,
80
+ reactive_power_kvar=0.0,
81
+ status=DERStatus.UNKNOWN,
82
+ metadata={
83
+ "note": "IEC 61850 stub — replace with real MMS implementation",
84
+ "logical_device": self._logical_device,
85
+ "logical_node": self._logical_node,
86
+ },
87
+ )
88
+
89
+ async def write_command(self, command: ControlCommand) -> bool:
90
+ """Write a control value to the IED via MMS.
91
+
92
+ Returns ``False`` until the full MMS client is integrated.
93
+ """
94
+ self.logger.warning(
95
+ "IEC 61850 write_command() called — stub implementation. "
96
+ "Command %s was NOT dispatched.",
97
+ command.command_id,
98
+ )
99
+ return False
@@ -0,0 +1,177 @@
1
+ """
2
+ Modbus TCP/RTU protocol adapter for GridOS.
3
+
4
+ Uses ``pymodbus`` (v3.6+) with its native async transport to read holding
5
+ registers and write coils / registers on DER devices. Register mappings
6
+ are configurable per device via the ``config`` dictionary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+ from gridos.adapters.base import BaseAdapter
16
+ from gridos.models.common import ControlCommand, DERStatus, DERTelemetry
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Default Modbus register map — override via adapter config
21
+ DEFAULT_REGISTER_MAP: dict[str, dict[str, Any]] = {
22
+ "power_kw": {"address": 0, "count": 2, "scale": 0.1, "unit": "kW"},
23
+ "reactive_power_kvar": {"address": 2, "count": 2, "scale": 0.1, "unit": "kVAR"},
24
+ "voltage_v": {"address": 4, "count": 2, "scale": 0.1, "unit": "V"},
25
+ "current_a": {"address": 6, "count": 2, "scale": 0.01, "unit": "A"},
26
+ "frequency_hz": {"address": 8, "count": 1, "scale": 0.01, "unit": "Hz"},
27
+ "soc_percent": {"address": 9, "count": 1, "scale": 0.1, "unit": "%"},
28
+ "temperature_c": {"address": 10, "count": 1, "scale": 0.1, "unit": "°C"},
29
+ }
30
+
31
+
32
+ def _decode_registers(registers: list[int], count: int, scale: float) -> float:
33
+ """Decode one or two 16-bit registers into a scaled float."""
34
+ if count == 2 and len(registers) >= 2:
35
+ raw = (registers[0] << 16) | registers[1]
36
+ elif registers:
37
+ raw = registers[0]
38
+ else:
39
+ raw = 0
40
+ return raw * scale
41
+
42
+
43
+ class ModbusAdapter(BaseAdapter):
44
+ """Async Modbus TCP adapter.
45
+
46
+ Parameters
47
+ ----------
48
+ device_id:
49
+ Unique device identifier.
50
+ config:
51
+ Must contain ``host`` and ``port``. Optionally ``unit_id``
52
+ (default 1) and ``register_map`` (overrides defaults).
53
+ """
54
+
55
+ def __init__(self, device_id: str, config: dict[str, Any] | None = None) -> None:
56
+ super().__init__(device_id, config)
57
+ self._host: str = self.config.get("host", "localhost")
58
+ self._port: int = int(self.config.get("port", 502))
59
+ self._unit_id: int = int(self.config.get("unit_id", 1))
60
+ self._register_map: dict[str, dict[str, Any]] = self.config.get(
61
+ "register_map", DEFAULT_REGISTER_MAP
62
+ )
63
+ self._client: Any = None
64
+
65
+ @property
66
+ def protocol_name(self) -> str:
67
+ return "modbus_tcp"
68
+
69
+ async def connect(self) -> None:
70
+ """Establish an async Modbus TCP connection."""
71
+ try:
72
+ from pymodbus.client import AsyncModbusTcpClient
73
+
74
+ self._client = AsyncModbusTcpClient(
75
+ host=self._host,
76
+ port=self._port,
77
+ timeout=5,
78
+ )
79
+ connected = await self._client.connect()
80
+ if not connected:
81
+ raise ConnectionError(
82
+ f"Modbus TCP connection to {self._host}:{self._port} failed"
83
+ )
84
+ self._connected = True
85
+ self.logger.info(
86
+ "Modbus connected",
87
+ extra={
88
+ "device_id": self.device_id,
89
+ "host": self._host,
90
+ "port": self._port,
91
+ },
92
+ )
93
+ except ImportError as err:
94
+ raise ImportError(
95
+ "pymodbus is required for the Modbus adapter. "
96
+ "Install it with: pip install pymodbus"
97
+ ) from err
98
+ except Exception as exc:
99
+ self._connected = False
100
+ self.logger.error("Modbus connection error: %s", exc)
101
+ raise ConnectionError(str(exc)) from exc
102
+
103
+ async def disconnect(self) -> None:
104
+ """Close the Modbus TCP connection."""
105
+ if self._client is not None:
106
+ self._client.close()
107
+ self._connected = False
108
+ self.logger.info("Modbus disconnected", extra={"device_id": self.device_id})
109
+
110
+ async def read_telemetry(self) -> DERTelemetry:
111
+ """Read registers and return a ``DERTelemetry`` snapshot."""
112
+ if not self._connected or self._client is None:
113
+ raise OSError("Modbus adapter is not connected")
114
+
115
+ values: dict[str, float] = {}
116
+ for field_name, mapping in self._register_map.items():
117
+ try:
118
+ result = await self._client.read_holding_registers(
119
+ address=mapping["address"],
120
+ count=mapping["count"],
121
+ slave=self._unit_id,
122
+ )
123
+ if result.isError():
124
+ self.logger.warning(
125
+ "Modbus read error for %s: %s", field_name, result
126
+ )
127
+ values[field_name] = 0.0
128
+ else:
129
+ values[field_name] = _decode_registers(
130
+ result.registers, mapping["count"], mapping["scale"]
131
+ )
132
+ except Exception as exc:
133
+ self.logger.warning("Modbus read exception for %s: %s", field_name, exc)
134
+ values[field_name] = 0.0
135
+
136
+ return DERTelemetry(
137
+ device_id=self.device_id,
138
+ timestamp=datetime.utcnow(),
139
+ power_kw=values.get("power_kw", 0.0),
140
+ reactive_power_kvar=values.get("reactive_power_kvar", 0.0),
141
+ voltage_v=values.get("voltage_v"),
142
+ current_a=values.get("current_a"),
143
+ frequency_hz=values.get("frequency_hz"),
144
+ soc_percent=values.get("soc_percent"),
145
+ temperature_c=values.get("temperature_c"),
146
+ status=DERStatus.ONLINE,
147
+ )
148
+
149
+ async def write_command(self, command: ControlCommand) -> bool:
150
+ """Write a power setpoint to the device via Modbus registers."""
151
+ if not self._connected or self._client is None:
152
+ raise OSError("Modbus adapter is not connected")
153
+
154
+ try:
155
+ if command.setpoint_kw is not None:
156
+ # Write setpoint to register 100 (configurable)
157
+ target_register = int(self.config.get("setpoint_register", 100))
158
+ raw_value = int(command.setpoint_kw / 0.1) # scale factor
159
+ result = await self._client.write_register(
160
+ address=target_register,
161
+ value=raw_value,
162
+ slave=self._unit_id,
163
+ )
164
+ if result.isError():
165
+ self.logger.error("Modbus write error: %s", result)
166
+ return False
167
+ self.logger.info(
168
+ "Modbus command sent: setpoint_kw=%.2f to register %d",
169
+ command.setpoint_kw,
170
+ target_register,
171
+ )
172
+ return True
173
+ self.logger.warning("No setpoint_kw in command; nothing written")
174
+ return False
175
+ except Exception as exc:
176
+ self.logger.error("Modbus write exception: %s", exc)
177
+ return False
@@ -0,0 +1,178 @@
1
+ """
2
+ MQTT protocol adapter for GridOS.
3
+
4
+ Subscribes to device telemetry topics and publishes control commands using
5
+ ``paho-mqtt`` with an asyncio wrapper. Messages are expected in JSON format
6
+ matching the :class:`DERTelemetry` schema.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ from collections.abc import Callable
15
+ from datetime import datetime
16
+ from typing import Any
17
+
18
+ from gridos.adapters.base import BaseAdapter
19
+ from gridos.models.common import ControlCommand, DERStatus, DERTelemetry
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class MQTTAdapter(BaseAdapter):
25
+ """Async MQTT adapter using ``paho-mqtt``.
26
+
27
+ Parameters
28
+ ----------
29
+ device_id:
30
+ Unique device identifier.
31
+ config:
32
+ Must contain ``broker_host``. Optional keys: ``broker_port``
33
+ (default 1883), ``username``, ``password``, ``topic_prefix``
34
+ (default ``gridos/``), ``qos`` (default 1).
35
+ """
36
+
37
+ def __init__(self, device_id: str, config: dict[str, Any] | None = None) -> None:
38
+ super().__init__(device_id, config)
39
+ self._broker_host: str = self.config.get("broker_host", "localhost")
40
+ self._broker_port: int = int(self.config.get("broker_port", 1883))
41
+ self._username: str = self.config.get("username", "")
42
+ self._password: str = self.config.get("password", "")
43
+ self._topic_prefix: str = self.config.get("topic_prefix", "gridos/")
44
+ self._qos: int = int(self.config.get("qos", 1))
45
+ self._client: Any = None
46
+ self._latest_telemetry: DERTelemetry | None = None
47
+ self._loop: asyncio.AbstractEventLoop | None = None
48
+ self._on_telemetry_callback: Callable[[DERTelemetry], None] | None = None
49
+
50
+ @property
51
+ def protocol_name(self) -> str:
52
+ return "mqtt"
53
+
54
+ @property
55
+ def telemetry_topic(self) -> str:
56
+ """MQTT topic for this device's telemetry."""
57
+ return f"{self._topic_prefix}telemetry/{self.device_id}"
58
+
59
+ @property
60
+ def command_topic(self) -> str:
61
+ """MQTT topic for this device's commands."""
62
+ return f"{self._topic_prefix}command/{self.device_id}"
63
+
64
+ def set_telemetry_callback(self, callback: Callable[[DERTelemetry], None]) -> None:
65
+ """Register a callback invoked on every new telemetry message."""
66
+ self._on_telemetry_callback = callback
67
+
68
+ async def connect(self) -> None:
69
+ """Connect to the MQTT broker and subscribe to the telemetry topic."""
70
+ try:
71
+ import paho.mqtt.client as mqtt
72
+
73
+ self._loop = asyncio.get_running_loop()
74
+ self._client = mqtt.Client(
75
+ client_id=f"gridos-{self.device_id}",
76
+ protocol=mqtt.MQTTv5,
77
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
78
+ )
79
+
80
+ if self._username:
81
+ self._client.username_pw_set(self._username, self._password)
82
+
83
+ self._client.on_connect = self._on_connect
84
+ self._client.on_message = self._on_message
85
+
86
+ self._client.connect_async(
87
+ self._broker_host, self._broker_port, keepalive=60
88
+ )
89
+ self._client.loop_start()
90
+ self._connected = True
91
+ self.logger.info(
92
+ "MQTT connecting to %s:%d", self._broker_host, self._broker_port
93
+ )
94
+ except ImportError as err:
95
+ raise ImportError(
96
+ "paho-mqtt is required for the MQTT adapter. "
97
+ "Install it with: pip install paho-mqtt"
98
+ ) from err
99
+ except Exception as exc:
100
+ self._connected = False
101
+ self.logger.error("MQTT connection error: %s", exc)
102
+ raise ConnectionError(str(exc)) from exc
103
+
104
+ async def disconnect(self) -> None:
105
+ """Disconnect from the MQTT broker."""
106
+ if self._client is not None:
107
+ self._client.loop_stop()
108
+ self._client.disconnect()
109
+ self._connected = False
110
+ self.logger.info("MQTT disconnected", extra={"device_id": self.device_id})
111
+
112
+ # ── Paho callbacks (run in the network thread) ───────────────────────
113
+
114
+ def _on_connect(
115
+ self, client: Any, userdata: Any, flags: Any, rc: Any, properties: Any = None
116
+ ) -> None:
117
+ """Subscribe to the device telemetry topic on successful connect."""
118
+ client.subscribe(self.telemetry_topic, qos=self._qos)
119
+ self.logger.info("MQTT subscribed to %s", self.telemetry_topic)
120
+
121
+ def _on_message(self, client: Any, userdata: Any, msg: Any) -> None:
122
+ """Parse incoming JSON telemetry and store it."""
123
+ try:
124
+ payload = json.loads(msg.payload.decode("utf-8"))
125
+ telemetry = DERTelemetry(
126
+ device_id=self.device_id,
127
+ timestamp=datetime.fromisoformat(
128
+ payload.get("timestamp", datetime.utcnow().isoformat())
129
+ ),
130
+ power_kw=float(payload.get("power_kw", 0.0)),
131
+ reactive_power_kvar=float(payload.get("reactive_power_kvar", 0.0)),
132
+ voltage_v=payload.get("voltage_v"),
133
+ current_a=payload.get("current_a"),
134
+ frequency_hz=payload.get("frequency_hz"),
135
+ soc_percent=payload.get("soc_percent"),
136
+ temperature_c=payload.get("temperature_c"),
137
+ status=DERStatus(payload.get("status", "online")),
138
+ )
139
+ self._latest_telemetry = telemetry
140
+
141
+ if self._on_telemetry_callback is not None:
142
+ self._on_telemetry_callback(telemetry)
143
+
144
+ except Exception as exc:
145
+ self.logger.warning("Failed to parse MQTT message: %s", exc)
146
+
147
+ # ── Public API ───────────────────────────────────────────────────────
148
+
149
+ async def read_telemetry(self) -> DERTelemetry:
150
+ """Return the most recently received telemetry.
151
+
152
+ If no message has been received yet, returns a zero-valued snapshot.
153
+ """
154
+ if self._latest_telemetry is not None:
155
+ return self._latest_telemetry
156
+
157
+ self.logger.debug("No MQTT telemetry received yet for %s", self.device_id)
158
+ return DERTelemetry(
159
+ device_id=self.device_id,
160
+ timestamp=datetime.utcnow(),
161
+ power_kw=0.0,
162
+ status=DERStatus.UNKNOWN,
163
+ )
164
+
165
+ async def write_command(self, command: ControlCommand) -> bool:
166
+ """Publish a control command to the device's command topic."""
167
+ if self._client is None:
168
+ raise OSError("MQTT adapter is not connected")
169
+
170
+ try:
171
+ payload = command.model_dump_json()
172
+ info = self._client.publish(self.command_topic, payload, qos=self._qos)
173
+ info.wait_for_publish(timeout=5)
174
+ self.logger.info("MQTT command published to %s", self.command_topic)
175
+ return True
176
+ except Exception as exc:
177
+ self.logger.error("MQTT publish error: %s", exc)
178
+ return False