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.
- egauge/ctid/__init__.py +7 -0
- egauge/ctid/bit_stuffer.py +65 -0
- egauge/ctid/ctid.py +967 -0
- egauge/ctid/encoder.py +436 -0
- egauge/ctid/intel_hex_encoder.py +98 -0
- egauge/ctid/waveform.py +299 -0
- egauge/examples/data/test-ctid-decoder.raw +0 -0
- egauge/examples/test_capture.py +77 -0
- egauge/examples/test_common.py +26 -0
- egauge/examples/test_ctid.py +89 -0
- egauge/examples/test_ctid_decoder.py +93 -0
- egauge/examples/test_local.py +201 -0
- egauge/examples/test_register.py +104 -0
- egauge/loggers.py +72 -0
- egauge/pyside/__init__.py +0 -0
- egauge/pyside/ansi2html.py +112 -0
- egauge/pyside/terminal.py +295 -0
- egauge/webapi/__init__.py +34 -0
- egauge/webapi/auth.py +364 -0
- egauge/webapi/cloud/__init__.py +30 -0
- egauge/webapi/cloud/credentials.py +86 -0
- egauge/webapi/cloud/credentials_dialog.py +58 -0
- egauge/webapi/cloud/gui/credentials_dialog.py +100 -0
- egauge/webapi/cloud/serial_number.py +276 -0
- egauge/webapi/device/__init__.py +38 -0
- egauge/webapi/device/capture.py +453 -0
- egauge/webapi/device/ctid_info.py +553 -0
- egauge/webapi/device/device.py +349 -0
- egauge/webapi/device/local.py +268 -0
- egauge/webapi/device/physical_quantity.py +439 -0
- egauge/webapi/device/physical_units.py +473 -0
- egauge/webapi/device/register.py +338 -0
- egauge/webapi/device/register_row.py +145 -0
- egauge/webapi/device/register_type.py +851 -0
- egauge/webapi/device/slop.py +334 -0
- egauge/webapi/device/virtual_register.py +353 -0
- egauge/webapi/error.py +34 -0
- egauge/webapi/json_api.py +332 -0
- egauge_python-0.9.8.dist-info/METADATA +148 -0
- egauge_python-0.9.8.dist-info/RECORD +44 -0
- egauge_python-0.9.8.dist-info/WHEEL +5 -0
- egauge_python-0.9.8.dist-info/entry_points.txt +2 -0
- egauge_python-0.9.8.dist-info/licenses/LICENSE +22 -0
- egauge_python-0.9.8.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2024 eGauge Systems LLC
|
|
3
|
+
# 1644 Conestoga St, Suite 2
|
|
4
|
+
# Boulder, CO 80301
|
|
5
|
+
# voice: 720-545-9767
|
|
6
|
+
# email: davidm@egauge.net
|
|
7
|
+
#
|
|
8
|
+
# All rights reserved.
|
|
9
|
+
#
|
|
10
|
+
# MIT License
|
|
11
|
+
#
|
|
12
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
# in the Software without restriction, including without limitation the rights
|
|
15
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
# furnished to do so, subject to the following conditions:
|
|
18
|
+
#
|
|
19
|
+
# The above copyright notice and this permission notice shall be included in
|
|
20
|
+
# all copies or substantial portions of the Software.
|
|
21
|
+
#
|
|
22
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
28
|
+
# THE SOFTWARE.
|
|
29
|
+
#
|
|
30
|
+
"""Module for monitoring the progress of slow operations (SLOPs).
|
|
31
|
+
|
|
32
|
+
Once a slow operation is started through WebAPI, it returns a token
|
|
33
|
+
which can be polled periodically to watch the progress of the
|
|
34
|
+
operation and detect when it has completed.
|
|
35
|
+
|
|
36
|
+
This module supports that paradigm through the `SLOPStatus.stream()`
|
|
37
|
+
class method which returns an iterator generating the sequence of
|
|
38
|
+
`SLOPStatus` objects describing the progress of the SLOP.
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import decimal
|
|
43
|
+
from enum import Enum
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from .device import Device
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Error(Exception):
|
|
50
|
+
"""Raised for errors detected by this module."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SLOPInfo(Enum):
|
|
54
|
+
"""Tags for the SLOPStatus.info property."""
|
|
55
|
+
|
|
56
|
+
#
|
|
57
|
+
# See egauge/priv-db-common.h:
|
|
58
|
+
#
|
|
59
|
+
DB_ADJUST = "ADJUST"
|
|
60
|
+
DB_RESTORE = "RESTORE"
|
|
61
|
+
DB_SKIP = "SKIP"
|
|
62
|
+
DB_SPLIT = "SPLIT"
|
|
63
|
+
DB_ZERO = "ZERO"
|
|
64
|
+
#
|
|
65
|
+
# See egauge/priv-update-common.h:
|
|
66
|
+
#
|
|
67
|
+
UPD_CHKSIG = "CHKSIG"
|
|
68
|
+
UPD_DOWNLOAD_FW = "FW_DOWNLOAD"
|
|
69
|
+
UPD_DOWNLOAD_KERNEL = "KERNEL_DOWNLOAD"
|
|
70
|
+
UPD_DOWNLOAD_SKIN = "SKIN_DOWNLOAD"
|
|
71
|
+
UPD_EXTRACT = "EXTRACT"
|
|
72
|
+
UPD_FINALIZE = "FINALIZE"
|
|
73
|
+
UPD_FLASH = "FLASH"
|
|
74
|
+
UPD_INSTALL = "INSTALL"
|
|
75
|
+
UPD_VALIDATE = "VALIDATE"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SLOPError(Enum):
|
|
79
|
+
"""Tags for the SLOPStatus.error property."""
|
|
80
|
+
|
|
81
|
+
#
|
|
82
|
+
# See egauge/slop.h:
|
|
83
|
+
#
|
|
84
|
+
OOM = "OOM"
|
|
85
|
+
CANCELED = "CANCELED"
|
|
86
|
+
#
|
|
87
|
+
# See egauge/priv-activate-common.h for details.
|
|
88
|
+
#
|
|
89
|
+
ACT_ALRT = "ALRT"
|
|
90
|
+
ACT_PUSH = "PUSH"
|
|
91
|
+
ACT_SRV = "SRV"
|
|
92
|
+
#
|
|
93
|
+
# See egauge/priv-db-common.h:
|
|
94
|
+
#
|
|
95
|
+
DB_BADHDR = "BADHDR"
|
|
96
|
+
DB_BADREG = "BADREG"
|
|
97
|
+
DB_BADTS = "BADTS"
|
|
98
|
+
DB_FILE_INVAL = "FILE_INVAL"
|
|
99
|
+
DB_FILE_READ_ERR = "FILE_READ_ERR"
|
|
100
|
+
DB_INVAL_INT = "INVAL_INT"
|
|
101
|
+
DB_IN_FUTURE = "IN_FUTURE"
|
|
102
|
+
DB_LOCK_FAILED = "LOCK_FAILED"
|
|
103
|
+
DB_MISSING_COMMA = "NOCOMMA"
|
|
104
|
+
DB_MISSING_VERSION = "NOVERS"
|
|
105
|
+
DB_NOT_A_NET_REG = "NOT_NET_REG"
|
|
106
|
+
DB_NO_FIRST_ROW = "NO_FIRST_ROW"
|
|
107
|
+
DB_NO_POS_REG = "NO_POS_REG"
|
|
108
|
+
DB_NO_SECOND_ROW = "NO_SECOND_ROW"
|
|
109
|
+
DB_READ_FAILED = "DB_READ_ERR"
|
|
110
|
+
DB_WRITE_FAILED = "WRITE_FAILED"
|
|
111
|
+
#
|
|
112
|
+
# See egauge/test-email.c:
|
|
113
|
+
#
|
|
114
|
+
MAIL_TX_ERR = "MAIL_TX_ERR"
|
|
115
|
+
#
|
|
116
|
+
# See egauge/priv-update-common.h:
|
|
117
|
+
#
|
|
118
|
+
UPD_BADSIG = "BADSIG"
|
|
119
|
+
UPD_DOWNLOAD_FAILED = "DOWNLOAD_FAILED"
|
|
120
|
+
UPD_EXTRACT_FAILED = "EXTRACT_FAILED"
|
|
121
|
+
UPD_FLASH_FAILED = "FLASH_FAILED"
|
|
122
|
+
UPD_INCOMPATIBLE = "INCOMPATBILE"
|
|
123
|
+
UPD_INSTALL_FAILED = "INSTALL_FAILED"
|
|
124
|
+
UPD_INVALID_IMAGE = "INVALID_IMAGE"
|
|
125
|
+
UPD_NO_IMAGE = "NO_IMAGE"
|
|
126
|
+
UPD_NOT_NEWER = "NOT_NEWER"
|
|
127
|
+
UPD_NOT_SUPPORTED = "NOT_SUPPORTED"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_ERR_MSG_TEMPLATE = {
|
|
131
|
+
SLOPError.OOM.value: "Out of memory.",
|
|
132
|
+
SLOPError.CANCELED.value: "Canceled.",
|
|
133
|
+
SLOPError.ACT_SRV.value: "Server error.value: {0}",
|
|
134
|
+
SLOPError.ACT_ALRT.value: "Alert activation.value: {0}",
|
|
135
|
+
SLOPError.ACT_PUSH.value: "Push activation.value: {0}",
|
|
136
|
+
SLOPError.DB_BADHDR.value: "Bad backup file header.value: `{0}'",
|
|
137
|
+
SLOPError.DB_BADREG.value: "Invalid register name `{0}'",
|
|
138
|
+
SLOPError.DB_BADTS.value: "Invalid timestamp `{0}'",
|
|
139
|
+
SLOPError.DB_FILE_INVAL.value: "Backup file is invalid.",
|
|
140
|
+
SLOPError.DB_FILE_READ_ERR.value: (
|
|
141
|
+
"Read of backup file failed after {0} lines"
|
|
142
|
+
),
|
|
143
|
+
SLOPError.DB_INVAL_INT.value: "Invalid value in backup file at line {0}",
|
|
144
|
+
SLOPError.DB_IN_FUTURE.value: "Backup is in the future ({0}>{1})",
|
|
145
|
+
SLOPError.DB_LOCK_FAILED.value: "Failed to acquire lock",
|
|
146
|
+
SLOPError.DB_MISSING_COMMA.value: "Comma missing in backup file line {0}",
|
|
147
|
+
SLOPError.DB_MISSING_VERSION.value: "Version missing from backup file",
|
|
148
|
+
SLOPError.DB_NOT_A_NET_REG.value: "Register `{0}' is not a net register",
|
|
149
|
+
SLOPError.DB_NO_FIRST_ROW.value: "Backup file has no data rows",
|
|
150
|
+
SLOPError.DB_NO_POS_REG.value: (
|
|
151
|
+
"Register `{0}' has no matching positive-only register"
|
|
152
|
+
),
|
|
153
|
+
SLOPError.DB_NO_SECOND_ROW.value: "Backup file has no second data row",
|
|
154
|
+
SLOPError.DB_READ_FAILED.value: "Failed to read db at {0}",
|
|
155
|
+
SLOPError.DB_WRITE_FAILED.value: "Failed to write db at {0}",
|
|
156
|
+
SLOPError.MAIL_TX_ERR.value: 'Failed to send email to "{0}"',
|
|
157
|
+
SLOPError.UPD_BADSIG.value: "Invalid signature",
|
|
158
|
+
SLOPError.UPD_DOWNLOAD_FAILED.value: "Download of {0} failed.value: {1}",
|
|
159
|
+
SLOPError.UPD_EXTRACT_FAILED.value: "Extraction of {0} failed.value: {1}",
|
|
160
|
+
SLOPError.UPD_FLASH_FAILED.value: "Flashing of parting {0} failed",
|
|
161
|
+
SLOPError.UPD_INCOMPATIBLE.value: (
|
|
162
|
+
'Image for "{0}" devices is not compatible with this "{1}" device.'
|
|
163
|
+
),
|
|
164
|
+
SLOPError.UPD_INSTALL_FAILED.value: "Installation failed.value: {0}",
|
|
165
|
+
SLOPError.UPD_INVALID_IMAGE.value: "Invalid image file",
|
|
166
|
+
SLOPError.UPD_NO_IMAGE.value: "File {0} does not exist",
|
|
167
|
+
SLOPError.UPD_NOT_NEWER.value: (
|
|
168
|
+
"Available version {0} is not newer than existing version {1}"
|
|
169
|
+
),
|
|
170
|
+
SLOPError.UPD_NOT_SUPPORTED.value: "Operation not supported",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_INFO_MSG_TEMPLATE = {
|
|
174
|
+
SLOPInfo.DB_ADJUST.value: "Adjusting",
|
|
175
|
+
SLOPInfo.DB_RESTORE.value: "Restoring",
|
|
176
|
+
SLOPInfo.DB_SKIP.value: "Skipping",
|
|
177
|
+
SLOPInfo.DB_SPLIT.value: "Splitting",
|
|
178
|
+
SLOPInfo.DB_ZERO.value: "Zeroing",
|
|
179
|
+
SLOPInfo.UPD_CHKSIG.value: "Checking signature of {0}",
|
|
180
|
+
SLOPInfo.UPD_DOWNLOAD_FW.value: "Downloading firmware {0}",
|
|
181
|
+
SLOPInfo.UPD_DOWNLOAD_KERNEL.value: "Downloading kernel {0}",
|
|
182
|
+
SLOPInfo.UPD_DOWNLOAD_SKIN.value: "Downloading skin {0}",
|
|
183
|
+
SLOPInfo.UPD_EXTRACT.value: "Extracting {0} files",
|
|
184
|
+
SLOPInfo.UPD_FINALIZE.value: "Finishing up",
|
|
185
|
+
SLOPInfo.UPD_FLASH.value: "Flashing partition {0}",
|
|
186
|
+
SLOPInfo.UPD_INSTALL.value: "Installing version {0}",
|
|
187
|
+
SLOPInfo.UPD_VALIDATE.value: "Validating bundle",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SLOPStatusStream:
|
|
192
|
+
def __init__(
|
|
193
|
+
self, status_class: type["SLOPStatus"], dev: Device, token: str
|
|
194
|
+
):
|
|
195
|
+
"""Create a SLOPStatusStream object.
|
|
196
|
+
|
|
197
|
+
Required arguments:
|
|
198
|
+
|
|
199
|
+
status_class -- The SLOPStatus class to use to convert a
|
|
200
|
+
/sys/status/TOKEN result to a SLOPStatus object.
|
|
201
|
+
|
|
202
|
+
dev -- The WebAPI device to fetch the latest SLOP status from.
|
|
203
|
+
|
|
204
|
+
token -- The token of the SLOP to monitor.
|
|
205
|
+
|
|
206
|
+
"""
|
|
207
|
+
self.status_class = status_class
|
|
208
|
+
self.dev = dev
|
|
209
|
+
self.token = token
|
|
210
|
+
|
|
211
|
+
def __iter__(self):
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
def __next__(self) -> "SLOPStatus":
|
|
215
|
+
if self.token is None:
|
|
216
|
+
raise StopIteration
|
|
217
|
+
|
|
218
|
+
result = self.dev.get(f"/sys/status/{self.token}").get("result", {})
|
|
219
|
+
if not isinstance(result, dict):
|
|
220
|
+
raise Error("unexpected status", result)
|
|
221
|
+
|
|
222
|
+
status = self.status_class(result)
|
|
223
|
+
if status.done:
|
|
224
|
+
self.token = None
|
|
225
|
+
return status
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class SLOPStatus:
|
|
229
|
+
"""The current status of a slow-operation (SLOP)."""
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def stream(cls, dev: Device, token: str):
|
|
233
|
+
"""Return an iterator to monitor the status of a SLOP.
|
|
234
|
+
|
|
235
|
+
Required arguments:
|
|
236
|
+
|
|
237
|
+
dev -- The WebAPI device to fetch the latest SLOP status from.
|
|
238
|
+
|
|
239
|
+
token -- The token of the SLOP to monitor.
|
|
240
|
+
|
|
241
|
+
"""
|
|
242
|
+
return SLOPStatusStream(cls, dev, token)
|
|
243
|
+
|
|
244
|
+
def __init__(self, status: dict[str, Any]):
|
|
245
|
+
"""Create a SLOPStatus object from a /sys/status/TOKEN
|
|
246
|
+
response.
|
|
247
|
+
|
|
248
|
+
Required arguments:
|
|
249
|
+
|
|
250
|
+
status -- The SLOP status response received from WebAPI.
|
|
251
|
+
|
|
252
|
+
"""
|
|
253
|
+
self._status = status
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def done(self) -> bool:
|
|
257
|
+
"""True if the SLOP has finished execution, False otherwise."""
|
|
258
|
+
val = self._status.get("done", False)
|
|
259
|
+
if not isinstance(val, bool):
|
|
260
|
+
raise Error('invalid "done" value', val)
|
|
261
|
+
return val
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def error(self) -> str | None:
|
|
265
|
+
"""The error tag, if an error occured, `None` otherwise."""
|
|
266
|
+
val = self._status.get("error", None)
|
|
267
|
+
if val is None:
|
|
268
|
+
return None
|
|
269
|
+
if not isinstance(val, str):
|
|
270
|
+
raise Error('invalid "error" value', val)
|
|
271
|
+
return val
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def info(self) -> str | None:
|
|
275
|
+
"""The info tag, if available, `None` otherwise."""
|
|
276
|
+
val = self._status.get("info", None)
|
|
277
|
+
if val is None:
|
|
278
|
+
return None
|
|
279
|
+
if not isinstance(val, str):
|
|
280
|
+
raise Error('invalid "info" value', val)
|
|
281
|
+
return val
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def progress(self) -> float | None:
|
|
285
|
+
"""The progress fraction, if available, `None` otherwise."""
|
|
286
|
+
val = self._status.get("progress", None)
|
|
287
|
+
if val is None:
|
|
288
|
+
return None
|
|
289
|
+
if not isinstance(val, float):
|
|
290
|
+
raise Error('invalid "progress" value', val)
|
|
291
|
+
return val
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def result(self) -> Any:
|
|
295
|
+
"""The result of the SLOP, if available, `None` otherwise."""
|
|
296
|
+
return self._status.get("result")
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def ts(self) -> decimal.Decimal | None:
|
|
300
|
+
"""The timestamp of when the SLOP status was last updated, if
|
|
301
|
+
available, `None` otherwise."""
|
|
302
|
+
return self._status.get("ts")
|
|
303
|
+
|
|
304
|
+
def error_message(self) -> str:
|
|
305
|
+
"""The human-readable error message derived from the error tag.
|
|
306
|
+
|
|
307
|
+
Returns a human-readable error message or "No error." if the
|
|
308
|
+
`error` property is `None`.
|
|
309
|
+
|
|
310
|
+
"""
|
|
311
|
+
if self.error is None:
|
|
312
|
+
return "No error."
|
|
313
|
+
|
|
314
|
+
template = _ERR_MSG_TEMPLATE.get(self.error)
|
|
315
|
+
if template is None:
|
|
316
|
+
return f"Unknown error {self.error}"
|
|
317
|
+
|
|
318
|
+
return template.format(*self._status.get("args", []))
|
|
319
|
+
|
|
320
|
+
def info_message(self) -> str:
|
|
321
|
+
"""The human-readable info message derived from the info tag.
|
|
322
|
+
|
|
323
|
+
Returns a human-readable info message or "No info." if the
|
|
324
|
+
`info` property is `None`.
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
if self.info is None:
|
|
328
|
+
return "No info."
|
|
329
|
+
|
|
330
|
+
template = _INFO_MSG_TEMPLATE.get(self.info)
|
|
331
|
+
if template is None:
|
|
332
|
+
return f"Unknown info {self.info}."
|
|
333
|
+
|
|
334
|
+
return template.format(*self._status.get("args", []))
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2022-2024 eGauge Systems LLC
|
|
3
|
+
# 1644 Conestoga St, Suite 2
|
|
4
|
+
# Boulder, CO 80301
|
|
5
|
+
# voice: 720-545-9767
|
|
6
|
+
# email: davidm@egauge.net
|
|
7
|
+
#
|
|
8
|
+
# All rights reserved.
|
|
9
|
+
#
|
|
10
|
+
# MIT License
|
|
11
|
+
#
|
|
12
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
# in the Software without restriction, including without limitation the rights
|
|
15
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
# furnished to do so, subject to the following conditions:
|
|
18
|
+
#
|
|
19
|
+
# The above copyright notice and this permission notice shall be included in
|
|
20
|
+
# all copies or substantial portions of the Software.
|
|
21
|
+
#
|
|
22
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
28
|
+
# THE SOFTWARE.
|
|
29
|
+
#
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from enum import Enum, auto
|
|
33
|
+
from types import SimpleNamespace
|
|
34
|
+
from typing import Any, Callable, Generic, NoReturn, TypeVar
|
|
35
|
+
|
|
36
|
+
from ..error import Error
|
|
37
|
+
|
|
38
|
+
ID_PATTERN = re.compile(r"[A-Z]+")
|
|
39
|
+
NUMBER_PATTERN = re.compile(r"(\d+)")
|
|
40
|
+
|
|
41
|
+
# When evaluating a virtual register formula, the register values are
|
|
42
|
+
# looked up by id, which could be the register name and integer index
|
|
43
|
+
# or really anything else that's convenient for the user. The
|
|
44
|
+
# translation from register name to register id is accomplished by the
|
|
45
|
+
# `compile_reg` function argument below.
|
|
46
|
+
RegId = TypeVar("RegId")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VirtRegError(Error):
|
|
50
|
+
"""Exception raised due to any errors in this module."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Operator(Enum):
|
|
54
|
+
REG = auto() # value of a register
|
|
55
|
+
MIN = auto() # deprecated MIN(reg,c)
|
|
56
|
+
MAX = auto() # deprecated MAX(reg,c)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Addend(Generic[RegId]):
|
|
61
|
+
op: Operator
|
|
62
|
+
reg: RegId
|
|
63
|
+
const = 0
|
|
64
|
+
negate: bool = False # true if addend should be subtracted
|
|
65
|
+
|
|
66
|
+
def __str__(self):
|
|
67
|
+
reg = f"reg[{self.reg}]"
|
|
68
|
+
if self.op == Operator.REG:
|
|
69
|
+
val = reg
|
|
70
|
+
elif self.op == Operator.MIN:
|
|
71
|
+
val = f"MIN({reg},{self.const})"
|
|
72
|
+
elif self.op == Operator.MAX:
|
|
73
|
+
val = f"MAX({reg},{self.const})"
|
|
74
|
+
else:
|
|
75
|
+
raise VirtRegError(f'Operator "{self.op}" is unknown.')
|
|
76
|
+
return ("-" if self.negate else "+") + val
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _default_compile_reg(reg: str) -> str:
|
|
80
|
+
"""By default, use the register name as the register id."""
|
|
81
|
+
return reg
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class VirtualRegister(Generic[RegId]):
|
|
85
|
+
"""Objects of this class are a parsed version of the virtual register
|
|
86
|
+
formula. Since these formulas need to work both for rates and
|
|
87
|
+
cumulative values, only addition and subtraction are supported.
|
|
88
|
+
For historical reasons, we continue to support the MIN/MAX
|
|
89
|
+
operators, even though they don't work properly for cumulate
|
|
90
|
+
value. The should not be used on new meters."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
formula: str,
|
|
95
|
+
compile_reg: Callable[[str], RegId] = _default_compile_reg,
|
|
96
|
+
):
|
|
97
|
+
"""Compile a formula to a virtual register calculator.
|
|
98
|
+
|
|
99
|
+
Required arguments:
|
|
100
|
+
|
|
101
|
+
formula -- The virtual register formula to compile.
|
|
102
|
+
|
|
103
|
+
compile_reg -- An optional callback that can be used to
|
|
104
|
+
translate a register name to a register id (default
|
|
105
|
+
`None`). If `None`, the register name is used as the
|
|
106
|
+
register id.
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
self._phys_regs = []
|
|
110
|
+
self._addends = self._compile(formula, compile_reg)
|
|
111
|
+
|
|
112
|
+
def __str__(self):
|
|
113
|
+
"""Returns a human-readable representation of the virtual
|
|
114
|
+
register's formula."""
|
|
115
|
+
return " ".join([str(a) for a in self._addends])
|
|
116
|
+
|
|
117
|
+
def calc(self, get: Callable[[RegId], Any]) -> Any:
|
|
118
|
+
"""Calculate the value of a virtual register and return it.
|
|
119
|
+
|
|
120
|
+
The returned value has the same type as is returned by the
|
|
121
|
+
`get` function. It may be `float` or `int`. The distinction
|
|
122
|
+
matters here because not all 64-bit integers can be
|
|
123
|
+
represented accurately as a double-precision IEEE754
|
|
124
|
+
floating-point number and vice versa. If you are considering
|
|
125
|
+
replacing `Any` with a generic type instead, read this
|
|
126
|
+
cautionary tale first: https://tinyurl.com/5bp2yrb2 It would
|
|
127
|
+
be nice if we could just use `numbers.Real` instead of `Any`,
|
|
128
|
+
but that has all kinds of issues, too.
|
|
129
|
+
|
|
130
|
+
Required arguments:
|
|
131
|
+
|
|
132
|
+
get -- A callback that must return the value of the physical
|
|
133
|
+
register id passed as its first and only argument.
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
total = 0
|
|
137
|
+
for a in self._addends:
|
|
138
|
+
val = get(a.reg)
|
|
139
|
+
if a.op == Operator.REG:
|
|
140
|
+
pass
|
|
141
|
+
elif a.op == Operator.MIN:
|
|
142
|
+
if a.const < val:
|
|
143
|
+
val = a.const
|
|
144
|
+
elif a.op == Operator.MAX:
|
|
145
|
+
if a.const > val:
|
|
146
|
+
val = a.const
|
|
147
|
+
if a.negate:
|
|
148
|
+
val = -val
|
|
149
|
+
total += val
|
|
150
|
+
return total
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def phys_regs(self) -> list[str]:
|
|
154
|
+
"""Get the list of physical registers the virtual register
|
|
155
|
+
depends on.
|
|
156
|
+
|
|
157
|
+
"""
|
|
158
|
+
return self._phys_regs
|
|
159
|
+
|
|
160
|
+
def _compile(
|
|
161
|
+
self,
|
|
162
|
+
formula: str,
|
|
163
|
+
compile_reg: Callable[[str], RegId] = _default_compile_reg,
|
|
164
|
+
) -> list[Addend[RegId]]:
|
|
165
|
+
"""Compile a virtual register formula to the equivalent list
|
|
166
|
+
of addends.
|
|
167
|
+
|
|
168
|
+
Virtual register are limited to sums/differences of physical
|
|
169
|
+
register values.
|
|
170
|
+
|
|
171
|
+
For backwards-compatibility, an addend may also consist of a
|
|
172
|
+
MAX() or MIN() function call. Those functions never worked
|
|
173
|
+
correctly for calculating cumulative values, so they're
|
|
174
|
+
deprecated. Unfortunately, old devices may still use them.
|
|
175
|
+
|
|
176
|
+
EBNF for a register formula:
|
|
177
|
+
|
|
178
|
+
formula = ['+'|'-'] addend { ('+'|'-') addend}* .
|
|
179
|
+
addend = regname | func .
|
|
180
|
+
regname = QUOTED_STRING .
|
|
181
|
+
formula = ('MIN'|'MAX') '(' regname ',' number ')' .
|
|
182
|
+
number = [0-9]+ .
|
|
183
|
+
|
|
184
|
+
Required arguments:
|
|
185
|
+
|
|
186
|
+
formula -- The virtual register formula to translate.
|
|
187
|
+
|
|
188
|
+
compile_reg -- An optional callback that can be used to
|
|
189
|
+
translate a register name to a register id (default
|
|
190
|
+
`None`). If `None`, the register name is used as the
|
|
191
|
+
register id.
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def error(reason: str) -> NoReturn:
|
|
196
|
+
raise VirtRegError(f"{reason} (rest: '{formula[state.idx :]}')")
|
|
197
|
+
|
|
198
|
+
def whitespace():
|
|
199
|
+
while state.idx < len(formula) and formula[state.idx] in [
|
|
200
|
+
" ",
|
|
201
|
+
"\t",
|
|
202
|
+
]:
|
|
203
|
+
state.idx += 1
|
|
204
|
+
|
|
205
|
+
def peek() -> str:
|
|
206
|
+
if state.idx >= len(formula):
|
|
207
|
+
return ""
|
|
208
|
+
return formula[state.idx]
|
|
209
|
+
|
|
210
|
+
def getch() -> str:
|
|
211
|
+
if state.idx >= len(formula):
|
|
212
|
+
return ""
|
|
213
|
+
state.idx += 1
|
|
214
|
+
return formula[state.idx - 1]
|
|
215
|
+
|
|
216
|
+
def match(what: str) -> bool:
|
|
217
|
+
whitespace()
|
|
218
|
+
if peek() == what:
|
|
219
|
+
state.idx += 1
|
|
220
|
+
return True
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
def regname() -> Addend[RegId]:
|
|
224
|
+
if not match('"'):
|
|
225
|
+
return error('Expected opening quote (")')
|
|
226
|
+
name = ""
|
|
227
|
+
while True:
|
|
228
|
+
ch = getch()
|
|
229
|
+
if ch == "\\":
|
|
230
|
+
ch = getch()
|
|
231
|
+
elif not ch or ch == '"':
|
|
232
|
+
break
|
|
233
|
+
name += ch
|
|
234
|
+
if not name:
|
|
235
|
+
return error("Register name must not be empty")
|
|
236
|
+
|
|
237
|
+
if ch != '"': # dont use match: no whitespace allowed
|
|
238
|
+
return error('Expected closing quote (")')
|
|
239
|
+
state.phys_regs[name] = True
|
|
240
|
+
return Addend(op=Operator.REG, reg=compile_reg(name))
|
|
241
|
+
|
|
242
|
+
def number() -> int:
|
|
243
|
+
whitespace()
|
|
244
|
+
m = NUMBER_PATTERN.match(formula[state.idx :])
|
|
245
|
+
if not m:
|
|
246
|
+
return error("Expected number")
|
|
247
|
+
t = m.group()
|
|
248
|
+
state.idx += len(t)
|
|
249
|
+
return int(t)
|
|
250
|
+
|
|
251
|
+
def func() -> Addend[RegId]:
|
|
252
|
+
"""Parse:
|
|
253
|
+
|
|
254
|
+
(MIN|MAX) '(' regname ',' number ')'
|
|
255
|
+
|
|
256
|
+
"""
|
|
257
|
+
m = ID_PATTERN.match(formula[state.idx :])
|
|
258
|
+
if not isinstance(m, re.Match):
|
|
259
|
+
error("Expected function id")
|
|
260
|
+
|
|
261
|
+
name = m.group()
|
|
262
|
+
if name == "MAX":
|
|
263
|
+
op = Operator.MAX
|
|
264
|
+
elif name == "MIN":
|
|
265
|
+
op = Operator.MIN
|
|
266
|
+
else:
|
|
267
|
+
error("Expected MIN or MAX")
|
|
268
|
+
state.idx += len(name)
|
|
269
|
+
|
|
270
|
+
if not match("("):
|
|
271
|
+
error('Expected "("')
|
|
272
|
+
|
|
273
|
+
a = regname()
|
|
274
|
+
|
|
275
|
+
if not match(","):
|
|
276
|
+
error('Expected ","')
|
|
277
|
+
|
|
278
|
+
a.const = number()
|
|
279
|
+
|
|
280
|
+
if not match(")"):
|
|
281
|
+
error('Expected ")"')
|
|
282
|
+
|
|
283
|
+
a.op = op
|
|
284
|
+
return a
|
|
285
|
+
|
|
286
|
+
def addend():
|
|
287
|
+
whitespace()
|
|
288
|
+
if state.idx >= len(formula):
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
negate = False
|
|
292
|
+
if match("-"):
|
|
293
|
+
negate = True
|
|
294
|
+
elif match("+"):
|
|
295
|
+
pass
|
|
296
|
+
elif state.addends:
|
|
297
|
+
error('Expected "+" or "-"')
|
|
298
|
+
|
|
299
|
+
whitespace()
|
|
300
|
+
|
|
301
|
+
if peek() == '"':
|
|
302
|
+
a = regname()
|
|
303
|
+
else:
|
|
304
|
+
a = func()
|
|
305
|
+
a.negate = negate
|
|
306
|
+
state.addends.append(a)
|
|
307
|
+
|
|
308
|
+
# with the above local functions, the rest is easy:
|
|
309
|
+
|
|
310
|
+
state = SimpleNamespace(idx=0, addends=[], phys_regs={})
|
|
311
|
+
while state.idx < len(formula):
|
|
312
|
+
addend()
|
|
313
|
+
self._phys_regs = list(state.phys_regs.keys())
|
|
314
|
+
return state.addends
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test():
|
|
318
|
+
regmap = {"Grid": 0, "Solar": 1}
|
|
319
|
+
|
|
320
|
+
for formula in [
|
|
321
|
+
"",
|
|
322
|
+
'"Solar"+"Solar"',
|
|
323
|
+
' - "Grid" ',
|
|
324
|
+
'+"Grid"',
|
|
325
|
+
'"Grid"+MAX("Solar",0)',
|
|
326
|
+
]:
|
|
327
|
+
try:
|
|
328
|
+
virt = VirtualRegister(formula, lambda reg: regmap[reg])
|
|
329
|
+
except VirtRegError as e:
|
|
330
|
+
print("Error: Compile failed for formula:", formula)
|
|
331
|
+
print("\t", e)
|
|
332
|
+
continue
|
|
333
|
+
print(
|
|
334
|
+
"formula:",
|
|
335
|
+
formula,
|
|
336
|
+
">>> compiled: ",
|
|
337
|
+
virt,
|
|
338
|
+
"phys_regs",
|
|
339
|
+
virt.phys_regs,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
virt = VirtualRegister("")
|
|
343
|
+
if virt.calc(lambda reg: 1 / 0) != 0:
|
|
344
|
+
raise VirtRegError("Expected 0")
|
|
345
|
+
|
|
346
|
+
virt = VirtualRegister('"Grid"+MAX("Solar",0)')
|
|
347
|
+
if virt.calc(lambda reg: {"Grid": 10, "Solar": 20}[reg]) != 30:
|
|
348
|
+
raise VirtRegError("Expected 30")
|
|
349
|
+
if virt.calc(lambda reg: {"Grid": 10, "Solar": -20}[reg]) != 10:
|
|
350
|
+
raise VirtRegError("Expected 10")
|
|
351
|
+
if virt.calc(lambda reg: {"Grid": -10, "Solar": -20}[reg]) != -10:
|
|
352
|
+
raise VirtRegError("Expected 10")
|
|
353
|
+
print("Success!")
|