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.
- gridos/__init__.py +10 -0
- gridos/adapters/__init__.py +12 -0
- gridos/adapters/base.py +116 -0
- gridos/adapters/dnp3.py +97 -0
- gridos/adapters/iec61850.py +99 -0
- gridos/adapters/modbus.py +177 -0
- gridos/adapters/mqtt.py +178 -0
- gridos/adapters/opcua.py +139 -0
- gridos/api/__init__.py +6 -0
- gridos/api/dependencies.py +101 -0
- gridos/api/routes/__init__.py +3 -0
- gridos/api/routes/control.py +68 -0
- gridos/api/routes/devices.py +85 -0
- gridos/api/routes/forecast.py +77 -0
- gridos/api/routes/optimization.py +126 -0
- gridos/api/routes/telemetry.py +122 -0
- gridos/api/websocket_manager.py +120 -0
- gridos/config.py +170 -0
- gridos/digital_twin/__init__.py +10 -0
- gridos/digital_twin/engine.py +353 -0
- gridos/digital_twin/ml/__init__.py +11 -0
- gridos/digital_twin/ml/anomaly_detector.py +187 -0
- gridos/digital_twin/ml/forecaster.py +332 -0
- gridos/digital_twin/ml/trainer.py +190 -0
- gridos/digital_twin/models/__init__.py +25 -0
- gridos/digital_twin/models/battery.py +121 -0
- gridos/digital_twin/models/bus.py +80 -0
- gridos/digital_twin/models/ev_charger.py +136 -0
- gridos/digital_twin/models/line.py +107 -0
- gridos/digital_twin/models/load.py +94 -0
- gridos/digital_twin/models/pv.py +104 -0
- gridos/digital_twin/models/transformer.py +106 -0
- gridos/edge/__init__.py +6 -0
- gridos/edge/local_cache.py +234 -0
- gridos/edge/sync.py +114 -0
- gridos/main.py +130 -0
- gridos/models/__init__.py +30 -0
- gridos/models/common.py +318 -0
- gridos/models/iec61850.py +200 -0
- gridos/optimization/__init__.py +10 -0
- gridos/optimization/dispatch.py +149 -0
- gridos/optimization/scheduler.py +315 -0
- gridos/security/__init__.py +6 -0
- gridos/security/auth.py +222 -0
- gridos/storage/__init__.py +10 -0
- gridos/storage/base.py +127 -0
- gridos/storage/influxdb.py +201 -0
- gridos/storage/timescaledb.py +221 -0
- gridos/utils/__init__.py +5 -0
- gridos/utils/helpers.py +149 -0
- gridos/utils/logging.py +117 -0
- gridos/utils/metrics.py +176 -0
- gridos-0.1.0.dist-info/METADATA +270 -0
- gridos-0.1.0.dist-info/RECORD +57 -0
- gridos-0.1.0.dist-info/WHEEL +5 -0
- gridos-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"]
|
gridos/adapters/base.py
ADDED
|
@@ -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
|
+
)
|
gridos/adapters/dnp3.py
ADDED
|
@@ -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
|
gridos/adapters/mqtt.py
ADDED
|
@@ -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
|