# Simple Finite State Machine used to control states import json from scm import eval, _, GLOBAL_ENV, stringify, load from time import perf_counter from chat import chat from persona import ManualIntervention from sensors import get_sensor_value from control import fixed_duty_wrapper from actuators import actuators from utils import consumption_diff, consumption_to_string, stohms MY_GLOBAL_ENV = ( _('make-duty-controller', 4, fixed_duty_wrapper, _('format-time', 1, lambda x: stohms(x.car), GLOBAL_ENV))) def safe_eval(data, env, notify=True): try: return eval(data, env) except Exception as e: err = ' '.join(('evaluating:', data, ':', str(e))) if notify: chat.send_sync(err) return True return err def load_phase(json): def fallback(v): return json.get(v, '#t') return Phase( json['name'], json['description'], onexit=fallback('on_exit'), onload=fallback('on_load'), nextcond=fallback('exit_condition')) def load_recipe(json): return Recipe( json['name'], json['description'], json.get('controllers', ()), [load_phase(p) for p in json['phases']]) def load_recipes(file): with open(file, "r") as f: recipes = json.loads(f.read()) return [load_recipe(recipe) for recipe in recipes] def store_recipes(file, recipes): print(file, recipes) with open(file, "w") as f: f.write(json.dumps(recipes, indent=2)) def manual_intervention_wrapper(state): def wrapper(args): label = None options = None if len(args) > 0: label = stringify(args.car, quote=False) if len(args) > 1: options = tuple( (stringify(o.car, quote=False), o.cdr.car) for o in args.cdr.car) resp = state.manual.get(label, options) if resp[0]: print('REQUESTING CLIENT UPDATE') state.onupdate() return resp[1] return wrapper class State(): def __init__(self, recipe_file, onupdate=lambda: None): self.recipes = load_recipes(recipe_file) self.recipe_file = recipe_file self.recipe = None self.phase = None self.manual = ManualIntervention() self.baseenv = ( _('get-sensor', 1, lambda x: get_sensor_value(stringify(x.car, quote=False)), _('set-actuator', 2, self.set_actuator, _('notify', 1, lambda x: chat.send_sync(stringify(x.car, quote=False)), _('hours', 1, lambda x: x.car * 60 * 60, _('minutes', 1, lambda x: x.car * 60, _('seconds', 1, lambda x: x.car, _('manual-intervention', -1, manual_intervention_wrapper(self), _('set-target', 2, self.set_target, _('get-sensor-target', 1, self.get_sensor_target, _('get-controller-target', 1, self.get_controller_target, _('set-controller', 2, self.set_controller, _('reload-recipes', 0, lambda x: self.reloadRecipes(), MY_GLOBAL_ENV))))))))))))) self.env = self.baseenv self.envdata = {} self.onupdate = onupdate def reloadRecipes(self): # chat.send_sync("Reloading recipes!") print("Reloading recipes!") self.recipes = load_recipes(self.recipe_file) def getState(self): return { 'phase': self.phase, 'phase-time': int(self.phase_time()), 'phase-consumption': consumption_to_string(self.phase_consumption()), 'recipe-time': int(self.recipe_time()), 'recipe-consumption': consumption_to_string(self.recipe_consumption()) } def now(self): return perf_counter() def postload(self): if self.recipe is None: return self.phase = 0 self.env = ( _('recipe-name', 0, lambda _: self.recipe.name, _('recipe-description', 0, lambda _: self.recipe.description, _('recipe-consumption', 0, lambda _: self.recipe_consumption(), _('recipe-time', 0, lambda _: self.recipe_time(), _('time-in-this-phase', 0, lambda _: self.phase_time(), _('phase-consumption', 0, lambda _: self.phase_consumption(), _('phase-time', 0, lambda _: self.phase_time(), self.baseenv)))))))) self.envdata['controllers'] = {} for controller in self.recipe.controllers: name, ctrl = safe_eval(controller, self.env) self.envdata['controllers'][name] = ctrl self.envdata['sensors'] = {} self.envdata['recipe-start-consumption'] = self.get_total_consumption() self.envdata['current-recipe-loadtime'] = self.now() self.loadphase(self.phase) def recipeById(self, id): try: id = int(id) except: id = -1 print(self.recipes) if id > -1 and id < len(self.recipes): return self.recipes[id] return None def loadByIdx(self, recipe): self.recipe = self.recipeById(recipe) self.postload() def stop(self): phase = self.current_phase() if phase is not None: phase.exit(self.env) self.done(message='(concat "Recipe " (recipe-name) " stopped manually")') def loadByName(self, recipe): r = tuple(i for (i, r) in enumerate(self.recipes) if r.name == recipe) if len(r) > 0: self.recipe = self.recipes[r[0]] self.postload() def current_phase(self): if self.recipe is None: return None if self.phase >= len(self.recipe.phases) or self.phase < 0: return None return self.recipe.phases[self.phase] def check(self): return self.current_phase().satisfied_p(self.env) def run_step(self): # Update sensor values for sensor in self.envdata.get('sensors', ()): _, ctrl, target = self.envdata['sensors'][sensor] self.envdata['sensors'][sensor] = (get_sensor_value(sensor), ctrl, target) # Apply actuators for controller in self.envdata.get('controllers', {}).keys(): ctrl = self.envdata['controllers'][controller] try: if ctrl.input_label not in self.envdata['sensors'].keys(): print(f'Missing sensor {ctrl.input_label}') continue response = ctrl.apply(self.envdata['sensors'][ctrl.input_label][0]) actuators[controller].enable(response) except Exception as e: print(f"UNKNOWN BUG HERE in run_step {e} on controller {ctrl}") if self.check(): if self.next() is None: return True print(f'Waiting for {self.current_phase().nextcond}') return False def next(self): self.current_phase().exit(self.env) if self.phase < len(self.recipe.phases) - 1: self.phase += 1 phase = self.recipe.phases[self.phase] self.loadphase(self.phase) self.manual.clear() return phase self.onupdate() return None def phase_time(self, arg=None): start = self.envdata.get('current-phase-loadtime', 0) return start and self.now() - start def recipe_time(self, arg=None): start = self.envdata.get('current-recipe-loadtime', 0) return start and self.now() - start def loadphase(self, phaseidx): phase = self.recipe.phases[phaseidx] self.envdata['current-phase-loadtime'] = self.now() self.envdata['current-phase-start-consumption'] = self.get_total_consumption() resp = safe_eval(phase.onload, self.env) self.onupdate() return resp def done(self, message=None): self.envdata['recipe-done-consumption'] = self.get_total_consumption() self.recipe.done(self.env, message) # Stop all actuators for controller in self.envdata.get('controllers', {}).keys(): actuators[controller].disable() self.recipe = None self.env = self.baseenv self.envdata = {} self.onupdate() def set_target(self, args): sensor, target = (stringify(args.car, quote=False), args.cdr.car) # We get the controller, and set it to target value, controller, _ = self.envdata['sensors'][sensor] self.envdata['sensors'][sensor] = (value, controller, target) self.envdata['controllers'][controller].set_target(target) print('setting sensor', sensor, controller, 'to target', target) def get_sensor_target(self, args): sensor = stringify(args.car, quote=False) if sensor not in self.envdata['sensors'].keys(): print(f'Sensor {sensor} not found') return None _, controller, target = self.envdata['sensors'][sensor] if controller is None: print('Sensor has no associated controller') return None return target def get_controller_target(self, args): controller = stringify(args.car, quote=False) if controller not in self.envdata['controllers'].keys(): print(f'Controller {controller} not found') return None return self.envdata['controllers'][controller].get_target() def set_controller(self, args): controller, sensor = (stringify(args.car, quote=False), stringify(args.cdr.car, quote=False)) self.envdata['controllers'][controller].set_input_label(sensor) # Value/Controller/Target self.envdata['sensors'][sensor] = (get_sensor_value(sensor), controller, None) print('linking sensor', sensor, 'to controller', controller, '(', self.envdata['controllers'][controller], ')') def set_actuator(self, args): actuator, value = (stringify(args.car, quote=False), args.cdr.car) if value: actuators[actuator].enable() else: actuators[actuator].disable() print('setting actuator', actuator, 'to value', value, ', remember to change it manually if stopping?') def get_total_consumption(self): return {actuator: actuators[actuator].consumption() for actuator in actuators.keys()} def phase_consumption(self): before = self.envdata.get('current-phase-start-consumption', {}) after = self.get_total_consumption() return consumption_diff(before, after) def recipe_consumption(self): return consumption_diff( self.envdata.get('recipe-start-consumption', {}), self.get_total_consumption()) class Recipe(): def __init__(self, name, description='', controllers=(), phases=[]): self.name = name self.controllers = controllers self.description = description self.phases = phases def getState(self, currentphase=None): def phasetodict(i, p): return { 'name': p.name, 'text': p.text, 'nextcond': p.nextcond, 'current': currentphase == i, # 'loaded_time': p.loaded_time, 'onload': p.onload, 'onexit': p.onexit, # 'envdata': p.envdata } return { 'name': self.name, 'description': self.description, 'controllers': self.controllers, 'phases': [ phasetodict(i, p) for (i, p) in enumerate(self.phases) ], # 'phase': self.phase } def done(self, env, message=None): message = message or ''' (concat "Recipe " (recipe-name) " completed at " (now) " in " (format-time (recipe-time)) ", Consumption: " (recipe-consumption)) ''' # safe_eval(f'(notify {message})', env) class Phase(): def __init__(self, name, text='', nextcond='#t', onload='', onexit=''): self.name = name self.text = text self.nextcond = nextcond self.onload = onload self.onexit = onexit # self.loaded_time = None # self.env = env # self.envdata = {} def satisfied_p(self, env): return safe_eval(self.nextcond, env) def exit(self, env): value = safe_eval(self.onexit, env) message = ''' (notify (concat "Phase done in " (format-time (phase-time)) ", with consumption: " (phase-consumption))) ''' safe_eval(message, env) return value def revert(self, env): # Undo state changes (attuators values) pass