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