cta2045 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.
- cta2045/__init__.py +20 -0
- cta2045/app/__init__.py +654 -0
- cta2045/codec/__init__.py +218 -0
- cta2045/enums.py +324 -0
- cta2045/link/__init__.py +9 -0
- cta2045/ucm/__init__.py +109 -0
- cta2045-0.1.0.dist-info/METADATA +167 -0
- cta2045-0.1.0.dist-info/RECORD +11 -0
- cta2045-0.1.0.dist-info/WHEEL +5 -0
- cta2045-0.1.0.dist-info/licenses/LICENSE +21 -0
- cta2045-0.1.0.dist-info/top_level.txt +1 -0
cta2045/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""cta2045 — a CTA-2045 protocol library.
|
|
2
|
+
|
|
3
|
+
CTA-2045 (ANSI/CTA-2045-B) is the modular communications interface between a
|
|
4
|
+
Smart Grid Device (SGD — e.g. a water heater) and a Universal Communications
|
|
5
|
+
Module (UCM). This library encodes and decodes CTA-2045 messages.
|
|
6
|
+
|
|
7
|
+
Package layout:
|
|
8
|
+
|
|
9
|
+
- ``cta2045.enums`` — on-the-wire enumerations (the canonical vocabulary).
|
|
10
|
+
- ``cta2045.app`` — application-layer messages (Basic DR, Intermediate DR,
|
|
11
|
+
commodity/energy, GetInformation).
|
|
12
|
+
- ``cta2045.codec`` — ASCII-hex ↔ bytes and message framing.
|
|
13
|
+
- ``cta2045.link`` — reserved for a future CTA-2045 RS485 link layer.
|
|
14
|
+
- ``cta2045.ucm`` — abstract UCM interface; vendor-proprietary bindings live
|
|
15
|
+
in separate (non-open) packages.
|
|
16
|
+
|
|
17
|
+
Status: scaffold. The implementation is tracked in the project's issue tracker.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
cta2045/app/__init__.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""CTA-2045 application layer — message encode/decode.
|
|
2
|
+
|
|
3
|
+
Structured Python objects ↔ CTA-2045 message bytes:
|
|
4
|
+
|
|
5
|
+
- **Basic DR** (CTA-2045-B § 10) — shed, end-shed, load-up, critical-peak,
|
|
6
|
+
grid-emergency, power-level, operational-state.
|
|
7
|
+
- **Intermediate DR** (CTA-2045.3 § 11) — commodity/energy reads
|
|
8
|
+
(:class:`CommodityReadReply`), device information
|
|
9
|
+
(:class:`GetInformationReply`), thermostat responses.
|
|
10
|
+
|
|
11
|
+
Decoding starts at :func:`decode` / :func:`decode_all` (bytes) or
|
|
12
|
+
:func:`decode_hex` (ASCII-hex). Encoding helpers (:func:`shed`, :func:`end`,
|
|
13
|
+
:func:`critical_peak`, :func:`grid_emergency`, :func:`load_up`,
|
|
14
|
+
:func:`power_level`) return :class:`BasicDR` objects; call ``.to_bytes()`` for
|
|
15
|
+
the wire frame.
|
|
16
|
+
|
|
17
|
+
Everything here is pure bytes ⇄ objects: no transport, no I/O.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from enum import Enum
|
|
23
|
+
from functools import reduce
|
|
24
|
+
from operator import or_
|
|
25
|
+
from struct import calcsize, pack, unpack_from
|
|
26
|
+
from typing import Union
|
|
27
|
+
|
|
28
|
+
from cta2045.codec import CodecError, Duration, Frame, build_frame, hex_to_bytes, parse_frame
|
|
29
|
+
from cta2045.enums import (
|
|
30
|
+
AdvancedLoadUpUnits,
|
|
31
|
+
BasicDRCategory,
|
|
32
|
+
Capability,
|
|
33
|
+
CommodityCode,
|
|
34
|
+
DeviceType,
|
|
35
|
+
IntermediateDRCategory,
|
|
36
|
+
IntermediateDRResponseCode,
|
|
37
|
+
MessageType,
|
|
38
|
+
OperationalState,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
# message types
|
|
43
|
+
"BasicDR",
|
|
44
|
+
"IntermediateDR",
|
|
45
|
+
"CommodityReport",
|
|
46
|
+
"CommodityReadReply",
|
|
47
|
+
"GetInformationReply",
|
|
48
|
+
"ThermostatResponse",
|
|
49
|
+
"AdvancedLoadUp",
|
|
50
|
+
"UnknownMessage",
|
|
51
|
+
"Message",
|
|
52
|
+
# decoding
|
|
53
|
+
"decode",
|
|
54
|
+
"decode_all",
|
|
55
|
+
"decode_hex",
|
|
56
|
+
"decode_frame",
|
|
57
|
+
# encoding
|
|
58
|
+
"shed",
|
|
59
|
+
"end",
|
|
60
|
+
"critical_peak",
|
|
61
|
+
"grid_emergency",
|
|
62
|
+
"load_up",
|
|
63
|
+
"power_level",
|
|
64
|
+
"advanced_load_up",
|
|
65
|
+
"get_advanced_load_up",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# Message type identifiers (CTA-2045-B § 6.1.1).
|
|
69
|
+
_BASIC_DR = (0x08, 0x01)
|
|
70
|
+
_INTERMEDIATE_DR = (0x08, 0x02)
|
|
71
|
+
|
|
72
|
+
# Basic DR categories whose opcode2 carries an event-duration byte (§ 10.1.2).
|
|
73
|
+
_DURATION_CATEGORIES = frozenset(
|
|
74
|
+
{
|
|
75
|
+
BasicDRCategory.Shed,
|
|
76
|
+
BasicDRCategory.Critical_Peak_Event,
|
|
77
|
+
BasicDRCategory.Grid_Emergency,
|
|
78
|
+
BasicDRCategory.Load_Up,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _enum_or_none(enum_cls: type[Enum], value: int) -> Enum | None:
|
|
84
|
+
"""Return the enum member for ``value``, or ``None`` if not a defined value."""
|
|
85
|
+
try:
|
|
86
|
+
return enum_cls(value)
|
|
87
|
+
except ValueError:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _enum_or_raw(enum_cls: type[Enum], value: int) -> Enum | int:
|
|
92
|
+
"""Return the enum member for ``value``, or the raw ``int`` if undefined.
|
|
93
|
+
|
|
94
|
+
Lenient decoding: an unrecognized on-the-wire value (vendor extension or a
|
|
95
|
+
newer spec revision) is preserved as its raw integer rather than raising.
|
|
96
|
+
"""
|
|
97
|
+
member = _enum_or_none(enum_cls, value)
|
|
98
|
+
return value if member is None else member
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _enum_value(value: Enum | int) -> int:
|
|
102
|
+
"""The on-the-wire int for an enum member or a raw int (for encoding)."""
|
|
103
|
+
return value.value if isinstance(value, Enum) else value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------- #
|
|
107
|
+
# Basic DR — CTA-2045-B § 10
|
|
108
|
+
# --------------------------------------------------------------------------- #
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class BasicDR:
|
|
113
|
+
"""A Basic DR application message (CTA-2045-B § 10, Table 10-1).
|
|
114
|
+
|
|
115
|
+
Two payload bytes: ``category`` (opcode1) and ``opcode2``. ``opcode2`` is
|
|
116
|
+
overloaded by category — an event-duration byte for the event commands, an
|
|
117
|
+
:class:`~cta2045.enums.OperationalState` for a state-query response, a
|
|
118
|
+
power-level percentage for a power-level request, etc.
|
|
119
|
+
|
|
120
|
+
Decoding an unrecognized opcode1 yields ``category=None`` with the raw byte
|
|
121
|
+
preserved in :attr:`opcode1` (lenient decoding).
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
category: BasicDRCategory | None
|
|
125
|
+
opcode2: int = 0x00
|
|
126
|
+
opcode1: int | None = None
|
|
127
|
+
|
|
128
|
+
def __post_init__(self) -> None:
|
|
129
|
+
if self.opcode1 is None and self.category is not None:
|
|
130
|
+
self.opcode1 = self.category.value
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_payload(cls, payload: bytes) -> "BasicDR":
|
|
134
|
+
if len(payload) < 2:
|
|
135
|
+
raise CodecError(f"Basic DR payload too short: {len(payload)} bytes")
|
|
136
|
+
opcode1 = payload[0]
|
|
137
|
+
return cls(_enum_or_none(BasicDRCategory, opcode1), payload[1], opcode1)
|
|
138
|
+
|
|
139
|
+
def to_payload(self) -> bytes:
|
|
140
|
+
if self.opcode1 is None:
|
|
141
|
+
raise CodecError("BasicDR has no opcode1 to encode")
|
|
142
|
+
return bytes([self.opcode1, self.opcode2])
|
|
143
|
+
|
|
144
|
+
def to_bytes(self) -> bytes:
|
|
145
|
+
return build_frame(*_BASIC_DR, self.to_payload())
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def operational_state(self) -> OperationalState | int | None:
|
|
149
|
+
"""The operational state, for a State_Query_Response (else ``None``).
|
|
150
|
+
|
|
151
|
+
An unrecognized state value is returned as a raw ``int``.
|
|
152
|
+
"""
|
|
153
|
+
if self.category is BasicDRCategory.State_Query_Response:
|
|
154
|
+
return _enum_or_raw(OperationalState, self.opcode2)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def duration(self) -> Duration | None:
|
|
159
|
+
"""The event duration, for event commands (else ``None``)."""
|
|
160
|
+
if self.category in _DURATION_CATEGORIES:
|
|
161
|
+
return Duration.from_byte(self.opcode2)
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --------------------------------------------------------------------------- #
|
|
166
|
+
# Intermediate DR — CTA-2045.3 § 11
|
|
167
|
+
# --------------------------------------------------------------------------- #
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class CommodityReport:
|
|
172
|
+
"""One commodity record from a Get CommodityRead reply (§ 11.3.1.2).
|
|
173
|
+
|
|
174
|
+
``instantaneous`` and ``cumulative`` are 48-bit unsigned values, each
|
|
175
|
+
carried as three big-endian 16-bit words.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
code: CommodityCode | int
|
|
179
|
+
instantaneous: int
|
|
180
|
+
cumulative: int
|
|
181
|
+
|
|
182
|
+
_FMT = ">BHHHHHH"
|
|
183
|
+
SIZE = calcsize(_FMT) # 13 bytes
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_bytes(cls, data: bytes, offset: int = 0) -> tuple["CommodityReport", int]:
|
|
187
|
+
code, m3a, m2a, m1a, m3b, m2b, m1b = unpack_from(cls._FMT, data, offset)
|
|
188
|
+
instantaneous = (m3a << 32) | (m2a << 16) | m1a
|
|
189
|
+
cumulative = (m3b << 32) | (m2b << 16) | m1b
|
|
190
|
+
return cls(_enum_or_raw(CommodityCode, code), instantaneous, cumulative), offset + cls.SIZE
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class CommodityReadReply:
|
|
195
|
+
"""Get CommodityRead reply (CTA-2045.3 § 11.3.1.2).
|
|
196
|
+
|
|
197
|
+
Body layout: ``opcode2`` (``0x80`` = reply), ``response_code``, then a
|
|
198
|
+
sequence of 13-byte :class:`CommodityReport` records.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
opcode2: int
|
|
202
|
+
response_code: IntermediateDRResponseCode | int | None = None
|
|
203
|
+
reports: list[CommodityReport] = field(default_factory=list)
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def from_body(cls, body: bytes) -> "CommodityReadReply":
|
|
207
|
+
if len(body) < 2:
|
|
208
|
+
raise CodecError("Consumption/Production body too short")
|
|
209
|
+
opcode2, rc = body[0], body[1]
|
|
210
|
+
reports: list[CommodityReport] = []
|
|
211
|
+
response_code = None
|
|
212
|
+
if opcode2 == 0x80: # Get_CommodityRead_Reply
|
|
213
|
+
response_code = _enum_or_raw(IntermediateDRResponseCode, rc)
|
|
214
|
+
offset = 2
|
|
215
|
+
while offset + CommodityReport.SIZE <= len(body):
|
|
216
|
+
report, offset = CommodityReport.from_bytes(body, offset)
|
|
217
|
+
reports.append(report)
|
|
218
|
+
return cls(opcode2, response_code, reports)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class ThermostatResponse:
|
|
223
|
+
"""Thermostat command response (CTA-2045.3 § 3.2.1)."""
|
|
224
|
+
|
|
225
|
+
opcode2: int
|
|
226
|
+
response_code: IntermediateDRResponseCode | int | None = None
|
|
227
|
+
type: str | None = None
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def from_body(cls, body: bytes) -> "ThermostatResponse":
|
|
231
|
+
if len(body) < 2:
|
|
232
|
+
raise CodecError("Thermostat body too short")
|
|
233
|
+
opcode2, rc = body[0], body[1]
|
|
234
|
+
type_ = "Set_Hold_Response" if opcode2 == 0x83 else None
|
|
235
|
+
return cls(opcode2, _enum_or_raw(IntermediateDRResponseCode, rc), type_)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class AdvancedLoadUp:
|
|
240
|
+
"""Advanced Load Up message (CTA-2045-B § 11.6, Intermediate DR 0x0C).
|
|
241
|
+
|
|
242
|
+
Models all four variants, distinguished by ``opcode2`` (``0x00`` request,
|
|
243
|
+
``0x80`` reply) and which fields are present:
|
|
244
|
+
|
|
245
|
+
- **Get request** — ``opcode2=0x00``, no further fields.
|
|
246
|
+
- **Set request** — ``opcode2=0x00`` + ``duration_minutes`` + ``value`` +
|
|
247
|
+
``units`` (+ optional trailing fields).
|
|
248
|
+
- **Set reply** — ``opcode2=0x80`` + ``response_code`` only.
|
|
249
|
+
- **Get reply** — ``opcode2=0x80`` + ``response_code`` + the measurement
|
|
250
|
+
block (+ optional trailing fields).
|
|
251
|
+
|
|
252
|
+
``duration_minutes`` is a plain 2-byte minute count (§ 11.6) — *not* the
|
|
253
|
+
Basic-DR ``2·n²``-seconds duration byte. ``value`` is counted in ``units``
|
|
254
|
+
(see :class:`~cta2045.enums.AdvancedLoadUpUnits`); energy ≈
|
|
255
|
+
``value × units.watt_hours``.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
opcode2: int = 0x00
|
|
259
|
+
response_code: IntermediateDRResponseCode | int | None = None
|
|
260
|
+
duration_minutes: int | None = None
|
|
261
|
+
value: int | None = None
|
|
262
|
+
units: AdvancedLoadUpUnits | int | None = None
|
|
263
|
+
# Optional trailing fields (§ 11.6.2 / 11.6.3).
|
|
264
|
+
efficiency: int | None = None
|
|
265
|
+
event_id: int | None = None
|
|
266
|
+
start_time: int | None = None
|
|
267
|
+
start_randomization: int | None = None
|
|
268
|
+
end_randomization: int | None = None
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def from_body(cls, body: bytes) -> "AdvancedLoadUp":
|
|
272
|
+
if not body:
|
|
273
|
+
raise CodecError("empty Advanced Load Up body")
|
|
274
|
+
obj = cls(opcode2=body[0])
|
|
275
|
+
pos = 1
|
|
276
|
+
|
|
277
|
+
def take(fmt: str) -> int | None:
|
|
278
|
+
nonlocal pos
|
|
279
|
+
size = calcsize(">" + fmt)
|
|
280
|
+
if pos + size > len(body):
|
|
281
|
+
return None
|
|
282
|
+
(value,) = unpack_from(">" + fmt, body, pos)
|
|
283
|
+
pos += size
|
|
284
|
+
return value
|
|
285
|
+
|
|
286
|
+
# Reply carries a response code before the measurement block.
|
|
287
|
+
if obj.opcode2 == 0x80:
|
|
288
|
+
if (v := take("B")) is None:
|
|
289
|
+
return obj
|
|
290
|
+
obj.response_code = _enum_or_raw(IntermediateDRResponseCode, v)
|
|
291
|
+
|
|
292
|
+
# Measurement block (a Set request always has it; a Get reply has it
|
|
293
|
+
# only while an event is active).
|
|
294
|
+
if (v := take("H")) is None:
|
|
295
|
+
return obj
|
|
296
|
+
obj.duration_minutes = v
|
|
297
|
+
if (v := take("H")) is None:
|
|
298
|
+
return obj
|
|
299
|
+
obj.value = v
|
|
300
|
+
if (v := take("B")) is None:
|
|
301
|
+
return obj
|
|
302
|
+
obj.units = _enum_or_raw(AdvancedLoadUpUnits, v)
|
|
303
|
+
|
|
304
|
+
# Optional trailing block.
|
|
305
|
+
if (v := take("B")) is None:
|
|
306
|
+
return obj
|
|
307
|
+
obj.efficiency = v
|
|
308
|
+
if (v := take("L")) is None:
|
|
309
|
+
return obj
|
|
310
|
+
obj.event_id = v
|
|
311
|
+
if (v := take("L")) is None:
|
|
312
|
+
return obj
|
|
313
|
+
obj.start_time = v
|
|
314
|
+
if (v := take("B")) is None:
|
|
315
|
+
return obj
|
|
316
|
+
obj.start_randomization = v
|
|
317
|
+
if (v := take("B")) is None:
|
|
318
|
+
return obj
|
|
319
|
+
obj.end_randomization = v
|
|
320
|
+
return obj
|
|
321
|
+
|
|
322
|
+
def to_payload(self) -> bytes:
|
|
323
|
+
out = bytearray([IntermediateDRCategory.Advanced_Load_Up.value, self.opcode2])
|
|
324
|
+
if self.opcode2 == 0x80 and self.response_code is not None:
|
|
325
|
+
out.append(_enum_value(self.response_code))
|
|
326
|
+
if self.duration_minutes is not None:
|
|
327
|
+
out += pack(">H", self.duration_minutes)
|
|
328
|
+
out += pack(">H", self.value if self.value is not None else 0)
|
|
329
|
+
if self.units is None:
|
|
330
|
+
raise CodecError("Advanced Load Up with a duration requires units")
|
|
331
|
+
out.append(_enum_value(self.units))
|
|
332
|
+
# Optional trailing fields, in order; stop at the first absent one.
|
|
333
|
+
for fmt, attr in (
|
|
334
|
+
(">B", "efficiency"),
|
|
335
|
+
(">L", "event_id"),
|
|
336
|
+
(">L", "start_time"),
|
|
337
|
+
(">B", "start_randomization"),
|
|
338
|
+
(">B", "end_randomization"),
|
|
339
|
+
):
|
|
340
|
+
field_value = getattr(self, attr)
|
|
341
|
+
if field_value is None:
|
|
342
|
+
break
|
|
343
|
+
out += pack(fmt, field_value)
|
|
344
|
+
return bytes(out)
|
|
345
|
+
|
|
346
|
+
def to_bytes(self) -> bytes:
|
|
347
|
+
return build_frame(*_INTERMEDIATE_DR, self.to_payload())
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# Field decoders for the GetInformation reply (CTA-2045-B § 11.1.1.2).
|
|
351
|
+
_CAP_MASK = reduce(or_, (c.value for c in Capability))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _ascii(s: bytes) -> str:
|
|
355
|
+
# Fixed-width string fields are NUL- or space-padded; truncate at the first
|
|
356
|
+
# NUL, then strip surrounding whitespace.
|
|
357
|
+
return s.split(b"\x00", 1)[0].decode("ascii", errors="replace").strip()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _version(s: bytes) -> str:
|
|
361
|
+
v = _ascii(s)
|
|
362
|
+
return v[0] if v else ""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _serial(s: bytes) -> str:
|
|
366
|
+
v = _ascii(s)
|
|
367
|
+
return "Unknown" if re.fullmatch("0+", v) else v
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _capabilities(raw: int) -> Capability:
|
|
371
|
+
# Mask to defined bits; reserved/future bits are ignored.
|
|
372
|
+
return Capability(raw & _CAP_MASK)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@dataclass
|
|
376
|
+
class GetInformationReply:
|
|
377
|
+
"""GetInformation reply (CTA-2045-B § 11.1.1.2).
|
|
378
|
+
|
|
379
|
+
Mandatory fields are always present; the trailing model/serial/firmware
|
|
380
|
+
fields are optional and only set if the message carries them (``None``
|
|
381
|
+
otherwise).
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
opcode2: int | None = None
|
|
385
|
+
response_code: IntermediateDRResponseCode | int | None = None
|
|
386
|
+
cta2045_version: str | None = None
|
|
387
|
+
vendor_id: int | None = None
|
|
388
|
+
device_type: DeviceType | int | None = None
|
|
389
|
+
device_revision: int | None = None
|
|
390
|
+
capabilities: Capability | None = None
|
|
391
|
+
reserved: int | None = None
|
|
392
|
+
model: str | None = None
|
|
393
|
+
serial: str | None = None
|
|
394
|
+
fw_year: int | None = None
|
|
395
|
+
fw_month: int | None = None
|
|
396
|
+
fw_day: int | None = None
|
|
397
|
+
fw_major: int | None = None
|
|
398
|
+
fw_minor: int | None = None
|
|
399
|
+
|
|
400
|
+
@classmethod
|
|
401
|
+
def from_body(cls, body: bytes) -> "GetInformationReply":
|
|
402
|
+
"""Decode the reply, field by field.
|
|
403
|
+
|
|
404
|
+
The trailing model/serial/firmware fields are optional with no length
|
|
405
|
+
marker, so each read is guarded: as soon as the body runs out, the
|
|
406
|
+
remaining fields stay ``None``.
|
|
407
|
+
"""
|
|
408
|
+
obj = cls()
|
|
409
|
+
pos = 0
|
|
410
|
+
|
|
411
|
+
def take(fmt: str) -> int | bytes | None:
|
|
412
|
+
nonlocal pos
|
|
413
|
+
size = calcsize(">" + fmt)
|
|
414
|
+
if pos + size > len(body):
|
|
415
|
+
return None
|
|
416
|
+
(value,) = unpack_from(">" + fmt, body, pos)
|
|
417
|
+
pos += size
|
|
418
|
+
return value
|
|
419
|
+
|
|
420
|
+
# Mandatory block.
|
|
421
|
+
if (v := take("B")) is None:
|
|
422
|
+
return obj
|
|
423
|
+
obj.opcode2 = v
|
|
424
|
+
if (v := take("B")) is None:
|
|
425
|
+
return obj
|
|
426
|
+
obj.response_code = _enum_or_raw(IntermediateDRResponseCode, v)
|
|
427
|
+
if (v := take("2s")) is None:
|
|
428
|
+
return obj
|
|
429
|
+
obj.cta2045_version = _version(v)
|
|
430
|
+
if (v := take("H")) is None:
|
|
431
|
+
return obj
|
|
432
|
+
obj.vendor_id = v
|
|
433
|
+
if (v := take("H")) is None:
|
|
434
|
+
return obj
|
|
435
|
+
obj.device_type = _enum_or_raw(DeviceType, v)
|
|
436
|
+
if (v := take("H")) is None:
|
|
437
|
+
return obj
|
|
438
|
+
obj.device_revision = v
|
|
439
|
+
if (v := take("L")) is None:
|
|
440
|
+
return obj
|
|
441
|
+
obj.capabilities = _capabilities(v)
|
|
442
|
+
if (v := take("B")) is None:
|
|
443
|
+
return obj
|
|
444
|
+
obj.reserved = v
|
|
445
|
+
|
|
446
|
+
# Optional trailing block.
|
|
447
|
+
if (v := take("15s")) is None:
|
|
448
|
+
return obj
|
|
449
|
+
obj.model = _ascii(v)
|
|
450
|
+
if (v := take("15s")) is None:
|
|
451
|
+
return obj
|
|
452
|
+
obj.serial = _serial(v)
|
|
453
|
+
if (v := take("B")) is None:
|
|
454
|
+
return obj
|
|
455
|
+
obj.fw_year = v
|
|
456
|
+
if (v := take("B")) is None:
|
|
457
|
+
return obj
|
|
458
|
+
obj.fw_month = v
|
|
459
|
+
if (v := take("B")) is None:
|
|
460
|
+
return obj
|
|
461
|
+
obj.fw_day = v
|
|
462
|
+
if (v := take("B")) is None:
|
|
463
|
+
return obj
|
|
464
|
+
obj.fw_major = v
|
|
465
|
+
if (v := take("B")) is None:
|
|
466
|
+
return obj
|
|
467
|
+
obj.fw_minor = v
|
|
468
|
+
return obj
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@dataclass
|
|
472
|
+
class IntermediateDR:
|
|
473
|
+
"""An Intermediate DR application message (CTA-2045.3 § 11).
|
|
474
|
+
|
|
475
|
+
``category`` (opcode1) selects the body type. Recognized categories decode
|
|
476
|
+
to a structured ``body`` (:class:`CommodityReadReply`,
|
|
477
|
+
:class:`GetInformationReply`, :class:`ThermostatResponse`); an unrecognized
|
|
478
|
+
opcode1 yields ``category=None`` and the raw body bytes (lenient decoding).
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
category: IntermediateDRCategory | None
|
|
482
|
+
body: Union["CommodityReadReply", "GetInformationReply", "ThermostatResponse", "AdvancedLoadUp", bytes]
|
|
483
|
+
opcode1: int | None = None
|
|
484
|
+
|
|
485
|
+
def __post_init__(self) -> None:
|
|
486
|
+
if self.opcode1 is None and self.category is not None:
|
|
487
|
+
self.opcode1 = self.category.value
|
|
488
|
+
|
|
489
|
+
@classmethod
|
|
490
|
+
def from_payload(cls, payload: bytes) -> "IntermediateDR":
|
|
491
|
+
if not payload:
|
|
492
|
+
raise CodecError("empty Intermediate DR payload")
|
|
493
|
+
opcode1 = payload[0]
|
|
494
|
+
category = _enum_or_none(IntermediateDRCategory, opcode1)
|
|
495
|
+
body = payload[1:]
|
|
496
|
+
if category is IntermediateDRCategory.Consumption_Production:
|
|
497
|
+
return cls(category, CommodityReadReply.from_body(body), opcode1)
|
|
498
|
+
if category is IntermediateDRCategory.Device_Mode:
|
|
499
|
+
return cls(category, GetInformationReply.from_body(body), opcode1)
|
|
500
|
+
if category is IntermediateDRCategory.Thermostat:
|
|
501
|
+
return cls(category, ThermostatResponse.from_body(body), opcode1)
|
|
502
|
+
if category is IntermediateDRCategory.Advanced_Load_Up:
|
|
503
|
+
return cls(category, AdvancedLoadUp.from_body(body), opcode1)
|
|
504
|
+
return cls(category, bytes(body), opcode1)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@dataclass
|
|
508
|
+
class UnknownMessage:
|
|
509
|
+
"""A frame whose message type this library does not (yet) decode."""
|
|
510
|
+
|
|
511
|
+
frame: Frame
|
|
512
|
+
message_type: MessageType
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
Message = Union[BasicDR, IntermediateDR, UnknownMessage]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# --------------------------------------------------------------------------- #
|
|
519
|
+
# Decoding
|
|
520
|
+
# --------------------------------------------------------------------------- #
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def decode_frame(frame: Frame) -> Message:
|
|
524
|
+
"""Decode an already-parsed :class:`~cta2045.codec.Frame` into a message."""
|
|
525
|
+
mtype = MessageType.classify(frame.type_msb, frame.type_lsb)
|
|
526
|
+
if mtype is MessageType.Basic_DR:
|
|
527
|
+
return BasicDR.from_payload(frame.payload)
|
|
528
|
+
if mtype is MessageType.Intermediate_DR:
|
|
529
|
+
return IntermediateDR.from_payload(frame.payload)
|
|
530
|
+
return UnknownMessage(frame, mtype)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def decode(data: bytes, offset: int = 0) -> tuple[Message, int]:
|
|
534
|
+
"""Decode one message from ``data`` at ``offset``.
|
|
535
|
+
|
|
536
|
+
Returns the message and the offset just past it (for streaming).
|
|
537
|
+
"""
|
|
538
|
+
frame, offset = parse_frame(data, offset)
|
|
539
|
+
return decode_frame(frame), offset
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def decode_all(data: bytes, offset: int = 0) -> list[Message]:
|
|
543
|
+
"""Decode every back-to-back message in ``data``.
|
|
544
|
+
|
|
545
|
+
.. note::
|
|
546
|
+
This expects a clean buffer of concatenated frames. Transport-specific
|
|
547
|
+
quirks (framing junk, leading padding) are the binding's job to strip
|
|
548
|
+
before calling this.
|
|
549
|
+
"""
|
|
550
|
+
messages: list[Message] = []
|
|
551
|
+
while offset < len(data):
|
|
552
|
+
message, offset = decode(data, offset)
|
|
553
|
+
messages.append(message)
|
|
554
|
+
return messages
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def decode_hex(s: str) -> list[Message]:
|
|
558
|
+
"""Decode every message in an ASCII-hex string."""
|
|
559
|
+
return decode_all(hex_to_bytes(s))
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# --------------------------------------------------------------------------- #
|
|
563
|
+
# Encoding — Basic DR commands (CTA-2045-B § 10)
|
|
564
|
+
# --------------------------------------------------------------------------- #
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _coerce_duration(value: Duration | float | None) -> Duration:
|
|
568
|
+
"""Normalize a duration argument to a :class:`~cta2045.codec.Duration`.
|
|
569
|
+
|
|
570
|
+
``None`` → Unknown; a bare number is interpreted as **minutes** (DR event
|
|
571
|
+
durations are expressed in minutes, not seconds); a
|
|
572
|
+
:class:`~cta2045.codec.Duration` passes through (use ``Duration.of_seconds``
|
|
573
|
+
or ``Duration.too_long()`` for other forms).
|
|
574
|
+
"""
|
|
575
|
+
if value is None:
|
|
576
|
+
return Duration.unknown()
|
|
577
|
+
if isinstance(value, Duration):
|
|
578
|
+
return value
|
|
579
|
+
if isinstance(value, (int, float)):
|
|
580
|
+
return Duration.of_minutes(value)
|
|
581
|
+
raise TypeError(f"duration must be Duration, minutes (number), or None; got {type(value).__name__}")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def shed(duration: Duration | float | None = None) -> BasicDR:
|
|
585
|
+
"""Shed (curtail load) for ``duration`` minutes (CTA-2045-B § 10, opcode 0x01)."""
|
|
586
|
+
return BasicDR(BasicDRCategory.Shed, _coerce_duration(duration).to_byte())
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def end() -> BasicDR:
|
|
590
|
+
"""End the current shed event (CTA-2045-B § 10, opcode 0x02)."""
|
|
591
|
+
return BasicDR(BasicDRCategory.End_Shed, 0x00)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def critical_peak(duration: Duration | float | None = None) -> BasicDR:
|
|
595
|
+
"""Critical peak event for ``duration`` minutes (CTA-2045-B § 10, opcode 0x0A)."""
|
|
596
|
+
return BasicDR(BasicDRCategory.Critical_Peak_Event, _coerce_duration(duration).to_byte())
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def grid_emergency(duration: Duration | float | None = None) -> BasicDR:
|
|
600
|
+
"""Grid emergency event for ``duration`` minutes (CTA-2045-B § 10, opcode 0x0B)."""
|
|
601
|
+
return BasicDR(BasicDRCategory.Grid_Emergency, _coerce_duration(duration).to_byte())
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def load_up(duration: Duration | float | None = None) -> BasicDR:
|
|
605
|
+
"""Load up (store energy) for ``duration`` minutes (CTA-2045-B § 10, opcode 0x17)."""
|
|
606
|
+
return BasicDR(BasicDRCategory.Load_Up, _coerce_duration(duration).to_byte())
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def power_level(percent: int) -> BasicDR:
|
|
610
|
+
"""Request a power level of ``percent`` (0–100) (CTA-2045-B § 10, opcode 0x06)."""
|
|
611
|
+
if not 0 <= percent <= 100:
|
|
612
|
+
raise ValueError(f"power level percent must be 0-100, got {percent}")
|
|
613
|
+
return BasicDR(BasicDRCategory.Power_Level_Request, percent)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# --------------------------------------------------------------------------- #
|
|
617
|
+
# Encoding — Advanced Load Up (CTA-2045-B § 11.6, Intermediate DR 0x0C)
|
|
618
|
+
# --------------------------------------------------------------------------- #
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def advanced_load_up(
|
|
622
|
+
duration_minutes: int,
|
|
623
|
+
value: int,
|
|
624
|
+
units: AdvancedLoadUpUnits | int,
|
|
625
|
+
*,
|
|
626
|
+
efficiency: int | None = None,
|
|
627
|
+
event_id: int | None = None,
|
|
628
|
+
start_time: int | None = None,
|
|
629
|
+
start_randomization: int | None = None,
|
|
630
|
+
end_randomization: int | None = None,
|
|
631
|
+
) -> AdvancedLoadUp:
|
|
632
|
+
"""SetAdvancedLoadUp request, UCM→SGD (CTA-2045-B § 11.6.3).
|
|
633
|
+
|
|
634
|
+
Asks the SGD to store ``value × units`` watt-hours of extra energy over
|
|
635
|
+
``duration_minutes``. The trailing fields are optional; per the wire format
|
|
636
|
+
they are positional, so include them only as a prefix (e.g. ``event_id``
|
|
637
|
+
requires ``efficiency``).
|
|
638
|
+
"""
|
|
639
|
+
return AdvancedLoadUp(
|
|
640
|
+
opcode2=0x00,
|
|
641
|
+
duration_minutes=duration_minutes,
|
|
642
|
+
value=value,
|
|
643
|
+
units=units,
|
|
644
|
+
efficiency=efficiency,
|
|
645
|
+
event_id=event_id,
|
|
646
|
+
start_time=start_time,
|
|
647
|
+
start_randomization=start_randomization,
|
|
648
|
+
end_randomization=end_randomization,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def get_advanced_load_up() -> AdvancedLoadUp:
|
|
653
|
+
"""GetAdvancedLoadUp request, UCM→SGD (CTA-2045-B § 11.6.1)."""
|
|
654
|
+
return AdvancedLoadUp(opcode2=0x00)
|