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 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"
@@ -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)