From d613ee800fb74c07f69074ded00201c8689d616f Mon Sep 17 00:00:00 2001 From: Tom Rankin Date: Fri, 31 Aug 2018 19:17:12 -0500 Subject: [PATCH] making a build?? --- openroast/freshroastsr700/__init__.py | 1110 +++++++++++++++++++++++ openroast/freshroastsr700/exceptions.py | 22 + openroast/freshroastsr700/pid.py | 101 +++ openroast/freshroastsr700/utils.py | 35 + openroast/freshroastsr700/version.py | 1 + 5 files changed, 1269 insertions(+) create mode 100644 openroast/freshroastsr700/__init__.py create mode 100644 openroast/freshroastsr700/exceptions.py create mode 100644 openroast/freshroastsr700/pid.py create mode 100644 openroast/freshroastsr700/utils.py create mode 100644 openroast/freshroastsr700/version.py diff --git a/openroast/freshroastsr700/__init__.py b/openroast/freshroastsr700/__init__.py new file mode 100644 index 0000000..573b753 --- /dev/null +++ b/openroast/freshroastsr700/__init__.py @@ -0,0 +1,1110 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2015-2016 Mark Spicer +# Made available under the MIT license. + +import time +import datetime +import serial +import threading +import logging +import multiprocessing as mp +from multiprocessing import sharedctypes +import struct +import binascii + +from freshroastsr700 import pid +from freshroastsr700 import utils +from freshroastsr700 import exceptions + + +class freshroastsr700(object): + """A class to interface with a freshroastsr700 coffee roaster. + + Args: + update_data_func (func): A function to call when this object + receives new data from the hardware. Defaults to None. + + state_transition_func (func): A function to call when time_remaining + counts down to 0 and the device is either in roasting or cooling + state. Defaults to None. + + thermostat (bool): thermostat mode. + if set to True, turns on thermostat mode. In thermostat + mode, freshroastsr700 takes control of heat_setting and does + software PID control to hit the demanded target_temp. Defaults to + False. + + ext_sw_heater_drive (bool): enable direct control over the internal + heat_controller object. Defaults to False. When set to True, the + thermostat field is IGNORED, and assumed to be False. Direct + control over the software heater_level means that the freshroastsr700's + PID controller cannot control the heater. Since thermostat and + ext_sw_heater_drive cannot be allowed to both be True, this arg + is given precedence over the thermostat arg. Note that + (thermostat=False, ext_sw_heater_drive=False), + (thermostat=True, ext_sw_heater_drive=False), + (thermostat=False, ext_sw_heater_drive=True), + are all acceptable arg combinations. Only the + (thermostat=True, ext_sw_heater_drive=True), + cominbation is not allowed, and this software will set + thermostat=False in that case. + + kp (float): Kp value to use for PID control. Defaults to 0.06. + + ki (float): Ki value to use for PID control. Defaults to 0.0075. + + kd (float): Kd value to use for PID control. Defaults to 0.01. + + heater_segments (int): the pseudo-control range for the internal + heat_controller object. Defaults to 8. + + """ + def __init__(self, + update_data_func=None, + state_transition_func=None, + thermostat=False, + kp=0.06, ki=0.0075, kd=0.01, + heater_segments=8, + ext_sw_heater_drive=False): + """Create variables used to send in packets to the roaster. The update + data function is called when a packet is opened. The state transistion + function is used by the timer thread to know what to do next. See wiki + for more information on packet structure and fields.""" + # constants for protocol decoding + self.LOOKING_FOR_HEADER_1 = 0 + self.LOOKING_FOR_HEADER_2 = 1 + self.PACKET_DATA = 2 + self.LOOKING_FOR_FOOTER_2 = 3 + # constants for connection state monitoring + self.CS_NOT_CONNECTED = -2 + self.CS_ATTEMPTING_CONNECT = -1 + self.CS_CONNECTING = 0 + self.CS_CONNECTED = 1 + # constants for connection attempt type + self.CA_NONE = 0 + self.CA_AUTO = 1 + self.CA_SINGLE_SHOT = 2 + + self._create_update_data_system(update_data_func) + self._create_state_transition_system(state_transition_func) + + self._header = sharedctypes.Array('c', b'\xAA\xAA') + self._temp_unit = sharedctypes.Array('c', b'\x61\x74') + self._flags = sharedctypes.Array('c', b'\x63') + self._current_state = sharedctypes.Array('c', b'\x02\x01') + self._footer = b'\xAA\xFA' + + self._fan_speed = sharedctypes.Value('i', 1) + self._heat_setting = sharedctypes.Value('i', 0) + self._target_temp = sharedctypes.Value('i', 150) + self._current_temp = sharedctypes.Value('i', 150) + self._time_remaining = sharedctypes.Value('i', 0) + self._total_time = sharedctypes.Value('i', 0) + + self._disconnect = sharedctypes.Value('i', 0) + self._teardown = sharedctypes.Value('i', 0) + + self._cooling_for_pid_control = False + + # for SW PWM heater setting + self._heater_level = sharedctypes.Value('i', 0) + # the following vars are not process-safe, do not access them + # from the comm or timer threads, nor from the callbacks. + self._ext_sw_heater_drive = ext_sw_heater_drive + if not self._ext_sw_heater_drive: + self._thermostat = thermostat + else: + self._thermostat = False + self._pid_kp = kp + self._pid_ki = ki + self._pid_kd = kd + self._heater_bangbang_segments = heater_segments + + # initialize to 'not connected' + self._connected = sharedctypes.Value('i', 0) + self._connect_state = sharedctypes.Value('i', self.CS_NOT_CONNECTED) + # initialize to 'not trying to connect' + self._attempting_connect = sharedctypes.Value('i', self.CA_NONE) + + # create comm process + self.comm_process = mp.Process( + target=self._comm, + args=( + self._thermostat, + self._pid_kp, + self._pid_ki, + self._pid_kd, + self._heater_bangbang_segments, + self._ext_sw_heater_drive, + self.update_data_event,)) + self.comm_process.daemon = True + self.comm_process.start() + # create timer process that counts down time_remaining + self.time_process = mp.Process( + target=self._timer, + args=( + self.state_transition_event,)) + self.time_process.daemon = True + self.time_process.start() + + def _create_update_data_system( + self, update_data_func, setFunc=True, createThread=False): + # these callbacks cannot be called from another process in Windows. + # Therefore, spawn a thread belonging to the calling process + # instead. + # the comm and timer processes will set events that the threads + # will listen for to initiate the callbacks + + # only create the mp.Event once - + # to mimic create_state_transition_system, for future-proofing + # (in this case, currently, this is only called at __init__() time) + if not hasattr(self, 'update_data_event'): + self.update_data_event = mp.Event() + # only create the thread.Event once - this is used to exit + # the callback thread + if not hasattr(self, 'update_data_callback_kill_event'): + self.update_data_callback_kill_event = mp.Event() + # destroy an existing thread if we had created one previously + if(hasattr(self, 'update_data_thread') and + self.update_data_thread is not None): + # let's tear this down. To kill it, two events must be set... + # in the right sequence! + self.update_data_callback_kill_event.set() + self.update_data_event.set() + self.update_data_thread.join() + if setFunc: + self.update_data_func = update_data_func + if self.update_data_func is not None: + if createThread: + self.update_data_callback_kill_event.clear() + self.update_data_thread = threading.Thread( + name='sr700_update_data', + target=self.update_data_run, + args=(self.update_data_event,), + daemon=True + ) + else: + self.update_data_thread = None + + def _create_state_transition_system( + self, state_transition_func, setFunc=True, createThread=False): + # these callbacks cannot be called from another process in Windows. + # Therefore, spawn a thread belonging to the calling process + # instead. + # the comm and timer processes will set events that the threads + # will listen for to initiate the callbacks + + # only create the mp.Event once - this fn can get called more + # than once, by __init__() and by set_state_transition_func() + if not hasattr(self, 'state_transition_event'): + self.state_transition_event = mp.Event() + # only create the thread.Event once - this is used to exit + # the callback thread + if not hasattr(self, 'state_transition_callback_kill_event'): + self.state_transition_callback_kill_event = mp.Event() + # destroy an existing thread if we had created one previously + if(hasattr(self, 'state_transition_thread') and + self.state_transition_thread is not None): + # let's tear this down. To kill it, two events must be set... + # in the right sequence! + self.state_transition_callback_kill_event.set() + self.state_transition_event.set() + self.state_transition_thread.join() + if setFunc: + self.state_transition_func = state_transition_func + if self.state_transition_func is not None: + if createThread: + self.state_transition_callback_kill_event.clear() + self.state_transition_thread = threading.Thread( + name='sr700_state_transition', + target=self.state_transition_run, + args=(self.state_transition_event,), + daemon=True + ) + else: + self.state_transition_thread = None + + @property + def fan_speed(self): + """Get/Set fan speed. Can be 1 to 9 inclusive. + + Args: + Setter: fan_speed (int): fan speed + + Returns: + Getter: (int): fan speed + """ + return self._fan_speed.value + + @fan_speed.setter + def fan_speed(self, value): + """Verifies the value is between 1 and 9 inclusively.""" + if value not in range(1, 10): + raise exceptions.RoasterValueError + + self._fan_speed.value = value + + @property + def heat_setting(self): + """Get/Set heat setting, 0 to 3 inclusive. 0=off, 3=high. + Do not set when running freshroastsr700 in thermostat mode. + + Args: + Setter: heat_setting (int): heat setting + + Returns: + Getter: (int): heat setting + """ + return self._heat_setting.value + + @heat_setting.setter + def heat_setting(self, value): + """Verifies that the heat setting is between 0 and 3.""" + if value not in range(0, 4): + raise exceptions.RoasterValueError + + self._heat_setting.value = value + + @property + def target_temp(self): + """Get/Set the target temperature for this package's built-in software + PID controler. Only used when freshroastsr700 is instantiated with + thermostat=True. + + Args: + Setter: value (int): a target temperature in degF between 150 + and 551. + + Returns: + Getter: (int) target temperature in degF between 150 + and 551 + """ + return self._target_temp.value + + @target_temp.setter + def target_temp(self, value): + if value not in range(150, 551): + raise exceptions.RoasterValueError + + self._target_temp.value = value + + @property + def current_temp(self): + """Current temperature of the roast chamber as reported by hardware. + + Returns: + (int) current temperature, in degrees Fahrenheit + """ + return self._current_temp.value + + @current_temp.setter + def current_temp(self, value): + if value not in range(150, 551): + raise exceptions.RoasterValueError + + self._current_temp.value = value + + @property + def time_remaining(self): + """The amount of time, in seconds, remaining until a call to + the state_transition_func is made. can be set to an arbitrary value + up to 600 seconds at any time. When a new value is set, + freshroastsr700 will count down from this new value down to 0. + + time_remaining is decremented to 0 only when in a roasting or + cooling state. In other states, the value is not touched. + + Args: + Setter: time_remaining (int): tiem remaining in seconds + + Returns: + Getter: time_remaining(int): time remaining, in seconds + """ + return self._time_remaining.value + + @time_remaining.setter + def time_remaining(self, value): + self._time_remaining.value = value + + @property + def total_time(self): + """The total time this instance has been in roasting or cooling + state sicne the latest roast began. + + Returns: + total_time (int): time, in seconds + """ + return self._total_time.value + + @total_time.setter + def total_time(self, value): + self._total_time.value = value + + @property + def heater_level(self): + """A getter method for _heater_level. + When thermostat=True, value is driven by built-in PID controller. + When ext_sw_heater_drive=True, value is driven by calls to + heater_level(). + Min will always be zero, max will be heater_segments + (optional instantiation parameter, defaults to 8).""" + return self._heater_level.value + + @heater_level.setter + def heater_level(self, value): + """Verifies that the heater_level is between 0 and heater_segments. + Can only be called when freshroastsr700 object is initialized + with ext_sw_heater_drive=True. Will throw RoasterValueError + otherwise.""" + if self._ext_sw_heater_drive: + if value not in range(0, self._heater_bangbang_segments+1): + raise exceptions.RoasterValueError + self._heater_level.value = value + else: + raise exceptions.RoasterValueError + + @property + def connected(self): + """A getter method for _connected. Indicates that the + this software is currently communicating with FreshRoast SR700 + hardware.""" + return self._connected.value + + @property + def connect_state(self): + """A getter method for _connect_state. Indicates the current + connection state this software is in for FreshRoast SR700 + hardware. + Returns: + freshroastsr700.CS_NOT_CONNECTED + the software is not currenting communicating with hardware, + neither was it instructed to do so. + A previously failed connection attempt will also result + in this state. + freshroastsr700.CS_ATTEMPTING_CONNECT + A call to auto_connect() or connect() was made, and the + software is currently attempting to connect to hardware. + freshroastsr700.CS_CONNECTED + The hardware was found, and the software is communicating + with the hardware. + """ + return self._connect_state.value + + def set_state_transition_func(self, func): + """THIS FUNCTION MUST BE CALLED BEFORE CALLING + freshroastsr700.auto_connect(). + + Set, or re-set, the state transition function callback. + The supplied function will be called from a separate thread within + freshroastsr700, triggered by a separate, internal child process. + This function will fail if the freshroastsr700 device is already + connected to hardware, because by that time, the timer process + and thread have already been spawned. + + Args: + state_transition_func (func): the function to call for every + state transition. A state transition occurs whenever the + freshroastsr700's time_remaining value counts down to 0. + + Returns: + nothing + """ + if self._connected.value: + logging.error("freshroastsr700.set_state_transition_func must be " + "called before freshroastsr700.auto_connect()." + " Not registering func.") + return False + # no connection yet. so OK to set func pointer + self._create_state_transition_system(func) + return True + + def update_data_run(self, event_to_wait_on): + """This is the thread that listens to an event from + the comm process to execute the update_data_func callback + in the context of the main process. + """ + # with the daemon=Turue setting, this thread should + # quit 'automatically' + while event_to_wait_on.wait(): + event_to_wait_on.clear() + if self.update_data_callback_kill_event.is_set(): + return + self.update_data_func() + + def state_transition_run(self, event_to_wait_on): + """This is the thread that listens to an event from + the timer process to execute the state_transition_func callback + in the context of the main process. + """ + # with the daemon=Turue setting, this thread should + # quit 'automatically' + while event_to_wait_on.wait(): + event_to_wait_on.clear() + if self.state_transition_callback_kill_event.is_set(): + return + self.state_transition_func() + + def _connect(self): + """Do not call this directly - call auto_connect() or connect(), + which will call _connect() for you. + + Connects to the roaster and creates communication thread. + Raises a RoasterLokkupError exception if the hardware is not found. + """ + # the following call raises a RoasterLookupException when the device + # is not found. It is + port = utils.find_device('1A86:5523') + # on some systems, after the device port is added to the device list, + # it can take up to 20 seconds after USB insertion for + # the port to become available... (!) + # let's put a safety timeout in here as a precaution + wait_timeout = time.time() + 40.0 # should be PLENTY of time! + # let's update the _connect_state while we're at it... + self._connect_state.value = self.CS_CONNECTING + connect_success = False + while time.time() < wait_timeout: + try: + self._ser = serial.Serial( + port=port, + baudrate=9600, + bytesize=8, + parity='N', + stopbits=1.5, + timeout=0.25, + xonxoff=False, + rtscts=False, + dsrdtr=False) + connect_success = True + break + except serial.SerialException: + time.sleep(0.5) + if not connect_success: + # timeout on attempts + raise exceptions.RoasterLookupError + + self._initialize() + + def _initialize(self): + """Sends the initialization packet to the roaster.""" + self._header.value = b'\xAA\x55' + self._current_state.value = b'\x00\x00' + s = self._generate_packet() + self._ser.write(s) + self._header.value = b'\xAA\xAA' + self._current_state.value = b'\x02\x01' + + return self._read_existing_recipe() + + def _write_to_device(self): + success = False + try: + packet = self._generate_packet() + logging.debug('WR: ' + str(binascii.hexlify(packet))) + self._ser.write(packet) + success = True + except serial.serialutil.SerialException: + logging.error('caught serial exception writing') + return success + + def _read_from_device(self): + r = [] + footer_reached = False + while len(r) < 14 and footer_reached is False: + r.append(self._ser.read(1)) + if len(r) >= 2 and b''.join(r)[-2:] == self._footer: + footer_reached = True + logging.debug('RD: ' + str(binascii.hexlify(b''.join(r)))) + return b''.join(r) + + def _read_existing_recipe(self): + existing_recipe = [] + end_of_recipe = False + while not end_of_recipe: + bytes_waiting = self._ser.in_waiting + if bytes_waiting < 14: + # still need to write to device every .25sec it seems + time.sleep(0.25) + self._write_to_device() + else: + while (bytes_waiting // 14) > 0: + r = self._read_from_device() + bytes_waiting = self._ser.in_waiting + if len(r) < 14: + logging.warn('short packet length') + else: + existing_recipe.append(r) + if r[4:5] == b'\xAF' or r[4:5] == b'\x00': + end_of_recipe = True + continue + return existing_recipe + + def connect(self): + """Attempt to connect to hardware immediately. Will not retry. + Check freshroastsr700.connected or freshroastsr700.connect_state + to verify result. + Raises: + freshroastsr700.exeptions.RoasterLookupError + No hardware connected to the computer. + """ + self._start_connect(self.CA_SINGLE_SHOT) + while(self._connect_state.value == self.CS_ATTEMPTING_CONNECT or + self._connect_state.value == self.CS_CONNECTING): + time.sleep(0.1) + if self.CS_CONNECTED != self._connect_state.value: + raise exceptions.RoasterLookupError + + def auto_connect(self): + """Starts a thread that will automatically connect to the roaster when + it is plugged in.""" + self._start_connect(self.CA_AUTO) + + def _start_connect(self, connect_type): + """Starts the connection process, as called (internally) + from the user context, either from auto_connect() or connect(). + Never call this from the _comm() process context. + """ + if self._connect_state.value != self.CS_NOT_CONNECTED: + # already done or in process, assume success + return + + self._connected.value = 0 + self._connect_state.value = self.CS_ATTEMPTING_CONNECT + # tell comm process to attempt connection + self._attempting_connect.value = connect_type + + # EXTREMELY IMPORTANT - for this to work at all in Windows, + # where the above processes are spawned (vs forked in Unix), + # the thread objects (as sattributes of this object) must be + # assigned to this object AFTER we have spawned the processes. + # That way, multiprocessing can pickle the freshroastsr700 + # successfully. (It can't pickle thread-related stuff.) + if self.update_data_func is not None: + # Need to launch the thread that will listen to the event + self._create_update_data_system( + None, setFunc=False, createThread=True) + self.update_data_thread.start() + if self.state_transition_func is not None: + # Need to launch the thread that will listen to the event + self._create_state_transition_system( + None, setFunc=False, createThread=True) + self.state_transition_thread.start() + + def _auto_connect(self): + """Attempts to connect to the roaster every quarter of a second.""" + while not self._teardown.value: + try: + self._connect() + return True + except exceptions.RoasterLookupError: + time.sleep(.25) + return False + + def disconnect(self): + """Stops the communication loop to the roaster. Note that this will not + actually stop the roaster itself.""" + self._disconnect.value = 1 + + def terminate(self): + """Stops the communication loop to the roaster and closes down all + communication processes. Note that this will not + actually stop the roaster itself. + You will need to instantiate a new freshroastsr700 object after + calling this function, in order to re-start communications with + the hardware. + """ + self.disconnect() + self._teardown.value = 1 + + def _comm(self, thermostat=False, + kp=0.06, ki=0.0075, kd=0.01, + heater_segments=8, ext_sw_heater_drive=False, + update_data_event=None): + """Do not call this directly - call auto_connect(), which will spawn + comm() for you. + + This is the main communications loop to the roaster. + whenever a valid packet is received from the device, if an + update_data_event is available, it will be signalled. + + Args: + thermostat (bool): thermostat mode. + if set to True, turns on thermostat mode. In thermostat + mode, freshroastsr700 takes control of heat_setting and does + software PID control to hit the demanded target_temp. + + ext_sw_heater_drive (bool): enable direct control over the internal + heat_controller object. Defaults to False. When set to True, the + thermostat field is IGNORED, and assumed to be False. Direct + control over the software heater_level means that the + PID controller cannot control the heater. Since thermostat and + ext_sw_heater_drive cannot be allowed to both be True, this arg + is given precedence over the thermostat arg. + + kp (float): Kp value to use for PID control. Defaults to 0.06. + + ki (float): Ki value to use for PID control. Defaults to 0.0075. + + kd (float): Kd value to use for PID control. Defaults to 0.01. + + heater_segments (int): the pseudo-control range for the internal + heat_controller object. Defaults to 8. + + update_data_event (multiprocessing.Event): If set, allows the + comm_process to signal to the parent process that new device data + is available. + + Returns: + nothing + """ + # since this process is started with daemon=True, it should exit + # when the owning process terminates. Therefore, safe to loop forever. + while not self._teardown.value: + + # waiting for command to attempt connect + # print( "waiting for command to attempt connect") + while self._attempting_connect.value == self.CA_NONE: + time.sleep(0.25) + if self._teardown.value: + break + # if we're tearing down, bail now. + if self._teardown.value: + break + + # we got the command to attempt to connect + # change state to 'attempting_connect' + self._connect_state.value = self.CS_ATTEMPTING_CONNECT + # attempt connection + if self.CA_AUTO == self._attempting_connect.value: + # this call will block until a connection is achieved + # it will also set _connect_state to CS_CONNECTING + # if appropriate + if self._auto_connect(): + # when we unblock, it is an indication of a successful + # connection + self._connected.value = 1 + self._connect_state.value = self.CS_CONNECTED + else: + # failure, normally due to a timeout + self._connected.value = 0 + self._connect_state.value = self.CS_NOT_CONNECTED + # we failed to connect - start over from the top + # reset flag + self._attempting_connect.value = self.CA_NONE + continue + + elif self.CA_SINGLE_SHOT == self._attempting_connect.value: + # try once, now, if failure, start teh big loop over + try: + self._connect() + self._connected.value = 1 + self._connect_state.value = self.CS_CONNECTED + except exceptions.RoasterLookupError: + self._connected.value = 0 + self._connect_state.value = self.CS_NOT_CONNECTED + if self._connect_state.value != self.CS_CONNECTED: + # we failed to connect - start over from the top + # reset flag + self._attempting_connect.value = self.CA_NONE + continue + else: + # shouldn't be here + # reset flag + self._attempting_connect.value = self.CA_NONE + continue + + # We are connected! + # print( "We are connected!") + # reset flag right away + self._attempting_connect.value = self.CA_NONE + + # Initialize PID controller if thermostat function was specified at + # init time + pidc = None + heater = None + if(thermostat): + pidc = pid.PID(kp, ki, kd, + Output_max=heater_segments, + Output_min=0 + ) + if thermostat or ext_sw_heater_drive: + heater = heat_controller(number_of_segments=heater_segments) + + read_state = self.LOOKING_FOR_HEADER_1 + r = [] + write_errors = 0 + read_errors = 0 + while not self._disconnect.value: + start = datetime.datetime.now() + # write to device + if not self._write_to_device(): + logging.error('comm - _write_to_device() failed!') + write_errors += 1 + if write_errors > 3: + # it's time to consider the device as being "gone" + logging.error('comm - 3 successive write ' + 'failures, disconnecting.') + self._disconnect.value = 1 + continue + else: + # reset write_errors + write_errors = 0 + + # read from device + try: + while self._ser.in_waiting: + _byte = self._ser.read(1) + read_state, r, err = ( + self._process_reponse_byte( + read_state, _byte, r, update_data_event)) + except IOError: + # typically happens when device is suddenly unplugged + logging.error('comm - read from device failed!') + read_errors += 1 + if write_errors > 3: + # it's time to consider the device as being "gone" + logging.error('comm - 3 successive read ' + 'failures, disconnecting.') + self._disconnect.value = 1 + continue + else: + read_errors = 0 + + # next, drive SW heater when using + # thermostat mode (PID controller calcs) + # or in external sw heater drive mode, + # when roasting. + if thermostat or ext_sw_heater_drive: + if ('roasting' == self.get_roaster_state() or self._cooling_for_pid_control): + if heater.about_to_rollover(): + # it's time to use the PID controller value + # and set new output level on heater! + if ext_sw_heater_drive: + # read user-supplied value + heater.heat_level = self._heater_level.value + else: + # thermostat + output = pidc.update( + self.current_temp, self.target_temp) + heater.heat_level = output + # make this number visible to other processes... + self._heater_level.value = heater.heat_level + # read bang-bang heater output array element & apply it + if heater.generate_bangbang_output(): + # ON + self.heat_setting = 3 + self.roast() + else: + # OFF + self.heat_setting = 0 + self.cool(True) + else: + # for all other states, heat_level = OFF + heater.heat_level = 0 + # make this number visible to other processes... + self._heater_level.value = heater.heat_level + self.heat_setting = 0 + + # calculate sleep time to stick to 0.25sec period + comp_time = datetime.datetime.now() - start + sleep_duration = 0.25 - comp_time.total_seconds() + if sleep_duration > 0: + time.sleep(sleep_duration) + + self._ser.close() + # reset disconnect flag + self._disconnect.value = 0 + # reset connection values + self._connected.value = 0 + self._connect_state.value = self.CS_NOT_CONNECTED + # print("We are disconnected.") + + def _process_reponse_byte(self, read_state, _byte, r, update_data_event): + err = False + if self.LOOKING_FOR_HEADER_1 == read_state: + if b'\xAA' == _byte: + read_state = self.LOOKING_FOR_HEADER_2 + elif self.LOOKING_FOR_HEADER_2 == read_state: + if b'\xAA' == _byte: + read_state = self.PACKET_DATA + # reset packet array now... + r = [] + else: + read_state = self.LOOKING_FOR_HEADER_1 + elif self.PACKET_DATA == read_state: + if b'\xAA' == _byte: + # this could be the start of an end of packet marker + read_state = self.LOOKING_FOR_FOOTER_2 + else: + r.append(_byte) + # SR700 FW bug - if current temp is 250 degF (0xFA), + # the FW does not transmit the footer at all. + # fake the footer here. + if(len(r) == 10 and b'\xFA' == _byte): + # we will 'fake' a footer to make this decoder work + # as intended + read_state, r, _ = ( + self._process_reponse_byte( + read_state, b'\xAA', r, update_data_event)) + read_state, r, err = ( + self._process_reponse_byte( + read_state, b'\xFA', r, update_data_event)) + + elif self.LOOKING_FOR_FOOTER_2 == read_state: + if b'\xFA' == _byte: + # OK we have a full packet - PROCESS PACKET + err = self._process_response_data(r, update_data_event) + read_state = self.LOOKING_FOR_HEADER_1 + else: + # the last byte was not the beginning of the footer + r.append(b'\xAA') + read_state = self.PACKET_DATA + read_state, r, err = self._process_reponse_byte( + read_state, _byte, r, update_data_event) + else: + # error state, shouldn't happen... + logging.error('_process_reponse_byte - invalid read_state %d' % + read_state) + read_state = self.LOOKING_FOR_HEADER_1 + err = True + return read_state, r, err + + def _process_response_data(self, r, update_data_event): + err = False + if len(r) != 10: + logging.warn('read packet data len not 10, got: %d' % len(r)) + logging.warn('RD: ' + str(binascii.hexlify(b''.join(r)))) + err = True + else: + temp = struct.unpack(">H", b''.join(r[8:10]))[0] + if(temp == 65280): + self.current_temp = 150 + elif(temp > 550 or temp < 150): + logging.warn('temperature out of range: reinitializing...') + self._initialize() + err = True + return + else: + self.current_temp = temp + + if(update_data_event is not None): + update_data_event.set() + return err + + def _timer(self, state_transition_event=None): + """Timer loop used to keep track of the time while roasting or + cooling. If the time remaining reaches zero, the roaster will call the + supplied state transistion function or the roaster will be set to + the idle state.""" + while not self._teardown.value: + state = self.get_roaster_state() + if(state == 'roasting' or state == 'cooling'): + time.sleep(1) + self.total_time += 1 + if(self.time_remaining > 0): + self.time_remaining -= 1 + else: + if(state_transition_event is not None): + state_transition_event.set() + else: + self.idle() + else: + time.sleep(0.01) + + def get_roaster_state(self): + """Returns a string based upon the current state of the roaster. Will + raise an exception if the state is unknown. + + Returns: + 'idle' if idle, + 'sleeping' if sleeping, + 'cooling' if cooling, + 'roasting' if roasting, + 'connecting' if in hardware connection phase, + 'unknown' otherwise + """ + value = self._current_state.value + if(value == b'\x02\x01'): + return 'idle' + elif(value == b'\x04\x04'): + if self._cooling_for_pid_control: + return 'roasting' + else: + return 'cooling' + elif(value == b'\x08\x01'): + return 'sleeping' + # handle null bytes as empty strings + elif(value == b'\x00\x00' or value == b''): + return 'connecting' + elif(value == b'\x04\x02'): + return 'roasting' + else: + return 'unknown' + + def _generate_packet(self): + """Generates a packet based upon the current class variables. Note that + current temperature is not sent, as the original application sent zeros + to the roaster for the current temperature.""" + roaster_time = utils.seconds_to_float(self._time_remaining.value) + packet = ( + self._header.value + + self._temp_unit.value + + self._flags.value + + self._current_state.value + + struct.pack(">B", self._fan_speed.value) + + struct.pack(">B", int(round(roaster_time * 10.0))) + + struct.pack(">B", self._heat_setting.value) + + b'\x00\x00' + + self._footer) + + return packet + + def idle(self): + """Sets the current state of the roaster to idle.""" + self._cooling_for_pid_control = False + self._current_state.value = b'\x02\x01' + + def roast(self): + """Sets the current state of the roaster to roast and begins + roasting.""" + self._cooling_for_pid_control = False + self._current_state.value = b'\x04\x02' + + def cool(self, cool_for_pid_control=False): + """Sets the current state of the roaster to cool. The roaster expects + that cool will be run after roast, and will not work as expected if ran + before.""" + self._cooling_for_pid_control = cool_for_pid_control + self._current_state.value = b'\x04\x04' + + def sleep(self): + """Sets the current state of the roaster to sleep. Different than idle + in that this will set double dashes on the roaster display rather than + digits.""" + self._cooling_for_pid_control = False + self._current_state.value = b'\x08\x01' + + +class heat_controller(object): + """A class to do gross-level pulse modulation on a bang-bang interface. + + Args: + number_of_segments (int): the resolution of the heat_controller. + Defaults to 8. for number_of_segments=N, creates a heat_controller + that varies the heat between 0..N inclusive, in integer increments, + where 0 is no heat, and N is full heat. The bigger the number, the + less often the heat value can be changed, because this object is + designed to be called at a regular time interval to output N binary + values before rolling over or picking up the latest commanded heat + value. + """ + def __init__(self, number_of_segments=8): + # num_segments determines how many time samples are used to produce + # the output. This effectively translates to a number of output + # levels on the bang-bang controller. If number_of_segments == 8, + # for example, then, possible output 'levels' are 0,1,2,...8. + # Depending on the output + # rate and the load's time constant, the result could be perceived + # as discrete lumps rather than an effective average output. + # higer rate of output is better than slower. + # This code does not attempt to control the rate of output, + # that is left to the caller. + self._num_segments = number_of_segments + self._output_array = [[0 for x in range(self._num_segments)] + for x in range(1+self._num_segments)] + # I'm sure there's a great way to do this algorithmically for + # all possible num_segments... + if 4 == self._num_segments: + self._output_array[0] = [False, False, False, False] + self._output_array[1] = [True, False, False, False] + self._output_array[2] = [True, False, True, False] + self._output_array[3] = [True, True, True, False] + self._output_array[4] = [True, True, True, True] + elif 8 == self._num_segments: + self._output_array[0] = [False, False, False, False, + False, False, False, False] + self._output_array[1] = [True, False, False, False, + False, False, False, False] + self._output_array[2] = [True, False, False, False, + True, False, False, False] + self._output_array[3] = [True, False, False, True, + False, False, True, False] + self._output_array[4] = [True, False, True, False, + True, False, True, False] + self._output_array[5] = [True, True, False, True, + True, False, True, False] + self._output_array[6] = [True, True, True, False, + True, True, True, False] + self._output_array[7] = [True, True, True, True, + True, True, True, False] + self._output_array[8] = [True, True, True, True, + True, True, True, True] + else: + # note that the most effective pulse modulation is one where + # ones and zeroes are as temporarily spread as possible. + # Example, for a 4-segment output, + # [1,1,0,0] is not as effective as/lumpier than + # [1,0,1,0], even though they supply the same energy. + # If the output rate is much greater than the load's time constant, + # this difference will not be percpetible. + # Here, we're just stuffing early slots with ones... lumpier + for i in range(1+self._num_segments): + for j in range(self._num_segments): + self._output_array[i][j] = j < i + # prepare for output + self._heat_level = 0 + self._heat_level_now = 0 + self._current_index = 0 + + @property + def heat_level(self): + """Set/Get the current desired output level. Must be between 0 and + number_of_segments inclusive. + + Args: + Setter: value (int): heat_level value, + between 0 and number_of_segments inclusive. + + Returns: + Getter (int): heat level""" + return self._heat_level + + @heat_level.setter + def heat_level(self, value): + """Set the desired output level. Must be between 0 and + number_of_segments inclusive.""" + if value < 0: + self._heat_level = 0 + elif round(value) > self._num_segments: + self._heat_level = self._num_segments + else: + self._heat_level = int(round(value)) + + def generate_bangbang_output(self): + """Generates the latest on or off pulse in + the string of on (True) or off (False) pulses + according to the desired heat_level setting. Successive calls + to this function will return the next value in the + on/off array series. Call this at control loop rate to + obtain the necessary on/off pulse train. + This system will not work if the caller expects to be able + to specify a new heat_level at every control loop iteration. + Only the value set at every number_of_segments iterations + will be picked up for output! Call about_to_rollover to determine + if it's time to set a new heat_level, if a new level is desired.""" + if self._current_index >= self._num_segments: + # we're due to switch over to the next + # commanded heat_level + self._heat_level_now = self._heat_level + # reset array index + self._current_index = 0 + # return output + out = self._output_array[self._heat_level_now][self._current_index] + self._current_index += 1 + return out + + def about_to_rollover(self): + """This method indicates that the next call to generate_bangbang_output + is a wraparound read. Use this to determine if it's time to + pick up the latest commanded heat_level value and run a PID + controller iteration.""" + return self._current_index >= self._num_segments diff --git a/openroast/freshroastsr700/exceptions.py b/openroast/freshroastsr700/exceptions.py new file mode 100644 index 0000000..c4e5d24 --- /dev/null +++ b/openroast/freshroastsr700/exceptions.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2015-2016 Mark Spicer +# Made available under the MIT license. + + +class RoasterError(Exception): + """A base error for freshroastsr700 errors.""" + + +class RoasterValueError(RoasterError): + """Raised when a class variable assigned is out of the range of acceptable + values.""" + + +class RoasterLookupError(RoasterError): + """Raised when a device is not able to be found from the connected + devices.""" + + +class RoasterStateError(RoasterError): + """Raised when the current state of the roaster is not a known roaster + state.""" diff --git a/openroast/freshroastsr700/pid.py b/openroast/freshroastsr700/pid.py new file mode 100644 index 0000000..118ec36 --- /dev/null +++ b/openroast/freshroastsr700/pid.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Author: cnr437@gmail.com +# Code URL: http://code.activestate.com/recipes/577231-discrete-pid-controller/ +# License: MIT +# Modified by Openroast. + + +class PID(object): + """Discrete PID control.""" + def __init__(self, P, I, D, Derivator=0, Integrator=0, + Output_max=8, Output_min=0): + self.Kp = P + self.Ki = I + self.Kd = D + self.Derivator = Derivator + self.Integrator = Integrator + self.Output_max = Output_max + self.Output_min = Output_min + if(I > 0.0): + self.Integrator_max = Output_max / I + self.Integrator_min = Output_min / I + else: + self.Integrator_max = 0.0 + self.Integrator_min = 0.0 + self.targetTemp = 0 + self.error = 0.0 + + def update(self, currentTemp, targetTemp): + """Calculate PID output value for given reference input and feedback.""" + # in this implementation, ki includes the dt multiplier term, + # and kd includes the dt divisor term. This is typical practice in + # industry. + self.targetTemp = targetTemp + self.error = targetTemp - currentTemp + + self.P_value = self.Kp * self.error + # it is common practice to compute derivative term against PV, + # instead of de/dt. This is because de/dt spikes + # when the set point changes. + + # PV version with no dPV/dt filter - note 'previous'-'current', + # that's desired, how the math works out + self.D_value = self.Kd * (self.Derivator - currentTemp) + self.Derivator = currentTemp + + self.Integrator = self.Integrator + self.error + if self.Integrator > self.Integrator_max: + self.Integrator = self.Integrator_max + elif self.Integrator < self.Integrator_min: + self.Integrator = self.Integrator_min + + self.I_value = self.Integrator * self.Ki + + output = self.P_value + self.I_value + self.D_value + if output > self.Output_max: + output = self.Output_max + if output < self.Output_min: + output = self.Output_min + return(output) + + def setPoint(self, targetTemp): + """Initilize the setpoint of PID.""" + self.targetTemp = targetTemp + self.Integrator = 0 + self.Derivator = 0 + + def setIntegrator(self, Integrator): + self.Integrator = Integrator + + def setDerivator(self, Derivator): + self.Derivator = Derivator + + def setKp(self, P): + self.Kp = P + + def setKi(self, I): + self.Ki = I + + def setKd(self, D): + self.Kd = D + + def getPoint(self): + return self.targetTemp + + def getError(self): + return self.error + + def getIntegrator(self): + return self.Integrator + + def getDerivator(self): + return self.Derivator + + def update_p(self, p): + self.Kp = p + + def update_i(self, i): + self.Ki = i + + def update_d(self, d): + self.Kd = d diff --git a/openroast/freshroastsr700/utils.py b/openroast/freshroastsr700/utils.py new file mode 100644 index 0000000..5f4be09 --- /dev/null +++ b/openroast/freshroastsr700/utils.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2015-2016 Mark Spicer +# Made available under the MIT license. + +import re +from serial.tools import list_ports + +from freshroastsr700 import exceptions + + +def frange(start, stop, step, precision): + """A generator that will generate a range of floats.""" + value = start + while round(value, precision) < stop: + yield round(value, precision) + value += step + + +def find_device(vidpid): + """Finds a connected device with the given VID:PID. Returns the serial + port url.""" + for port in list_ports.comports(): + if re.search(vidpid, port[2], flags=re.IGNORECASE): + return port[0] + + raise exceptions.RoasterLookupError + + +def seconds_to_float(time_in_seconds): + """Converts seconds to float rounded to one digit. Will cap the float at + 9.9 or 594 seconds.""" + if(time_in_seconds <= 594): + return round((float(time_in_seconds) / 60.0), 1) + + return 9.9 diff --git a/openroast/freshroastsr700/version.py b/openroast/freshroastsr700/version.py new file mode 100644 index 0000000..788da1f --- /dev/null +++ b/openroast/freshroastsr700/version.py @@ -0,0 +1 @@ +__version__ = "0.2.4"