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,299 @@
1
+ #
2
+ # Copyright (c) 2016-2017, 2019-2022, 2024-2025 eGauge Systems LLC
3
+ # 4805 Sterling Dr, Suite 1
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 math
31
+
32
+ from egauge.loggers import ModuleLogger
33
+
34
+ log = ModuleLogger.get(__name__)
35
+
36
+ _START_SYM_ZC_COUNT = 16 # number of zero-crossings in start-symbol
37
+ _CLK_FREQ = 480 # nominal clock frequency of CTid carrier
38
+ _CLK_TOL = 50 # clock tolerance in %
39
+
40
+ _MAX_CLK_FREQ = math.ceil(_CLK_FREQ * (1.0 + (_CLK_TOL / 100)))
41
+ _MIN_CLK_FREQ = math.floor(_CLK_FREQ * (1.0 - (_CLK_TOL / 100)))
42
+ _MIN_CLK_PERIOD = 1e6 / _MAX_CLK_FREQ # min. period in microseconds
43
+ _MAX_CLK_PERIOD = 1e6 / _MIN_CLK_FREQ # max. period in microseconds
44
+
45
+ _START_SYM_MAX_DURATION = _START_SYM_ZC_COUNT / 2 * _MAX_CLK_PERIOD
46
+
47
+
48
+ def time_diff(l: float, r: float) -> float:
49
+ return l - r
50
+
51
+
52
+ def is_zero_crossing(curr: float, prev: float, mean: float) -> bool:
53
+ return (prev < mean) != (curr < mean)
54
+
55
+
56
+ class Window:
57
+ def __init__(self, sampling_freq: float):
58
+ self.sample_period = 1e6 / sampling_freq # period in microseconds
59
+ self.avg_len = math.ceil(_START_SYM_MAX_DURATION / self.sample_period)
60
+ window_len = 2 * self.avg_len
61
+
62
+ self.tolerance = 0.0
63
+ self.count = 0 # number of valid samples
64
+ self.avg_count = 0 # number of averaged samples
65
+ self.sum = 0 # sum of the valid samples
66
+ self.mean = 0 # mean value of valid samples
67
+ self.wr = 0 # index of next sample to be written
68
+ self.prev_polarity = 0 # previous polarity
69
+ self.ts: list[float] = [0] * window_len
70
+ self.val: list[float] = [0] * window_len
71
+
72
+ def enter_sample(self, ts: float, val: float):
73
+ if self.avg_count < self.avg_len:
74
+ self.avg_count += 1
75
+ else:
76
+ idx = self.wr + self.avg_len
77
+ if idx >= len(self.val):
78
+ idx -= len(self.val)
79
+ self.sum -= self.val[idx]
80
+ if self.count < len(self.val):
81
+ self.count += 1
82
+ self.sum += val
83
+ self.val[self.wr] = val
84
+ self.ts[self.wr] = ts
85
+ self.wr = (self.wr + 1) % len(self.val)
86
+ self.mean = self.sum / self.avg_count
87
+
88
+ def at_zero_crossing(self) -> bool:
89
+ curr_val = self.val[(self.wr + len(self.val) - 1) % len(self.val)]
90
+ curr_pol = curr_val >= self.mean
91
+ prev_pol = self.prev_polarity
92
+ self.prev_polarity = curr_pol
93
+ return curr_pol != prev_pol
94
+
95
+ def at_start_symbol(self) -> float | None:
96
+ if self.count < 1:
97
+ return None
98
+
99
+ curr = (self.wr + len(self.val) - 1) % len(self.val)
100
+ prev_val = self.val[curr]
101
+ self.prev_polarity = prev_val >= self.mean
102
+ end_ts = self.ts[curr]
103
+ start_ts = end_ts - _START_SYM_MAX_DURATION
104
+ total = 0
105
+ avg_count = 0
106
+
107
+ # See if we can find 16 zero-crossings within _START_SYM_MAX_DURATION:
108
+ first_ts = 0
109
+ zc_ts = []
110
+ i = 1
111
+ while i < self.count:
112
+ curr = (curr + len(self.val) - 1) % len(self.val)
113
+ curr_ts = self.ts[curr]
114
+ curr_val = self.val[curr]
115
+
116
+ if time_diff(start_ts, curr_ts) > _MAX_CLK_PERIOD:
117
+ return None
118
+
119
+ total += curr_val
120
+ avg_count += 1
121
+
122
+ if not is_zero_crossing(curr_val, prev_val, self.mean):
123
+ continue # keep looking for zero-crossing
124
+ prev_val = curr_val
125
+ zc_ts.append(curr_ts)
126
+ if len(zc_ts) >= _START_SYM_ZC_COUNT:
127
+ first_ts = curr_ts
128
+ break
129
+
130
+ i += 1
131
+
132
+ if len(zc_ts) < _START_SYM_ZC_COUNT:
133
+ return None
134
+
135
+ mean = total / avg_count
136
+
137
+ # Verify that there were no zero-crossings for
138
+ # _START_SYM_MAX_DURATION before the first edge of the
139
+ # start-symbol
140
+ while i < self.count:
141
+ curr = (curr + len(self.val) - 1) % len(self.val)
142
+ curr_ts = self.ts[curr]
143
+ curr_val = self.val[curr]
144
+
145
+ if is_zero_crossing(curr_val, prev_val, mean):
146
+ return None
147
+ if time_diff(first_ts, curr_ts) >= _START_SYM_MAX_DURATION:
148
+ break
149
+ prev_val = curr_val
150
+
151
+ i += 1
152
+
153
+ # Calculate average period based on the 7 full bit times the
154
+ # 16 zero-crossings cover (clock periods may be asymmetric):
155
+ period = time_diff(zc_ts[1], zc_ts[_START_SYM_ZC_COUNT - 1]) / (
156
+ _START_SYM_ZC_COUNT / 2 - 1
157
+ )
158
+ if period < _MIN_CLK_PERIOD or period > _MAX_CLK_PERIOD:
159
+ return None
160
+
161
+ self.tolerance = max(period / 4, 1.2 * self.sample_period)
162
+
163
+ # Verify that each bit has the expected period:
164
+ curr_ts = end_ts
165
+ for i in range(0, _START_SYM_ZC_COUNT, 2):
166
+ dt = time_diff(curr_ts, zc_ts[i + 1])
167
+ if abs(dt - period) > self.tolerance:
168
+ return None
169
+ curr_ts = zc_ts[i + 1]
170
+
171
+ # Now that we confirmed a start-symbol, commit to its mean value:
172
+ self.sum = total
173
+ self.avg_count = avg_count
174
+ self.mean = mean
175
+ return period
176
+
177
+
178
+ class ByteDecoder:
179
+ def __init__(self):
180
+ self.num_bits = 0
181
+ self.val = 0
182
+ self.edge_count = 0
183
+ self.run_length = 0
184
+ self.start_ts = None
185
+ self.decoded_byte = 0
186
+
187
+ def reset(self):
188
+ self.__init__()
189
+
190
+ def timed_out(self, period: float, now: float) -> bool:
191
+ if self.start_ts is None:
192
+ return False
193
+ return time_diff(now, self.start_ts) > 4 * period
194
+
195
+ def update(self, period: float, tolerance: float, ts: float) -> bool:
196
+ if self.start_ts is None:
197
+ self.start_ts = ts
198
+ return False
199
+
200
+ if time_diff(ts, self.start_ts + period - tolerance) < 0:
201
+ # got what looks like a 1-edge
202
+ self.edge_count += 1
203
+ return False
204
+
205
+ bit = self.edge_count & 1
206
+ log.debug(
207
+ "bit %d at 0x%08x (edge_count %d, start_ts 0x%08x)",
208
+ bit,
209
+ ts,
210
+ self.edge_count,
211
+ self.start_ts,
212
+ )
213
+
214
+ self.edge_count = 0
215
+ self.start_ts = ts
216
+ if self.run_length >= 7:
217
+ # drop stuffer bit...
218
+ self.run_length = 0
219
+ return False
220
+
221
+ if bit:
222
+ self.run_length += 1
223
+ else:
224
+ self.run_length = 0
225
+
226
+ self.val = (self.val << 1) | bit
227
+ self.num_bits += 1
228
+
229
+ if self.num_bits < 8:
230
+ return False
231
+ self.decoded_byte = self.val
232
+ self.val = 0
233
+ self.num_bits = 0
234
+ return True
235
+
236
+ def get_byte(self) -> int:
237
+ return self.decoded_byte
238
+
239
+
240
+ class Decoder:
241
+ """Decode a sequence of equidistant sample values that represent a
242
+ differential Manchester-encoded signal into a byte stream.
243
+
244
+ """
245
+
246
+ def __init__(self, sampling_freq: float):
247
+ """Create a waveform decoder for data sampled at a given
248
+ frequency.
249
+
250
+ Required arguments:
251
+
252
+ sampling_freq -- The sampling frequency in Hertz.
253
+
254
+ """
255
+ self.w = Window(sampling_freq)
256
+ self.bd = ByteDecoder()
257
+ self.period = None
258
+
259
+ def add_sample(self, timestamp: float, value: float) -> int:
260
+ """Enter a sample value for a given timestamp.
261
+
262
+ Returns -1 when a new start-symbol was detected, 0 when more
263
+ data is needed, and 1 if a complete byte has been decoded.
264
+
265
+ Required arguments:
266
+
267
+ timestamp -- The time in microseconds at which the sample
268
+ value was acquired.
269
+
270
+ value -- The value of the sample.
271
+
272
+ """
273
+ self.w.enter_sample(timestamp, value)
274
+
275
+ if self.period is None:
276
+ self.period = self.w.at_start_symbol()
277
+ if self.period is None:
278
+ return 0
279
+ self.bd.reset()
280
+ return -1 # got a new start-symbol
281
+
282
+ if self.bd.timed_out(self.period, timestamp):
283
+ # check if implied last edge finishes a byte, if so, return it:
284
+ ret = self.bd.update(self.period, self.w.tolerance, timestamp)
285
+ self.period = None
286
+ return 1 if ret else 0
287
+
288
+ if not self.w.at_zero_crossing():
289
+ return 0
290
+
291
+ log.debug("zero-crossing at 0x%08x (mean %d)", timestamp, self.w.mean)
292
+ return self.bd.update(self.period, self.w.tolerance, timestamp)
293
+
294
+ def get_byte(self) -> int:
295
+ """After Decoder.add_samples() returns 1, this returns the
296
+ most recently decoded byte.
297
+
298
+ """
299
+ return self.bd.get_byte()
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2023-2024 eGauge Systems LLC
4
+ #
5
+ # See LICENSE file for details.
6
+ #
7
+ """This test program demonstrates the use of class
8
+ egauge.webapi.device.Capture to capture waveform data via the /capture
9
+ WebAPI. When executed, it captures 34ms of waveform data for up to
10
+ the first three channels, then creates a timeplot of the data using
11
+ matplotlib.
12
+
13
+ Install egauge-python with a command of the form:
14
+
15
+ pip install egauge-python[examples]
16
+
17
+ to ensure that matplotlib is installed on your system.
18
+
19
+ You can set environment variables:
20
+
21
+ EGDEV - the URL of the meter to use (e.g., http://eGaugeXXX.local)
22
+ EGUSR - the username to log in to the meter (e.g., "owner")
23
+ EGPWD - the password for the username
24
+
25
+ Alternatively, you can edit examples/test_common.py to suit your needs.
26
+
27
+ """
28
+
29
+ import sys
30
+
31
+ from matplotlib import pyplot
32
+
33
+ from egauge.examples import test_common
34
+ from egauge.webapi.device import Capture, TriggerMode
35
+
36
+ cap = Capture(test_common.dev)
37
+ print(f"available channels: {cap.available_channels}")
38
+
39
+ # capture samples for (up to) the first three channels:
40
+ if len(cap.available_channels) < 1:
41
+ print(
42
+ "The meter has no channels configured - waveform cannot be acquired."
43
+ )
44
+ sys.exit(1)
45
+ elif len(cap.available_channels) > 3:
46
+ cap.channels = set(cap.available_channels[0:3])
47
+ else:
48
+ cap.channels = set(cap.available_channels)
49
+
50
+ # these settings are only needed for triggered captures:
51
+ cap.trigger_mode = TriggerMode.RISING
52
+ cap.trigger_channel = "L1"
53
+ cap.trigger_level = 0
54
+ cap.pretrigger = 0.0083 # how many seconds of data to keep ahead of trigger
55
+ cap.trigger_timeout = 1 # number of seconds before auto triggering
56
+
57
+ data = cap.acquire(duration=0.034)
58
+
59
+ # show the trigger point sample (if any):
60
+ if data.trigger_point is None:
61
+ print("Capture was auto-triggered.")
62
+ else:
63
+ print(data.trigger_point)
64
+
65
+ # plot the data:
66
+ _, axis = pyplot.subplots()
67
+ for chan in cap.channels:
68
+ axis.plot(data.samples[chan].ts, data.samples[chan].ys, label=chan)
69
+ axis.set(
70
+ xlabel="time [s]",
71
+ ylabel="[" + cap.channel_unit(chan) + "]",
72
+ title="captured waveform data",
73
+ )
74
+ axis.grid()
75
+ pyplot.legend(loc="upper right")
76
+ print("Plotting the waveform - close the plot window when done")
77
+ pyplot.show()
@@ -0,0 +1,26 @@
1
+ import os
2
+ import sys
3
+
4
+ from egauge import webapi
5
+
6
+ #
7
+ # You can edit the values below directly or use environment variables
8
+ # EGDEV, EGUSR, and EGPWD to set the device URL, username, and
9
+ # password, respectively.
10
+ #
11
+ meter_dev = os.getenv("EGDEV", "http://egauge-dut")
12
+ meter_user = os.getenv("EGUSR", "dmo")
13
+ meter_password = os.getenv("EGPWD", "secret password")
14
+
15
+ dev = webapi.device.Device(
16
+ meter_dev, webapi.JWTAuth(meter_user, meter_password)
17
+ )
18
+
19
+ # verify we can talk to the meter:
20
+ try:
21
+ rights = dev.get("/auth/rights").get("rights", [])
22
+ except webapi.Error as e:
23
+ print(f"Sorry, failed to connect to {meter_dev}: {e}")
24
+ sys.exit(1)
25
+
26
+ print(f"Using meter {meter_dev} (user {meter_user}, rights={rights})")
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2023-2024 eGauge Systems LLC
4
+ #
5
+ # See LICENSE file for details.
6
+ #
7
+ """This test program demonstrates the use of class
8
+ egauge.webapi.device.CTidInfo to scan the information from a
9
+ CTid®-enabled sensor and/or to blink the locator LED on such a sensor.
10
+
11
+ Install egauge-python with a command of the form:
12
+
13
+ pip install egauge-python[examples]
14
+
15
+ to ensure that the readchar module is installed on your system.
16
+
17
+ You can set environment variables:
18
+
19
+ EGDEV - the URL of the meter to use (e.g., http://eGaugeXXX.local)
20
+ EGUSR - the username to log in to the meter (e.g., "owner")
21
+ EGPWD - the password for the username
22
+
23
+ Alternatively, you can edit examples/test_common.py to suit your needs.
24
+
25
+ """
26
+
27
+ import sys
28
+
29
+ import readchar
30
+
31
+ from egauge.examples import test_common
32
+ from egauge.webapi.device import CTidInfo, PortInfo
33
+
34
+
35
+ def ask_port():
36
+ result = input("Port number (1-30): ")
37
+ if result.startswith("q"):
38
+ sys.exit(0)
39
+ return int(result)
40
+
41
+
42
+ def print_port_info(port_info):
43
+ port = port_info.port
44
+ mfg = port_info.short_mfg_name()
45
+ model = port_info.model_name()
46
+ print(f"\tS{port}: {mfg} {model}")
47
+
48
+
49
+ ctid_info = CTidInfo(test_common.dev)
50
+ print("ports with existing CTid® info:")
51
+
52
+ for port_info in ctid_info:
53
+ print_port_info(port_info)
54
+
55
+ port = None
56
+
57
+ while True:
58
+ while port is None:
59
+ port = ask_port()
60
+ sys.stdout.write(
61
+ "Press b (blink), s (scan), c (change port), d (delete), q (quit): "
62
+ )
63
+ sys.stdout.flush()
64
+ ch = readchar.readchar()
65
+ print()
66
+ if ch == "s":
67
+ print(f" scanning port S{port}...")
68
+ result = ctid_info.scan(port)
69
+ if isinstance(result, PortInfo):
70
+ if result.table:
71
+ print(" detected sensor:")
72
+ print_port_info(result)
73
+ else:
74
+ print(" no CTid® sensor detected")
75
+ else:
76
+ print(f" scan of port S{port} failed: {result}")
77
+ elif ch == "b":
78
+ print(f" blinking port S{port}...")
79
+ ctid_info.flash(port)
80
+ elif ch == "c":
81
+ port = None
82
+ elif ch == "d":
83
+ print(f" deleting info saved for S{port}...")
84
+ ctid_info.delete(port)
85
+ elif ch == "q":
86
+ print("Done.")
87
+ break
88
+
89
+ ctid_info.stop()
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2016-2017, 2024 eGauge Systems LLC
4
+ #
5
+ # See LICENSE file for details.
6
+ #
7
+ """This program is a simple demonstration of how the ctid.Decoder()
8
+ can be used to decode the CTid info from a waveform consisting of a
9
+ series of timestamped samples. The data is read from
10
+ `examples/data/test-ctid-decoder.raw`, which contains data sampled at
11
+ 8kHz.
12
+
13
+ """
14
+
15
+ import pickle
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from egauge import ctid
20
+
21
+
22
+ def fmt_ts(timestamp: float) -> str:
23
+ """Format a timestamp in a human-readable format.
24
+
25
+ Required arguments:
26
+
27
+ timestamp -- A timestamp measured in microseconds.
28
+
29
+ """
30
+ sec = 1e-6 * timestamp
31
+ return f"{sec:.3f} sec"
32
+
33
+
34
+ def decode(
35
+ sample_rate: float, ts_list: list[float], values: list[float]
36
+ ) -> ctid.Table | None:
37
+ """Decode a series of samples and convert them to a CTid table.
38
+
39
+ Returns a ctid.Table object as soon as the first CTid table has
40
+ been successfully decoded or None if no CTid table was found.
41
+
42
+ Required arguments:
43
+
44
+ sampling_rate -- The average frequency at which the samples were
45
+ acquired. This is used to properly size the decoding window
46
+ size. Assuming a sufficient number of samples, this could be
47
+ inferred from `ts_list`.
48
+
49
+ ts_list -- The list of timestamps, measured in microseconds.
50
+
51
+ values -- The list of sample values. This must be the same length
52
+ as `ts_list`.
53
+
54
+ """
55
+ table_data = b""
56
+ decoder = ctid.Decoder(sample_rate)
57
+ for i, val in enumerate(values):
58
+ ts = ts_list[i]
59
+ ret = decoder.add_sample(ts, val)
60
+ if ret < 0:
61
+ print(f"found start symbol at {fmt_ts(ts)}")
62
+ table_data = b""
63
+ elif ret > 0:
64
+ byte = decoder.get_byte()
65
+ idx = len(table_data)
66
+ print(f"decoded byte {idx:2}: 0x{byte:02x} at {fmt_ts(ts)}")
67
+ table_data += byte.to_bytes(1)
68
+ try:
69
+ table = ctid.Table(table_data)
70
+ return table
71
+ except ctid.CRCError as e:
72
+ print(f"Error: {e}")
73
+ decoder = ctid.Decoder(sample_rate)
74
+ table_data = b""
75
+ except ctid.Error:
76
+ pass # need more data
77
+ return None
78
+
79
+
80
+ input_path = Path(__file__).parent / "data" / "test-ctid-decoder.raw"
81
+
82
+ with open(input_path, "rb") as f:
83
+ (freq, ts, samples) = pickle.load(f)
84
+
85
+ print(f"decoding data (sampling rate {freq:.1f} Hz)...")
86
+
87
+ table = decode(freq, ts, samples)
88
+
89
+ if table is None:
90
+ print("Sorry, no CTid table detected...", file=sys.stderr)
91
+ sys.exit(1)
92
+
93
+ print(f"Received: {table}")