Commit 02a86c97bb58037f3cd4c1b4771a7dcf1adbd391
1 parent
802a2f1110
Exists in
main
copy and tweak tc08 monitor code from ssa project
Showing 5 changed files with 363 additions and 0 deletions Side-by-side Diff
.gitignore
View file @
02a86c9
influxdb_bridge.py
View file @
02a86c9
1 | +import datetime | |
2 | +import influxdb | |
3 | + | |
4 | + | |
5 | +class InfluxDBBridge: | |
6 | + def __init__(self): | |
7 | + # todo make this stuff configurable | |
8 | + self.host = "localhost" | |
9 | + self.port = 8086 | |
10 | + self.username = None | |
11 | + self.password = None | |
12 | + self.dbname = "logger" | |
13 | + | |
14 | + def connect(self): | |
15 | + # create and open client | |
16 | + self.client = influxdb.InfluxDBClient( | |
17 | + self.host, self.port, self.username, self.password, self.dbname | |
18 | + ) | |
19 | + # try to create db just in case | |
20 | + try: | |
21 | + self.client.create_database(self.dbname) | |
22 | + except influxdb.client.InfluxDBClientError: | |
23 | + # db already exists, proceed | |
24 | + pass | |
25 | + | |
26 | + def commit(self, json_body): | |
27 | + self.client.write_points(json_body, time_precision="ms") |
tc08_device.py
View file @
02a86c9
1 | +# inspired by example at | |
2 | +# https://github.com/picotech/picosdk-python-wrappers/blob/master/usbtc08Examples/tc08StreamingModeExample.py | |
3 | +# | |
4 | +# API docs | |
5 | +# https://www.picotech.com/download/manuals/usb-tc08-thermocouple-data-logger-programmers-guide.pdf | |
6 | + | |
7 | +import ctypes | |
8 | +import signal | |
9 | +import time | |
10 | +from threading import Timer | |
11 | +from picosdk.usbtc08 import usbtc08 as tc08 | |
12 | +from picosdk.usbtc08 import USBTC08_INFO | |
13 | + | |
14 | +DEBUG = False | |
15 | + | |
16 | + | |
17 | +def dprint(arg): | |
18 | + if DEBUG: | |
19 | + print(arg) | |
20 | + | |
21 | + | |
22 | +ERROR_CODES = [ | |
23 | + "USBTC08_ERROR_OK", | |
24 | + "USBTC08_ERROR_OS_NOT_SUPPORTED", | |
25 | + "USBTC08_ERROR_NO_CHANNELS_SET", | |
26 | + "USBTC08_ERROR_INVALID_PARAMETER", | |
27 | + "USBTC08_ERROR_VARIANT_NOT_SUPPORTED", | |
28 | + "USBTC08_ERROR_INCORRECT_MODE", | |
29 | + "USBTC08_ERROR_ENUMERATION_INCOMPLETE", | |
30 | + "USBTC08_ERROR_NOT_RESPONDING", | |
31 | + "USBTC08_ERROR_FW_FAIL", | |
32 | + "USBTC08_ERROR_CONFIG_FAIL", | |
33 | + "USBTC08_ERROR_NOT_FOUND", | |
34 | + "USBTC08_ERROR_THREAD_FAIL", | |
35 | + "USBTC08_ERROR_PIPE_INFO_FAIL", | |
36 | + "USBTC08_ERROR_NOT_CALIBRATED", | |
37 | + "USBTC08_ERROR_PICOPP_TOO_OLD", | |
38 | + "USBTC08_ERROR_COMMUNICATION", | |
39 | +] | |
40 | + | |
41 | + | |
42 | +class TC08: | |
43 | + def __init__(self, handle): | |
44 | + self.handle = handle | |
45 | + # populate info about self | |
46 | + self.info = USBTC08_INFO() | |
47 | + # must set size attribute before any call to get_unit_info | |
48 | + self.info.size = ctypes.sizeof(USBTC08_INFO) | |
49 | + rc = tc08.usb_tc08_get_unit_info(self.handle, ctypes.byref(self.info)) | |
50 | + TC08.check_return(self.handle, rc) | |
51 | + | |
52 | + def __str__(self): | |
53 | + return ( | |
54 | + f"TC08 Oject\n" | |
55 | + f"\tHandle: {self.handle}\n" | |
56 | + f"\tSerial Number: {self.get_serial()}" | |
57 | + ) | |
58 | + | |
59 | + def get_serial(self): | |
60 | + return getattr(self.info, "szSerial[USBTC08_MAX_SERIAL_CHAR]").decode() | |
61 | + | |
62 | + # static method to open and return a new TC08 | |
63 | + @staticmethod | |
64 | + def get_any_tc08(): | |
65 | + open_status = tc08.usb_tc08_open_unit() | |
66 | + if open_status > 0: | |
67 | + # successfully opened a tc08 unit, create a TC08 object using that handle | |
68 | + handle = open_status | |
69 | + dprint(f"Found device handle {handle}") | |
70 | + device = TC08(handle) | |
71 | + return device | |
72 | + elif open_status == 0: | |
73 | + # no more units found, return a None object | |
74 | + return None | |
75 | + else: | |
76 | + # error occured with the open_unit call, try to grab an error code | |
77 | + TC08.check_return(0, open_status) | |
78 | + | |
79 | + # helper to check return codes | |
80 | + @staticmethod | |
81 | + def check_return(handle, rc): | |
82 | + if rc <= 0: | |
83 | + dprint(f"last return code: {rc}") | |
84 | + # tc08 functions seem to return 0 or -1 on failure, in which case | |
85 | + # we can querry get_last_error for a more detailed error code | |
86 | + error_code = tc08.usb_tc08_get_last_error(handle) | |
87 | + if error_code == -1: | |
88 | + raise Exception( | |
89 | + f"(handle {handle}) Error retrieving last error (oh the irony) (last return code: {rc})" | |
90 | + ) | |
91 | + if error_code == 0: | |
92 | + # error_code of 0 from usb_tc08_get_last_error is USBTC08_ERROR_OK - "No error occurred" | |
93 | + return | |
94 | + if error_code >= 0 and error_code < len(ERROR_CODES): | |
95 | + raise Exception( | |
96 | + f"(handle {handle}) Error {error_code}: {ERROR_CODES[error_code]}" | |
97 | + ) | |
98 | + else: | |
99 | + raise Exception(f"(handle {handle}) Unknown error code: {error_code}") | |
100 | + | |
101 | + # non-static version | |
102 | + def check(self, rc): | |
103 | + return TC08.check_return(self.handle, rc) | |
104 | + | |
105 | + # must be called before any get_data calls | |
106 | + def setup(self): | |
107 | + # set mains rejection to 60hz | |
108 | + rc = tc08.usb_tc08_set_mains(self.handle, 1) | |
109 | + self.check(rc) | |
110 | + | |
111 | + # set up cold junction channel 0 | |
112 | + rc = tc08.usb_tc08_set_channel(self.handle, 0, ord("C")) | |
113 | + self.check(rc) | |
114 | + # set up specified channels as typeK | |
115 | + for channel in range(1, 9): | |
116 | + rc = tc08.usb_tc08_set_channel(self.handle, channel, ord("K")) | |
117 | + self.check(rc) | |
118 | + | |
119 | + # Invoke single get_data call, use time.time() to get approximate timestamp | |
120 | + # Returns tuple of ([channel_0, channel_1, ..., channel_9], timestamp_ms, overflow) | |
121 | + def get_single_data(self): | |
122 | + timestamp_sec = time.time() | |
123 | + timestamp_ms = int(timestamp_sec * 1000) | |
124 | + temperature_data = (ctypes.c_float * 9)() | |
125 | + overflow = ctypes.c_int16(0) | |
126 | + rc = tc08.usb_tc08_get_single( | |
127 | + self.handle, | |
128 | + ctypes.byref(temperature_data), | |
129 | + ctypes.byref(overflow), | |
130 | + tc08.USBTC08_UNITS["USBTC08_UNITS_CENTIGRADE"], | |
131 | + ) | |
132 | + self.check(rc) | |
133 | + data = [] | |
134 | + for i in range(0, 9): | |
135 | + data.append(temperature_data[i]) | |
136 | + return (data, timestamp_ms) | |
137 | + | |
138 | + def close(self): | |
139 | + rc = tc08.usb_tc08_close_unit(self.handle) | |
140 | + self.check(rc) | |
141 | + | |
142 | + # | |
143 | + # functions for using streaming mode | |
144 | + # | |
145 | + | |
146 | + def run(self, sampling_interval_ms): | |
147 | + # get minimum sampling interval in ms as sanity check | |
148 | + rc = tc08.usb_tc08_get_minimum_interval_ms(self.handle) | |
149 | + self.check(rc) | |
150 | + min_sampling_interval_ms = rc | |
151 | + | |
152 | + interval_ms = max(sampling_interval_ms, min_sampling_interval_ms) | |
153 | + rc = tc08.usb_tc08_run(self.handle, interval_ms) | |
154 | + self.check(rc) | |
155 | + self.start_time_sec = time.time() | |
156 | + | |
157 | + def stop(self): | |
158 | + rc = tc08.usb_tc08_stop(self.handle) | |
159 | + self.check(rc) | |
160 | + | |
161 | + # tc08 timestamps readings with ms since usb_tc08_run called | |
162 | + def convert_time(self, relative_time_ms): | |
163 | + return int((self.start_time_sec * 1000) + relative_time_ms) | |
164 | + | |
165 | + # Return list of lists of readings for configured channels specified in setup | |
166 | + # Each element of array is a (temperature, timestamp, overflow) tuple | |
167 | + # e.g. | |
168 | + # [ | |
169 | + # 0: [(channel0_temp1, time1, o1), (channel0_temp2, time2, o2)], | |
170 | + # 1: [(channel1_temp1, time1, o1), (channel1_temp2, time2, o2)], | |
171 | + # ... | |
172 | + # 9: [(channel9_temp1, time1, o1), (channel9_temp2, time2, o2)] | |
173 | + # ] | |
174 | + def get_streaming_data(self): | |
175 | + data = [] | |
176 | + buffer_size = 8 | |
177 | + temperature_buffer = (ctypes.c_float * buffer_size)() | |
178 | + times_ms_buffer = (ctypes.c_int32 * buffer_size)() | |
179 | + overflow = ctypes.c_int16() | |
180 | + for channel in range(0, 9): | |
181 | + # load in up to buffer_size readings from current channel | |
182 | + num_readings = tc08.usb_tc08_get_temp( | |
183 | + self.handle, | |
184 | + ctypes.byref(temperature_buffer), | |
185 | + ctypes.byref(times_ms_buffer), | |
186 | + buffer_size, | |
187 | + ctypes.byref(overflow), | |
188 | + channel, | |
189 | + tc08.USBTC08_UNITS["USBTC08_UNITS_CENTIGRADE"], | |
190 | + 0, | |
191 | + ) | |
192 | + # if num_readings is -1, there was an error | |
193 | + if num_readings < 0: | |
194 | + self.check(num_readings) | |
195 | + elif num_readings == 0: | |
196 | + # no data, nothing to do | |
197 | + dprint("no data read") | |
198 | + else: | |
199 | + channel_data = [] | |
200 | + for i in range(0, num_readings): | |
201 | + temperature = temperature_buffer[i] | |
202 | + # remember to conver time from relative to absolute timestamp | |
203 | + time_ms = self.convert_time(times_ms_buffer[i]) | |
204 | + channel_data.append((temperature, time_ms, overflow.value)) | |
205 | + data.append(channel_data) | |
206 | + return data |
temperature_logger.py
View file @
02a86c9
1 | +# inspired by example at | |
2 | +# https://github.com/picotech/picosdk-python-wrappers/blob/master/usbtc08Examples/tc08StreamingModeExample.py | |
3 | +# | |
4 | +# API docs | |
5 | +# https://www.picotech.com/download/manuals/usb-tc08-thermocouple-data-logger-programmers-guide.pdf | |
6 | + | |
7 | +import signal | |
8 | +from threading import Timer | |
9 | +from tc08_device import TC08 | |
10 | +from influxdb_bridge import InfluxDBBridge | |
11 | + | |
12 | +# how often does Monitor check for new data from the TC08 driver | |
13 | +LOG_INTERVAL_SEC = 5 | |
14 | + | |
15 | +DEBUG = False | |
16 | + | |
17 | + | |
18 | +def dprint(arg): | |
19 | + if DEBUG: | |
20 | + print(arg) | |
21 | + | |
22 | + | |
23 | +class Monitor(Timer): | |
24 | + def __init__(self, interval): | |
25 | + # Timer constructer expects a callback function, but we don't care | |
26 | + # about that, so just pass None | |
27 | + super(Monitor, self).__init__(interval, None) | |
28 | + self.idb = None | |
29 | + self.tc08s = list() | |
30 | + | |
31 | + # override Timer's run method to loop on our injest_data function | |
32 | + # wait will block while loop for timeout interval | |
33 | + # calling timer.cancel() sets the finish flag and exits the loop | |
34 | + def run(self): | |
35 | + dprint("in timer.run method") | |
36 | + while not self.finished.wait(timeout=self.interval): | |
37 | + self.do_work() | |
38 | + | |
39 | + # connect as many tc08's as we can find | |
40 | + def setup(self): | |
41 | + searching = True | |
42 | + while searching: | |
43 | + device = TC08.get_any_tc08() | |
44 | + if device is None: | |
45 | + dprint(f"No more TC08s to dicsover") | |
46 | + searching = False | |
47 | + else: | |
48 | + dprint(f"Found TC08: {device}") | |
49 | + self.tc08s.append(device) | |
50 | + | |
51 | + # setup and run | |
52 | + for device in self.tc08s: | |
53 | + device.setup() | |
54 | + device.run(LOG_INTERVAL_SEC * 1000) | |
55 | + | |
56 | + # open connection to influxdb | |
57 | + self.idb = InfluxDBBridge() | |
58 | + self.idb.connect() | |
59 | + | |
60 | + dprint("end setup") | |
61 | + | |
62 | + def cleaup(self): | |
63 | + for device in self.tc08s: | |
64 | + device.stop() | |
65 | + device.close() | |
66 | + | |
67 | + def do_work(self): | |
68 | + points = [] | |
69 | + for device in self.tc08s: | |
70 | + serial = device.get_serial() | |
71 | + data = device.get_streaming_data() | |
72 | + for channel in range(0, 9): | |
73 | + for reading in data[channel]: | |
74 | + (temperature, time_ms, overflow) = reading | |
75 | + point = self.make_point( | |
76 | + channel, | |
77 | + temperature, | |
78 | + time_ms, | |
79 | + overflow, | |
80 | + ) | |
81 | + points.append(point) | |
82 | + self.idb.commit(points) | |
83 | + | |
84 | + def make_point(self, channel, temperature, time_ms, overflow): | |
85 | + json_body = { | |
86 | + "measurement": "temperature", | |
87 | + "tags": {"channel": channel}, | |
88 | + "time": time_ms, | |
89 | + "fields": {"temperature": temperature, "overflow": overflow}, | |
90 | + } | |
91 | + return json_body | |
92 | + | |
93 | + def start_monitor(self): | |
94 | + self.setup() | |
95 | + self.start() | |
96 | + | |
97 | + def cancelISR(self, *args, **kwargs): | |
98 | + dprint("cancelISR called") | |
99 | + self.cancel() | |
100 | + for device in self.tc08s: | |
101 | + device.stop() | |
102 | + device.close() | |
103 | + | |
104 | + | |
105 | +if __name__ == "__main__": | |
106 | + monitor = Monitor(LOG_INTERVAL_SEC) | |
107 | + monitor.start_monitor() | |
108 | + dprint("monitor running") | |
109 | + signal.signal(signal.SIGINT, monitor.cancelISR) | |
110 | + signal.signal(signal.SIGTERM, monitor.cancelISR) | |
111 | + monitor.join() |
temperature_logger.service
View file @
02a86c9
1 | +[Unit] | |
2 | +Description=TC08 Temperature monitoring service | |
3 | +Requires=time-sync.target | |
4 | + | |
5 | +[Service] | |
6 | +Type=simple | |
7 | +User=pi | |
8 | +PIDFile=/path/to/repo/tc08-run.pid | |
9 | +ExecStart=python -m temperature_logger | |
10 | +WorkingDirectory=/path/to/repo/ | |
11 | +Restart=always | |
12 | +RestartSec=5 | |
13 | +StandardOutput=journal | |
14 | +StandardError=journal | |
15 | + | |
16 | +[Install] | |
17 | +WantedBy=multi-user.target |