diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4f1641 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +.vscode/ diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/influxdb_bridge.py b/influxdb_bridge.py new file mode 100644 index 0000000..0a1ef33 --- /dev/null +++ b/influxdb_bridge.py @@ -0,0 +1,27 @@ +import datetime +import influxdb + + +class InfluxDBBridge: + def __init__(self): + # todo make this stuff configurable + self.host = "localhost" + self.port = 8086 + self.username = None + self.password = None + self.dbname = "logger" + + def connect(self): + # create and open client + self.client = influxdb.InfluxDBClient( + self.host, self.port, self.username, self.password, self.dbname + ) + # try to create db just in case + try: + self.client.create_database(self.dbname) + except influxdb.client.InfluxDBClientError: + # db already exists, proceed + pass + + def commit(self, json_body): + self.client.write_points(json_body, time_precision="ms") diff --git a/tc08_device.py b/tc08_device.py new file mode 100644 index 0000000..1c7bdc9 --- /dev/null +++ b/tc08_device.py @@ -0,0 +1,206 @@ +# inspired by example at +# https://github.com/picotech/picosdk-python-wrappers/blob/master/usbtc08Examples/tc08StreamingModeExample.py +# +# API docs +# https://www.picotech.com/download/manuals/usb-tc08-thermocouple-data-logger-programmers-guide.pdf + +import ctypes +import signal +import time +from threading import Timer +from picosdk.usbtc08 import usbtc08 as tc08 +from picosdk.usbtc08 import USBTC08_INFO + +DEBUG = False + + +def dprint(arg): + if DEBUG: + print(arg) + + +ERROR_CODES = [ + "USBTC08_ERROR_OK", + "USBTC08_ERROR_OS_NOT_SUPPORTED", + "USBTC08_ERROR_NO_CHANNELS_SET", + "USBTC08_ERROR_INVALID_PARAMETER", + "USBTC08_ERROR_VARIANT_NOT_SUPPORTED", + "USBTC08_ERROR_INCORRECT_MODE", + "USBTC08_ERROR_ENUMERATION_INCOMPLETE", + "USBTC08_ERROR_NOT_RESPONDING", + "USBTC08_ERROR_FW_FAIL", + "USBTC08_ERROR_CONFIG_FAIL", + "USBTC08_ERROR_NOT_FOUND", + "USBTC08_ERROR_THREAD_FAIL", + "USBTC08_ERROR_PIPE_INFO_FAIL", + "USBTC08_ERROR_NOT_CALIBRATED", + "USBTC08_ERROR_PICOPP_TOO_OLD", + "USBTC08_ERROR_COMMUNICATION", +] + + +class TC08: + def __init__(self, handle): + self.handle = handle + # populate info about self + self.info = USBTC08_INFO() + # must set size attribute before any call to get_unit_info + self.info.size = ctypes.sizeof(USBTC08_INFO) + rc = tc08.usb_tc08_get_unit_info(self.handle, ctypes.byref(self.info)) + TC08.check_return(self.handle, rc) + + def __str__(self): + return ( + f"TC08 Oject\n" + f"\tHandle: {self.handle}\n" + f"\tSerial Number: {self.get_serial()}" + ) + + def get_serial(self): + return getattr(self.info, "szSerial[USBTC08_MAX_SERIAL_CHAR]").decode() + + # static method to open and return a new TC08 + @staticmethod + def get_any_tc08(): + open_status = tc08.usb_tc08_open_unit() + if open_status > 0: + # successfully opened a tc08 unit, create a TC08 object using that handle + handle = open_status + dprint(f"Found device handle {handle}") + device = TC08(handle) + return device + elif open_status == 0: + # no more units found, return a None object + return None + else: + # error occured with the open_unit call, try to grab an error code + TC08.check_return(0, open_status) + + # helper to check return codes + @staticmethod + def check_return(handle, rc): + if rc <= 0: + dprint(f"last return code: {rc}") + # tc08 functions seem to return 0 or -1 on failure, in which case + # we can querry get_last_error for a more detailed error code + error_code = tc08.usb_tc08_get_last_error(handle) + if error_code == -1: + raise Exception( + f"(handle {handle}) Error retrieving last error (oh the irony) (last return code: {rc})" + ) + if error_code == 0: + # error_code of 0 from usb_tc08_get_last_error is USBTC08_ERROR_OK - "No error occurred" + return + if error_code >= 0 and error_code < len(ERROR_CODES): + raise Exception( + f"(handle {handle}) Error {error_code}: {ERROR_CODES[error_code]}" + ) + else: + raise Exception(f"(handle {handle}) Unknown error code: {error_code}") + + # non-static version + def check(self, rc): + return TC08.check_return(self.handle, rc) + + # must be called before any get_data calls + def setup(self): + # set mains rejection to 60hz + rc = tc08.usb_tc08_set_mains(self.handle, 1) + self.check(rc) + + # set up cold junction channel 0 + rc = tc08.usb_tc08_set_channel(self.handle, 0, ord("C")) + self.check(rc) + # set up specified channels as typeK + for channel in range(1, 9): + rc = tc08.usb_tc08_set_channel(self.handle, channel, ord("K")) + self.check(rc) + + # Invoke single get_data call, use time.time() to get approximate timestamp + # Returns tuple of ([channel_0, channel_1, ..., channel_9], timestamp_ms, overflow) + def get_single_data(self): + timestamp_sec = time.time() + timestamp_ms = int(timestamp_sec * 1000) + temperature_data = (ctypes.c_float * 9)() + overflow = ctypes.c_int16(0) + rc = tc08.usb_tc08_get_single( + self.handle, + ctypes.byref(temperature_data), + ctypes.byref(overflow), + tc08.USBTC08_UNITS["USBTC08_UNITS_CENTIGRADE"], + ) + self.check(rc) + data = [] + for i in range(0, 9): + data.append(temperature_data[i]) + return (data, timestamp_ms) + + def close(self): + rc = tc08.usb_tc08_close_unit(self.handle) + self.check(rc) + + # + # functions for using streaming mode + # + + def run(self, sampling_interval_ms): + # get minimum sampling interval in ms as sanity check + rc = tc08.usb_tc08_get_minimum_interval_ms(self.handle) + self.check(rc) + min_sampling_interval_ms = rc + + interval_ms = max(sampling_interval_ms, min_sampling_interval_ms) + rc = tc08.usb_tc08_run(self.handle, interval_ms) + self.check(rc) + self.start_time_sec = time.time() + + def stop(self): + rc = tc08.usb_tc08_stop(self.handle) + self.check(rc) + + # tc08 timestamps readings with ms since usb_tc08_run called + def convert_time(self, relative_time_ms): + return int((self.start_time_sec * 1000) + relative_time_ms) + + # Return list of lists of readings for configured channels specified in setup + # Each element of array is a (temperature, timestamp, overflow) tuple + # e.g. + # [ + # 0: [(channel0_temp1, time1, o1), (channel0_temp2, time2, o2)], + # 1: [(channel1_temp1, time1, o1), (channel1_temp2, time2, o2)], + # ... + # 9: [(channel9_temp1, time1, o1), (channel9_temp2, time2, o2)] + # ] + def get_streaming_data(self): + data = [] + buffer_size = 8 + temperature_buffer = (ctypes.c_float * buffer_size)() + times_ms_buffer = (ctypes.c_int32 * buffer_size)() + overflow = ctypes.c_int16() + for channel in range(0, 9): + # load in up to buffer_size readings from current channel + num_readings = tc08.usb_tc08_get_temp( + self.handle, + ctypes.byref(temperature_buffer), + ctypes.byref(times_ms_buffer), + buffer_size, + ctypes.byref(overflow), + channel, + tc08.USBTC08_UNITS["USBTC08_UNITS_CENTIGRADE"], + 0, + ) + # if num_readings is -1, there was an error + if num_readings < 0: + self.check(num_readings) + elif num_readings == 0: + # no data, nothing to do + dprint("no data read") + else: + channel_data = [] + for i in range(0, num_readings): + temperature = temperature_buffer[i] + # remember to conver time from relative to absolute timestamp + time_ms = self.convert_time(times_ms_buffer[i]) + channel_data.append((temperature, time_ms, overflow.value)) + data.append(channel_data) + return data diff --git a/temperature_logger.py b/temperature_logger.py new file mode 100644 index 0000000..129985d --- /dev/null +++ b/temperature_logger.py @@ -0,0 +1,111 @@ +# inspired by example at +# https://github.com/picotech/picosdk-python-wrappers/blob/master/usbtc08Examples/tc08StreamingModeExample.py +# +# API docs +# https://www.picotech.com/download/manuals/usb-tc08-thermocouple-data-logger-programmers-guide.pdf + +import signal +from threading import Timer +from tc08_device import TC08 +from influxdb_bridge import InfluxDBBridge + +# how often does Monitor check for new data from the TC08 driver +LOG_INTERVAL_SEC = 5 + +DEBUG = False + + +def dprint(arg): + if DEBUG: + print(arg) + + +class Monitor(Timer): + def __init__(self, interval): + # Timer constructer expects a callback function, but we don't care + # about that, so just pass None + super(Monitor, self).__init__(interval, None) + self.idb = None + self.tc08s = list() + + # override Timer's run method to loop on our injest_data function + # wait will block while loop for timeout interval + # calling timer.cancel() sets the finish flag and exits the loop + def run(self): + dprint("in timer.run method") + while not self.finished.wait(timeout=self.interval): + self.do_work() + + # connect as many tc08's as we can find + def setup(self): + searching = True + while searching: + device = TC08.get_any_tc08() + if device is None: + dprint(f"No more TC08s to dicsover") + searching = False + else: + dprint(f"Found TC08: {device}") + self.tc08s.append(device) + + # setup and run + for device in self.tc08s: + device.setup() + device.run(LOG_INTERVAL_SEC * 1000) + + # open connection to influxdb + self.idb = InfluxDBBridge() + self.idb.connect() + + dprint("end setup") + + def cleaup(self): + for device in self.tc08s: + device.stop() + device.close() + + def do_work(self): + points = [] + for device in self.tc08s: + serial = device.get_serial() + data = device.get_streaming_data() + for channel in range(0, 9): + for reading in data[channel]: + (temperature, time_ms, overflow) = reading + point = self.make_point( + channel, + temperature, + time_ms, + overflow, + ) + points.append(point) + self.idb.commit(points) + + def make_point(self, channel, temperature, time_ms, overflow): + json_body = { + "measurement": "temperature", + "tags": {"channel": channel}, + "time": time_ms, + "fields": {"temperature": temperature, "overflow": overflow}, + } + return json_body + + def start_monitor(self): + self.setup() + self.start() + + def cancelISR(self, *args, **kwargs): + dprint("cancelISR called") + self.cancel() + for device in self.tc08s: + device.stop() + device.close() + + +if __name__ == "__main__": + monitor = Monitor(LOG_INTERVAL_SEC) + monitor.start_monitor() + dprint("monitor running") + signal.signal(signal.SIGINT, monitor.cancelISR) + signal.signal(signal.SIGTERM, monitor.cancelISR) + monitor.join() diff --git a/temperature_logger.service b/temperature_logger.service new file mode 100644 index 0000000..a90d76f --- /dev/null +++ b/temperature_logger.service @@ -0,0 +1,17 @@ +[Unit] +Description=TC08 Temperature monitoring service +Requires=time-sync.target + +[Service] +Type=simple +User=pi +PIDFile=/path/to/repo/tc08-run.pid +ExecStart=python -m temperature_logger +WorkingDirectory=/path/to/repo/ +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target \ No newline at end of file