egauge-python 0.9.8__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 (44) hide show
  1. egauge/ctid/__init__.py +7 -0
  2. egauge/ctid/bit_stuffer.py +65 -0
  3. egauge/ctid/ctid.py +967 -0
  4. egauge/ctid/encoder.py +436 -0
  5. egauge/ctid/intel_hex_encoder.py +98 -0
  6. egauge/ctid/waveform.py +299 -0
  7. egauge/examples/data/test-ctid-decoder.raw +0 -0
  8. egauge/examples/test_capture.py +77 -0
  9. egauge/examples/test_common.py +26 -0
  10. egauge/examples/test_ctid.py +89 -0
  11. egauge/examples/test_ctid_decoder.py +93 -0
  12. egauge/examples/test_local.py +201 -0
  13. egauge/examples/test_register.py +104 -0
  14. egauge/loggers.py +72 -0
  15. egauge/pyside/__init__.py +0 -0
  16. egauge/pyside/ansi2html.py +112 -0
  17. egauge/pyside/terminal.py +295 -0
  18. egauge/webapi/__init__.py +34 -0
  19. egauge/webapi/auth.py +364 -0
  20. egauge/webapi/cloud/__init__.py +30 -0
  21. egauge/webapi/cloud/credentials.py +86 -0
  22. egauge/webapi/cloud/credentials_dialog.py +58 -0
  23. egauge/webapi/cloud/gui/credentials_dialog.py +100 -0
  24. egauge/webapi/cloud/serial_number.py +276 -0
  25. egauge/webapi/device/__init__.py +38 -0
  26. egauge/webapi/device/capture.py +453 -0
  27. egauge/webapi/device/ctid_info.py +553 -0
  28. egauge/webapi/device/device.py +349 -0
  29. egauge/webapi/device/local.py +268 -0
  30. egauge/webapi/device/physical_quantity.py +439 -0
  31. egauge/webapi/device/physical_units.py +473 -0
  32. egauge/webapi/device/register.py +338 -0
  33. egauge/webapi/device/register_row.py +145 -0
  34. egauge/webapi/device/register_type.py +851 -0
  35. egauge/webapi/device/slop.py +334 -0
  36. egauge/webapi/device/virtual_register.py +353 -0
  37. egauge/webapi/error.py +34 -0
  38. egauge/webapi/json_api.py +332 -0
  39. egauge_python-0.9.8.dist-info/METADATA +148 -0
  40. egauge_python-0.9.8.dist-info/RECORD +44 -0
  41. egauge_python-0.9.8.dist-info/WHEEL +5 -0
  42. egauge_python-0.9.8.dist-info/entry_points.txt +2 -0
  43. egauge_python-0.9.8.dist-info/licenses/LICENSE +22 -0
  44. egauge_python-0.9.8.dist-info/top_level.txt +1 -0
egauge/ctid/ctid.py ADDED
@@ -0,0 +1,967 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2016-2017, 2019-2025 eGauge Systems LLC
4
+ # 4805 Sterling Dr, Suite 1
5
+ # Boulder, CO 80301
6
+ # voice: 720-545-9767
7
+ # email: davidm@egauge.net
8
+ #
9
+ # All rights reserved.
10
+ #
11
+ # MIT License
12
+ #
13
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ # of this software and associated documentation files (the "Software"), to deal
15
+ # in the Software without restriction, including without limitation the rights
16
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ # copies of the Software, and to permit persons to whom the Software is
18
+ # furnished to do so, subject to the following conditions:
19
+ #
20
+ # The above copyright notice and this permission notice shall be included in
21
+ # all copies or substantial portions of the Software.
22
+ #
23
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
29
+ # THE SOFTWARE.
30
+ #
31
+ import math
32
+ import struct
33
+
34
+ import crcmod
35
+ from deprecated import deprecated
36
+
37
+ from .bit_stuffer import BitStuffer
38
+
39
+ CTID_VERSION = 5 # latest version of CTid Specification this code supports
40
+ START_SYM = 0xFF
41
+
42
+ SENSOR_TYPE_AC = 0x0 # AC-only sensor
43
+ SENSOR_TYPE_DC = 0x1 # DC-capable sensor
44
+ SENSOR_TYPE_RC = 0x2 # differential-output sensor ("Rogowski Coil"...)
45
+ SENSOR_TYPE_VOLTAGE = 0x3 # DEPRECATED -- use SENSOR_TYPE_LINEAR instead
46
+ SENSOR_TYPE_LINEAR = 0x3
47
+ SENSOR_TYPE_TEMP_LINEAR = 0x4
48
+ SENSOR_TYPE_TEMP_NTC = 0x5
49
+ SENSOR_TYPE_PULSE = 0x6
50
+
51
+ # List of registered manufacturer IDs:
52
+ _MFG_ID = {
53
+ 0x0000: ["eGauge Systems LLC", "eGauge"],
54
+ 0x0001: ["Magnelab, Inc.", "Magnelab"],
55
+ 0x0002: ["Continental Control Systems LLC", "CCS"],
56
+ 0x0003: ["J&D Electronics", "J&D"],
57
+ 0x0004: ["Accuenergy, Inc.", "Accuenergy"],
58
+ }
59
+
60
+ # For backwards-compatibility:
61
+ MFG_ID = {}
62
+ for key, value in _MFG_ID.items():
63
+ MFG_ID[key] = value[0]
64
+
65
+ SENSOR_TYPE_NAME = ["AC", "DC", "RC", "linear", "temp", "NTC", "pulse"]
66
+
67
+ SENSOR_UNITS = (
68
+ ("V", "voltage"),
69
+ ("A", "current"),
70
+ ("Ah", "charge"),
71
+ ("W", "power"),
72
+ ("var", "reactive power"),
73
+ ("VA", "apparent power"),
74
+ ("Hz", "frequency"),
75
+ ("Ω", "resistance"),
76
+ ("°", "angle"),
77
+ ("°C", "temperature"),
78
+ ("%RH", "humidity"),
79
+ ("Pa", "pressure"),
80
+ ("g", "mass"),
81
+ ("m/s", "velocity"),
82
+ ("g/s", "mass flow"),
83
+ ("m^3/s", "volumetric flow"),
84
+ ("%", "percentage"),
85
+ ("ppm", "parts-per-million"),
86
+ ("", "air quality"),
87
+ ("", "number"),
88
+ )
89
+
90
+ crc8_rohc = crcmod.predefined.mkCrcFun("crc-8-rohc")
91
+ crc16_modbus = crcmod.predefined.mkCrcFun("modbus")
92
+
93
+ # Max. mantissa value for float12 format:
94
+ F12_MAX_MANTISSA = 0x1FF
95
+
96
+
97
+ class CRCError(Exception):
98
+ """Exception raised in case of a CRC error. Property `expected`
99
+ holds the expected CRC value, `got` holds the received CRC.
100
+
101
+ """
102
+
103
+ def __init__(self, expected, got):
104
+ super().__init__()
105
+ self.expected = expected
106
+ self.got = got
107
+
108
+ def __str__(self):
109
+ return f"Expected CRC {self.expected:#x} but got {self.got:#x}"
110
+
111
+
112
+ class Error(Exception):
113
+ """This is raised for errors other than CRCErrors."""
114
+
115
+
116
+ def get_mfg_id_for_name(name: str) -> int:
117
+ """Get the manufacturer id for the given name.
118
+
119
+ Raises Error if the passed string is not unique or if the passed
120
+ string is neither a recognized name or a valid number.
121
+
122
+ Required arguments:
123
+
124
+ name -- The name of the manufacturer for which to return the id.
125
+ This may be a prefix of the full name, as long as the prefix
126
+ is unique. Alternatively, name may also be a decimal integer
127
+ string or a hexdecimal string with prefix 0x.
128
+
129
+ """
130
+ match = None
131
+ for key, value in MFG_ID.items():
132
+ if len(value) >= len(name):
133
+ if value.startswith(name):
134
+ if match is None:
135
+ match = key
136
+ else:
137
+ raise Error(
138
+ "Name is ambiguous. Please specify more letters."
139
+ )
140
+ if match is not None:
141
+ return match
142
+
143
+ try:
144
+ return int(name, 0)
145
+ except ValueError as e:
146
+ raise Error("Neither a name nor a valid number.") from e
147
+
148
+
149
+ def get_sensor_type_id(name_or_number: str) -> int:
150
+ """Convert a string to a sensor type id.
151
+
152
+ Raises Error if the passed string is neither a recognized sensor
153
+ type name nor a valid number string.
154
+
155
+ Required arguments:
156
+
157
+ name_or_number -- The string to convert to a sensor type id. The
158
+ string may be one of the names listed in
159
+ `ctid.SENSOR_TYPE_NAME` (the comparison ignores case) or it
160
+ may be a number representing the integer id of the sensor
161
+ type. The number string should be in decimal, unless it
162
+ starts with "0x", in which case it must be in hexadecimal.
163
+
164
+ """
165
+ lc_name = name_or_number.lower()
166
+ for i, name in enumerate(SENSOR_TYPE_NAME):
167
+ if name.lower() == lc_name:
168
+ return i
169
+
170
+ try:
171
+ return int(name_or_number, 0)
172
+ except ValueError as e:
173
+ raise Error("Neither a sensor type name nor a valid number.") from e
174
+
175
+
176
+ def get_sensor_type_name(sensor_type: int) -> str | None:
177
+ if sensor_type < 0 or sensor_type >= len(SENSOR_TYPE_NAME):
178
+ return None
179
+ return SENSOR_TYPE_NAME[sensor_type]
180
+
181
+
182
+ def get_sensor_unit(unit_code: int) -> str:
183
+ if 0 <= unit_code < len(SENSOR_UNITS):
184
+ return SENSOR_UNITS[unit_code][0]
185
+ return "?"
186
+
187
+
188
+ def get_sensor_unit_desc(unit_code: int) -> str:
189
+ if 0 <= unit_code < len(SENSOR_UNITS):
190
+ return SENSOR_UNITS[unit_code][1]
191
+ return "?"
192
+
193
+
194
+ def mfg_name(ident: int) -> str | None:
195
+ """Get the manufacturer name for a given manufacturer id.
196
+
197
+ Returns None if the specified id is unknown.
198
+
199
+ Required arguments:
200
+
201
+ ident -- The id of the manufacturer whose name to return.
202
+
203
+ """
204
+ if ident in _MFG_ID:
205
+ return _MFG_ID[ident][0]
206
+ return None
207
+
208
+
209
+ def mfg_short_name(ident: int) -> str | None:
210
+ """Get the short version of a manufacturer's name for a given
211
+ manufacturer id.
212
+
213
+ Returns None if the specified id is unknown.
214
+
215
+ Required arguments:
216
+
217
+ ident -- The id of the manufacturer whose short name to return.
218
+
219
+ """
220
+ if ident in _MFG_ID:
221
+ return _MFG_ID[ident][1]
222
+ return None
223
+
224
+
225
+ @deprecated(version="0.0", reason="Use CTid.mfg_name() instead")
226
+ def get_mfg_id(ident: int):
227
+ return mfg_name(ident)
228
+
229
+
230
+ def s10(val: int) -> int:
231
+ """Convert 10 bit unsigned integer to a signed 9-bit integer.
232
+
233
+ Required arguments:
234
+
235
+ val -- The integer to convert.
236
+
237
+ """
238
+ if not 0 <= val < 1024:
239
+ raise ValueError
240
+ if val < 512:
241
+ return val
242
+ return val - 1024
243
+
244
+
245
+ def fix(
246
+ val: float,
247
+ num_bits: int,
248
+ is_signed: bool,
249
+ name: str,
250
+ unit: str | None,
251
+ scale: float,
252
+ ):
253
+ f = round(val / scale)
254
+ limit = 1 << num_bits
255
+ min_val = -(limit / 2) if is_signed else 0
256
+ max_val = +(limit / 2 - 1) if is_signed else limit - 1
257
+ if f < min_val or f > max_val:
258
+ unit_str = (" " + unit) if unit is not None else ""
259
+ prec = -int(math.log10(scale))
260
+ val_str = f"{val:.{prec}f}"
261
+ min_str = f"{min_val * scale:.{prec}f}"
262
+ max_str = f"{max_val * scale:.{prec}f}"
263
+ raise Error(
264
+ f"{name} {val_str}{unit_str} outside of range "
265
+ f"from {min_str} to {max_str}{unit_str}."
266
+ )
267
+ return f
268
+
269
+
270
+ def unfix(val: int, scale: float) -> float:
271
+ return val * scale
272
+
273
+
274
+ def float12(val: float, name: str, unit: str) -> int:
275
+ """Encode a floating point value in the range from 0 to 1e7 to the
276
+ 12-bit float format.
277
+
278
+ Required arguments:
279
+
280
+ val -- The floating point value to convert.
281
+
282
+ name -- The name of the CTid parameters being converted.
283
+
284
+ unit -- The physical unit of the CTid parameter being converted.
285
+ """
286
+ if val < 0 or val > 1e7:
287
+ raise Error(
288
+ f"{name} {val}{unit} outside of range from 0 to 10,000,000{unit}"
289
+ )
290
+ exp = math.log10(val) if val >= 1.0 else 0
291
+ exp = int(math.ceil(exp))
292
+ mantissa = int(round((val / math.pow(10, exp)) * F12_MAX_MANTISSA))
293
+ return (mantissa << 3) | exp
294
+
295
+
296
+ def unfloat12(val: int) -> float:
297
+ """Decode a 12-bit integer to a floating point value in the range
298
+ from 0 to 1e7.
299
+
300
+ Required arguments:
301
+
302
+ val -- The integer value to decode.
303
+
304
+ """
305
+ mantissa = (val >> 3) & F12_MAX_MANTISSA
306
+ exp = val & 0x7
307
+ return mantissa / float(F12_MAX_MANTISSA) * math.pow(10, exp)
308
+
309
+
310
+ def decimal16(val: float, name: str, unit: str) -> int:
311
+ """Encode a decimal value with a 11-bit signed mantissa and a
312
+ power-of-ten exponent from -16..15 as a 16-bit integer.
313
+ This covers the range from -1024e15 to +1023e15.
314
+
315
+ Required arguments:
316
+
317
+ val -- The floating point value to convert.
318
+
319
+ name -- The name of the CTid parameters being converted.
320
+
321
+ unit -- The physical unit of the CTid parameter being converted.
322
+
323
+ """
324
+ mantissa = 0
325
+ exp = 0
326
+
327
+ if val != 0.0:
328
+ # Determine exponent that gives us an mantissa in the range
329
+ # from -1024..-103 or 103..1023:
330
+ while exp > -16 and -102.4 <= val <= 102.3:
331
+ val *= 10.0
332
+ exp -= 1
333
+ mantissa = round(val)
334
+ while mantissa < -1024 or mantissa > 1023:
335
+ mantissa /= 10.0
336
+ exp += 1
337
+ if exp > 15:
338
+ raise Error(
339
+ f"{name} {val}{unit} outside of range "
340
+ f"from -1024e15..1023e15{unit}"
341
+ )
342
+ mantissa = int(mantissa)
343
+ return ((mantissa & 0x7FF) << 5) | (exp & 0x1F)
344
+
345
+
346
+ def undecimal16(val: int) -> float:
347
+ """Decode 16-bit integer to a floating number number.
348
+
349
+ Required arguments:
350
+
351
+ val -- The integer value to decode.
352
+
353
+ """
354
+ mantissa = (val >> 5) & 0x7FF
355
+ if mantissa >= 0x400:
356
+ mantissa -= 0x800
357
+ exp = val & 0x1F
358
+ if exp >= 0x10:
359
+ exp -= 0x20
360
+ return mantissa * math.pow(10, exp)
361
+
362
+
363
+ def check_table_data(raw_data: bytes):
364
+ version = struct.unpack_from(">B", raw_data)[0]
365
+ if version <= 1:
366
+ length = 30
367
+ if len(raw_data) < length + 1: # excluding start symbol
368
+ raise Error("Insufficient table data")
369
+ exp_crc = crc8_rohc(raw_data[:length])
370
+ got_crc = struct.unpack_from(">B", raw_data[length:])[0]
371
+ if exp_crc != got_crc:
372
+ raise CRCError(exp_crc, got_crc)
373
+ elif version <= 3:
374
+ length = 33
375
+ if len(raw_data) < length + 2: # excluding start symbol
376
+ raise Error("Insufficient table data")
377
+ exp_crc = crc16_modbus(raw_data[:length])
378
+ got_crc = struct.unpack_from(">H", raw_data[length:])[0]
379
+ if exp_crc != got_crc:
380
+ raise CRCError(exp_crc, got_crc)
381
+ else:
382
+ length = 43
383
+ if len(raw_data) < length + 2: # excluding start symbol
384
+ raise Error("Insufficient table data")
385
+ exp_crc = crc16_modbus(raw_data[:length])
386
+ got_crc = struct.unpack_from(">H", raw_data[length:])[0]
387
+ if exp_crc != got_crc:
388
+ raise CRCError(exp_crc, got_crc)
389
+ return raw_data[:length]
390
+
391
+
392
+ class Table:
393
+ """Objects of this class can be used to hold the contents of a
394
+ CTid table in a Python-native format. Use method Table.encode()
395
+ to convert the table to a sequence of bytes, encoded as per CTid
396
+ specification or decode a sequence of bytes with method
397
+ Table.decode().
398
+
399
+ """
400
+
401
+ def __init__(self, data: bytes | None = None):
402
+ if data is not None:
403
+ data = check_table_data(data)
404
+
405
+ self.encoding = False
406
+ self.version = CTID_VERSION
407
+ self.mfg_id = 0
408
+ self.model = ""
409
+ self.serial_number: int = 0
410
+ self.sensor_type = SENSOR_TYPE_AC
411
+ self.r_source: float = 0
412
+ self.r_load: float = 0 # undefined
413
+
414
+ self.size: float = 0
415
+ self.rated_current: float = 0
416
+ self.voltage_at_rated_current: float = 0
417
+ self.phase_at_rated_current: float = 0
418
+ self.voltage_temp_coeff: float = 0
419
+ self.phase_temp_coeff: float = 0
420
+ self.cal_table = {1.5: [0, 0], 5: [0, 0], 15: [0, 0], 50: [0, 0]}
421
+ self.bias_voltage: float = 0
422
+ self.reserved = 0
423
+ self.mfg_info = 0
424
+ self.scale: float = 0
425
+ self.offset: float = 0
426
+ self.delay: float = 0
427
+ self.sensor_unit: int = 0
428
+ self.threshold: float = 0
429
+ self.hysteresis: float = 0
430
+ self.debounce_time: int = 0
431
+ self.edge_mask: int = 0
432
+ self.ntc_a: float = 0
433
+ self.ntc_b: float = 0
434
+ self.ntc_c: float = 0
435
+ self.ntc_m: float = 0
436
+ self.ntc_n: float = 0
437
+ self.ntc_k: float = 0
438
+
439
+ # internal data:
440
+ self._raw_data = b""
441
+ self._raw_offset = 0
442
+
443
+ if data is not None:
444
+ self.decode(data)
445
+
446
+ def __str__(self) -> str:
447
+ mfg_id_str = get_mfg_id(self.mfg_id)
448
+ if mfg_id_str is not None:
449
+ mfg_id_str = '"' + mfg_id_str + '"'
450
+ else:
451
+ mfg_id_str = f"{self.mfg_id:#04x}"
452
+
453
+ sensor_type_str = get_sensor_type_name(self.sensor_type)
454
+ if sensor_type_str is not None:
455
+ sensor_type_str = '"' + sensor_type_str + '"'
456
+ else:
457
+ sensor_type_str = f"{self.sensor_type:#1x}"
458
+
459
+ ret = (
460
+ f"CTid version={self.version:d}, sensor_type={sensor_type_str}, "
461
+ f'mfg_id={mfg_id_str}, model="{self.model}", '
462
+ )
463
+ if (
464
+ self.sensor_type >= SENSOR_TYPE_AC
465
+ and self.sensor_type <= SENSOR_TYPE_RC
466
+ ):
467
+ rows = []
468
+ for lvl, val in self.cal_table.items():
469
+ rows.append(f"{repr(lvl)}%: {val[0]:+.2f}%/{val[1]:+.2f}°")
470
+ ret += (
471
+ f"size={self.size:.1f}mm, "
472
+ f"serial={self.serial_number}, "
473
+ f"current={self.rated_current:.1f}A, "
474
+ f"voltage={self.voltage_at_rated_current:.6f}V, "
475
+ f"bias={1e3 * self.bias_voltage:.3f}mV, "
476
+ f"phase={self.phase_at_rated_current:.2f}°, "
477
+ f"voltage_temp_coeff={self.voltage_temp_coeff:.0f}ppm/°C, "
478
+ f"phase_temp_coeff={self.phase_temp_coeff:.1f}m°/°C, "
479
+ f"reserved={self.reserved:#02x}, "
480
+ f"mfg_info={self.mfg_info:#02x}, "
481
+ f"Rs={self.r_source:g}, "
482
+ f"Rl={self.r_load:g}, "
483
+ "cal={" + ", ".join(rows) + "}"
484
+ )
485
+ elif self.sensor_type == SENSOR_TYPE_LINEAR:
486
+ unit = get_sensor_unit(self.sensor_unit)
487
+ ret += (
488
+ f"scale={self.scale:g}{unit}/V "
489
+ f"offset={self.offset:g}{unit} "
490
+ f"delay={self.delay:g}μs"
491
+ )
492
+ elif self.sensor_type == SENSOR_TYPE_TEMP_LINEAR:
493
+ ret += f"scale={self.scale:g}°C/V offset={self.offset:g}°C"
494
+ elif self.sensor_type == SENSOR_TYPE_TEMP_NTC:
495
+ ret += (
496
+ f"A={self.ntc_a:g} "
497
+ f"B={self.ntc_b:g} "
498
+ f"C={self.ntc_c:g} "
499
+ f"M={self.ntc_m:g} "
500
+ f"N={self.ntc_n:g} "
501
+ f"K={self.ntc_k:g}"
502
+ )
503
+ elif self.sensor_type == SENSOR_TYPE_PULSE:
504
+ ret += (
505
+ f"threshold={self.threshold:g}±{self.hysteresis:g}V "
506
+ f"debounce={self.debounce_time:d}ms "
507
+ f"edge={self.edge_mask:#x}"
508
+ )
509
+ else:
510
+ pass
511
+ ret += "}"
512
+ return ret
513
+
514
+ def m_u2(self, name: str, scale: float = 1, unit: str | None = None):
515
+ fmt = ">B"
516
+ if self.encoding:
517
+ val = fix(getattr(self, name), 2, False, name, unit, scale)
518
+ self._raw_data += struct.pack(fmt, val << 6)
519
+ else:
520
+ val = struct.unpack_from(fmt, self._raw_data, self._raw_offset)[0]
521
+ self._raw_offset += struct.calcsize(fmt)
522
+ setattr(self, name, unfix(val >> 6, scale))
523
+
524
+ def m_u8(self, name: str, scale: float = 1, unit: str | None = None):
525
+ fmt = ">B"
526
+ if self.encoding:
527
+ val = fix(getattr(self, name), 8, False, name, unit, scale)
528
+ self._raw_data += struct.pack(fmt, val)
529
+ else:
530
+ val = struct.unpack_from(fmt, self._raw_data, self._raw_offset)[0]
531
+ self._raw_offset += struct.calcsize(fmt)
532
+ setattr(self, name, unfix(val, scale))
533
+
534
+ def m_s8(
535
+ self,
536
+ name: str,
537
+ scale: float = 1,
538
+ unit: str | None = None,
539
+ idx1: float | None = None,
540
+ idx2: int | None = None,
541
+ ):
542
+ fmt = ">b"
543
+ if self.encoding:
544
+ if idx1 is not None and idx2 is not None:
545
+ val = fix(
546
+ getattr(self, name)[idx1][idx2], 8, True, name, unit, scale
547
+ )
548
+ else:
549
+ val = fix(getattr(self, name), 8, True, name, unit, scale)
550
+ self._raw_data += struct.pack(fmt, val)
551
+ else:
552
+ val = struct.unpack_from(fmt, self._raw_data, self._raw_offset)[0]
553
+ self._raw_offset += struct.calcsize(fmt)
554
+ if idx1 is not None and idx2 is not None:
555
+ t = getattr(self, name)
556
+ t[idx1][idx2] = unfix(val, scale)
557
+ else:
558
+ setattr(self, name, unfix(val, scale))
559
+
560
+ def m_4s10(self, name: str, levels: list[float]):
561
+ """Pack/unpack four signed 10-bit words into/from 5 unsigned bytes.
562
+
563
+ Required arguments:
564
+
565
+ name -- The name of the CTid parameter being packed/unpacked
566
+
567
+
568
+ levels -- The signal levels (in percent) by which the table is
569
+ indexed.
570
+
571
+ """
572
+ fmt = 5 * "B"
573
+ t = getattr(self, name)
574
+ if self.encoding:
575
+ v0 = fix(t[levels[0]][0], 10, True, name, "%", 0.01)
576
+ v1 = fix(t[levels[0]][1], 10, True, name, "°", 0.01)
577
+ v2 = fix(t[levels[1]][0], 10, True, name, "%", 0.01)
578
+ v3 = fix(t[levels[1]][1], 10, True, name, "°", 0.01)
579
+ bs = (
580
+ (v0 >> 2) & 0xFF,
581
+ ((v0 & 0x03) << 6) | ((v1 >> 4) & 0x3F),
582
+ ((v1 & 0x0F) << 4) | ((v2 >> 6) & 0x0F),
583
+ ((v2 & 0x3F) << 2) | ((v3 >> 8) & 0x03),
584
+ (v3 & 0xFF),
585
+ )
586
+ self._raw_data += struct.pack(fmt, *bs)
587
+ else:
588
+ bs = struct.unpack_from(fmt, self._raw_data, self._raw_offset)
589
+ self._raw_offset += struct.calcsize(fmt)
590
+ v0 = s10((bs[0] << 2) | ((bs[1] >> 6) & 0x03))
591
+ v1 = s10(((bs[1] & 0x3F) << 4) | ((bs[2] >> 4) & 0x0F))
592
+ v2 = s10(((bs[2] & 0x0F) << 6) | ((bs[3] >> 2) & 0x3F))
593
+ v3 = s10(((bs[3] & 0x03) << 8) | bs[4])
594
+ t[levels[0]][0] = unfix(v0, 0.01)
595
+ t[levels[0]][1] = unfix(v1, 0.01)
596
+ t[levels[1]][0] = unfix(v2, 0.01)
597
+ t[levels[1]][1] = unfix(v3, 0.01)
598
+
599
+ def m_u16(self, name: str, scale: float = 1, unit: str | None = None):
600
+ fmt = ">H"
601
+ if self.encoding:
602
+ val = fix(getattr(self, name), 16, False, name, unit, scale)
603
+ self._raw_data += struct.pack(fmt, val)
604
+ else:
605
+ val = struct.unpack_from(fmt, self._raw_data, self._raw_offset)[0]
606
+ self._raw_offset += struct.calcsize(fmt)
607
+ setattr(self, name, unfix(val, scale))
608
+
609
+ def m_s16(self, name: str, scale: float = 1, unit: str | None = None):
610
+ fmt = ">h"
611
+ if self.encoding:
612
+ val = fix(getattr(self, name), 16, True, name, unit, scale)
613
+ self._raw_data += struct.pack(fmt, val)
614
+ else:
615
+ val = struct.unpack_from(fmt, self._raw_data, self._raw_offset)[0]
616
+ self._raw_offset += struct.calcsize(fmt)
617
+ setattr(self, name, unfix(val, scale))
618
+
619
+ def m_d16(self, name: str, unit: str):
620
+ fmt = ">H"
621
+ if self.encoding:
622
+ val = getattr(self, name)
623
+ self._raw_data += struct.pack(fmt, decimal16(val, name, unit))
624
+ else:
625
+ val = struct.unpack_from(fmt, self._raw_data, self._raw_offset)[0]
626
+ self._raw_offset += struct.calcsize(fmt)
627
+ setattr(self, name, undecimal16(val))
628
+
629
+ def m_s12(self, name: str, scale: float = 1, unit: str | None = None):
630
+ fmt = ">H"
631
+ if self.encoding:
632
+ s12 = fix(getattr(self, name), 12, True, name, unit, scale)
633
+ if s12 < 0:
634
+ s12 &= 0xFFF
635
+ self._raw_data += struct.pack(fmt, s12 << 4)
636
+ else:
637
+ u16 = struct.unpack_from(fmt, self._raw_data, self._raw_offset)[0]
638
+ self._raw_offset += struct.calcsize(fmt)
639
+ s12 = (u16 >> 4) & 0x0FFF
640
+ if s12 >= 0x800:
641
+ s12 -= 0x1000
642
+ setattr(self, name, unfix(s12, scale))
643
+
644
+ def m_u4_s12(
645
+ self,
646
+ name4: str,
647
+ scale4: float,
648
+ unit4: str | None,
649
+ name12: str,
650
+ scale12: float = 1,
651
+ unit12: str | None = None,
652
+ ):
653
+ fmt = ">H"
654
+ if self.encoding:
655
+ u4 = fix(getattr(self, name4), 4, False, name4, unit4, scale4)
656
+ s12 = fix(getattr(self, name12), 12, True, name12, unit12, scale12)
657
+ if s12 < 0:
658
+ s12 &= 0xFFF
659
+ self._raw_data += struct.pack(fmt, (u4 << 12) | s12)
660
+ else:
661
+ u16 = struct.unpack_from(fmt, self._raw_data, self._raw_offset)[0]
662
+ self._raw_offset += struct.calcsize(fmt)
663
+ setattr(self, name4, unfix((u16 >> 12) & 0xF, scale4))
664
+ s12 = u16 & 0x0FFF
665
+ if s12 >= 0x800:
666
+ s12 -= 0x1000
667
+ setattr(self, name12, unfix(s12, scale12))
668
+
669
+ def m_f12_f12(self, name1: str, unit1: str, name2: str, unit2: str):
670
+ fmt = ">BH"
671
+ if self.encoding:
672
+ f1 = float12(getattr(self, name1), name1, unit1)
673
+ f2 = float12(getattr(self, name2), name2, unit2)
674
+ u24 = (f1 << 12) | f2
675
+ self._raw_data += struct.pack(fmt, u24 >> 16, u24 & 0xFFFF)
676
+ else:
677
+ val = struct.unpack_from(fmt, self._raw_data, self._raw_offset)
678
+ u24 = (val[0] << 16) | val[1]
679
+ self._raw_offset += struct.calcsize(fmt)
680
+ setattr(self, name1, round(unfloat12((u24 >> 12) & 0xFFF)))
681
+ setattr(self, name2, round(unfloat12((u24 >> 0) & 0xFFF)))
682
+
683
+ def m_u24(self, name: str, scale: float = 1, unit: str | None = None):
684
+ fmt = ">BH"
685
+ if self.encoding:
686
+ val = fix(getattr(self, name), 24, False, name, unit, scale)
687
+ self._raw_data += struct.pack(
688
+ fmt, (val >> 16) & 0xFF, val & 0xFFFF
689
+ )
690
+ else:
691
+ val = struct.unpack_from(fmt, self._raw_data, self._raw_offset)
692
+ val = (val[0] << 16) | val[1]
693
+ self._raw_offset += struct.calcsize(fmt)
694
+ setattr(self, name, unfix(val, scale))
695
+
696
+ def m_f32(self, name: str):
697
+ fmt = ">f"
698
+ if self.encoding:
699
+ self._raw_data += struct.pack(fmt, getattr(self, name))
700
+ else:
701
+ val = struct.unpack_from(fmt, self._raw_data, self._raw_offset)
702
+ self._raw_offset += struct.calcsize(fmt)
703
+ setattr(self, name, val[0])
704
+
705
+ def m_utf8_4(self, name: str):
706
+ if self.encoding:
707
+ val = getattr(self, name).encode("utf-8")
708
+ if len(val) > 4:
709
+ raise Error(
710
+ f"{name} `{getattr(self, name)}' is {len(val)} bytes "
711
+ "long in UTF-8 but only up to 4 bytes are allowed."
712
+ )
713
+ while len(val) < 4:
714
+ val += b"\0"
715
+ self._raw_data += val
716
+ else:
717
+ raw = b""
718
+ for i in range(self._raw_offset, self._raw_offset + 4):
719
+ if self._raw_data[i] == 0:
720
+ break
721
+ raw += bytearray((self._raw_data[i],))
722
+ self._raw_offset += 4
723
+ setattr(self, name, raw.decode())
724
+
725
+ def m_utf8_8(self, name: str):
726
+ if self.encoding:
727
+ val = getattr(self, name).encode("utf-8")
728
+ if len(val) > 8:
729
+ raise Error(
730
+ f"{name} `{getattr(self, name)}' is {len(val)} bytes "
731
+ "long in UTF-8 but only up to 8 bytes are allowed."
732
+ )
733
+ while len(val) < 8:
734
+ val += b"\0"
735
+ self._raw_data += val
736
+ else:
737
+ raw = b""
738
+ for i in range(self._raw_offset, self._raw_offset + 8):
739
+ if self._raw_data[i] == 0:
740
+ break
741
+ raw += bytearray((self._raw_data[i],))
742
+ self._raw_offset += 8
743
+ setattr(self, name, raw.decode())
744
+
745
+ def marshall_params_ct(self):
746
+ """Current Transducer Parameters"""
747
+ self.m_u16("rated_current", 0.1, "A")
748
+ self.m_u16("voltage_at_rated_current", 10e-6, "V")
749
+ self.m_u16("size", 0.1, "mm")
750
+ self.m_s12("phase_at_rated_current", 0.01, "°")
751
+ self.m_s8("voltage_temp_coeff", 5, "ppm/°C")
752
+ self.m_s8("phase_temp_coeff", 0.5, "m°/°C")
753
+ if self.version >= 5:
754
+ keys = list(self.cal_table.keys())
755
+ if len(keys) != 4:
756
+ raise Error(f"Cal table has {len(keys)} rows; expected 4.")
757
+ self.m_4s10("cal_table", keys[0:2])
758
+ self.m_4s10("cal_table", keys[2:4])
759
+ else:
760
+ for k in sorted(self.cal_table.keys()):
761
+ self.m_s8("cal_table", 0.02, "%", idx1=k, idx2=0)
762
+ self.m_s8("cal_table", 0.02, "\u00b0", idx1=k, idx2=1)
763
+ self.m_s16("bias_voltage", 1e-6, "V")
764
+
765
+ def marshall_params_basic_linear(self):
766
+ """Basic linear Parameters (for linear temp. and generic linear
767
+ sensors)."""
768
+ self.m_f32("scale")
769
+ self.m_f32("offset")
770
+
771
+ def marshall_params_linear(self):
772
+ """Linear Parameters."""
773
+ self.marshall_params_basic_linear()
774
+ self.m_s16("delay", 0.01, "μs")
775
+ self.m_u16("sensor_unit")
776
+
777
+ def marshall_params_ntc(self):
778
+ """NTC Parameters."""
779
+ self.m_f32("ntc_a")
780
+ self.m_f32("ntc_b")
781
+ self.m_f32("ntc_c")
782
+ self.m_f32("ntc_m")
783
+ if self.version == 3:
784
+ self.m_d16("ntc_r1", "Ω")
785
+ else:
786
+ self.m_f32("ntc_n")
787
+ self.m_f32("ntc_k")
788
+
789
+ def marshall_params_pulse(self):
790
+ """Pulse Parameters."""
791
+ self.m_u16("threshold", 10e-6, "V")
792
+ self.m_u16("hysteresis", 10e-6, "V")
793
+ self.m_u8("debounce_time")
794
+ self.m_u2("edge_mask")
795
+
796
+ def marshall_params(self):
797
+ if (
798
+ self.sensor_type >= SENSOR_TYPE_AC
799
+ and self.sensor_type <= SENSOR_TYPE_RC
800
+ ):
801
+ self.marshall_params_ct()
802
+ elif self.sensor_type == SENSOR_TYPE_LINEAR:
803
+ self.marshall_params_linear()
804
+ elif self.sensor_type == SENSOR_TYPE_TEMP_LINEAR:
805
+ self.marshall_params_basic_linear()
806
+ elif self.sensor_type == SENSOR_TYPE_TEMP_NTC:
807
+ self.marshall_params_ntc()
808
+ elif self.sensor_type == SENSOR_TYPE_PULSE:
809
+ self.marshall_params_pulse()
810
+
811
+ max_size = 43 if self.version >= 4 else 33
812
+
813
+ if len(self._raw_data) < max_size:
814
+ self._raw_data += (max_size - len(self._raw_data)) * b"\0"
815
+ elif len(self._raw_data) > max_size:
816
+ raise Error("CTid table too big", len(self._raw_data), max_size)
817
+
818
+ def marshall(self):
819
+ self.m_u8("version")
820
+ self.m_u16("mfg_id")
821
+ if self.version >= 4:
822
+ # v4 or newer
823
+ self.m_utf8_8("model")
824
+ self.m_u8("reserved")
825
+ self.m_u24("serial_number")
826
+ self.m_u8("sensor_type")
827
+ self.m_f12_f12("r_source", "Ω", "r_load", "Ω")
828
+ self.marshall_params()
829
+ else:
830
+ self.m_utf8_4("model")
831
+ if self.version == 3:
832
+ # v3
833
+ self.m_u24("serial_number")
834
+ self.m_u8("sensor_type")
835
+ self.m_f12_f12("r_source", "Ω", "r_load", "Ω")
836
+ self.m_u8("reserved")
837
+ self.marshall_params()
838
+ else:
839
+ # v1 or v2
840
+ self.m_u16("size", 0.1, "mm")
841
+ self.m_u24("serial_number")
842
+ self.m_u16("rated_current", 0.1, "A")
843
+ self.m_u16("voltage_at_rated_current", 10e-6, "V")
844
+ self.m_u4_s12(
845
+ "sensor_type",
846
+ 1,
847
+ None,
848
+ "phase_at_rated_current",
849
+ 0.01,
850
+ "°",
851
+ )
852
+ self.m_s8("voltage_temp_coeff", 5, "ppm/°C")
853
+ self.m_s8("phase_temp_coeff", 0.5, "m°/°C")
854
+ for k in sorted(self.cal_table.keys()):
855
+ self.m_s8("cal_table", 0.02, "%", idx1=k, idx2=0)
856
+ self.m_s8("cal_table", 0.02, "°", idx1=k, idx2=1)
857
+ self.m_u8("reserved")
858
+ self.m_u8("mfg_info")
859
+ if self.version > 1:
860
+ self.m_f12_f12("r_source", "Ω", "r_load", "Ω")
861
+
862
+ def encode(self, version: int = CTID_VERSION):
863
+ """Encode the table contents and store it as a sequence of
864
+ bytes in property `raw_data`.
865
+
866
+ Keyword arguments:
867
+
868
+ version -- The version of the CTid specification according to
869
+ which to produce the table (default latest version).
870
+
871
+ """
872
+ self.encoding = True
873
+ self._raw_data = b""
874
+ self._raw_offset = 0
875
+ self.version = version
876
+ if version < 2 and (self.r_source != 0.0 or self.r_load != 0.0):
877
+ raise Error(
878
+ "Unable to encode r_source and r_load in "
879
+ f"CTid table version {version}."
880
+ )
881
+ try:
882
+ self.marshall()
883
+ except KeyError as e:
884
+ raise Error("Required parameter missing from CTid table.") from e
885
+ except struct.error as e:
886
+ raise Error("CTid table parameter has an invalid value.") from e
887
+ if self.version == 1:
888
+ # v1 used an 8-bit CRC
889
+ self._raw_data += struct.pack(">B", crc8_rohc(self._raw_data))
890
+ assert len(self._raw_data) == 31 # excluding start symbol
891
+ else:
892
+ self._raw_data += struct.pack(">H", crc16_modbus(self._raw_data))
893
+ if self.version < 4:
894
+ # v2-3
895
+ assert len(self._raw_data) == 35 # excluding start symbol
896
+ else:
897
+ # v4 and on...
898
+ assert len(self._raw_data) == 45 # excluding start symbol
899
+ return self._raw_data
900
+
901
+ def decode(self, raw_data: bytes):
902
+ """Load the table from a sequence of bytes.
903
+
904
+ Required arguments:
905
+
906
+ raw_data -- The byte sequence to decode. Note: the
907
+ start-symbol and the CRC must not be part of this data.
908
+ The CRC should already have been confirmed to be correct
909
+ before calling this method.
910
+
911
+ """
912
+ self.encoding = False
913
+ self._raw_data = raw_data
914
+ self._raw_offset = 0
915
+ self.marshall()
916
+
917
+
918
+ def bitstuff(data: bytes) -> bytes:
919
+ """Get the bit-stuffed version of the data, prefixed with the
920
+ start symbol.
921
+
922
+ Required arguments:
923
+
924
+ data -- The data bytes to bit-stuff.
925
+
926
+ """
927
+ bs = BitStuffer(bytes((START_SYM,)))
928
+ for b in data:
929
+ bs.append(b)
930
+ return bs.get_output()
931
+
932
+
933
+ def unstuff(bitstream: bytes) -> bytearray:
934
+ """Remove the start-symbol and the bit-stuffing from the
935
+ bitstream.
936
+
937
+ Required arguments:
938
+
939
+ bitstream -- The bitstream to unstuff.
940
+
941
+ """
942
+ if bitstream[0] != START_SYM:
943
+ raise Error("Bitstream missing start symbol.", bitstream[0], START_SYM)
944
+
945
+ data = bytearray()
946
+ run_length = 0
947
+ byte = 0x00
948
+ num_bits = 0
949
+ for b in bitstream[1:]:
950
+ mask = 0x80
951
+ while mask != 0:
952
+ if run_length >= 7:
953
+ # drop a stuffer bit
954
+ run_length = 0
955
+ else:
956
+ if b & mask:
957
+ byte |= 1 << (7 - num_bits)
958
+ run_length += 1
959
+ else:
960
+ run_length = 0
961
+ num_bits += 1
962
+ if num_bits >= 8:
963
+ data.append(byte)
964
+ byte = 0
965
+ num_bits = 0
966
+ mask >>= 1
967
+ return data