class CoordinatorManager(StateMachineManager): """Manages device state machine thread that spawns child threads to run recipes, read sensors, set actuators, manage control loops, sync data, and manage external events.""" # Initialize vars latest_publish_timestamp = 0.0 peripherals: Dict[str, StateMachineManager] = {} controllers: Dict[str, StateMachineManager] = {} new_config: bool = False def __init__(self) -> None: """Initializes coordinator.""" # Initialize parent class super().__init__() # Initialize logger self.logger = Logger("Coordinator", "coordinator") self.logger.debug("Initializing coordinator") # Initialize state self.state = State() # Initialize environment state dict, TODO: remove this self.state.environment = { "sensor": { "desired": {}, "reported": {} }, "actuator": { "desired": {}, "reported": {} }, "reported_sensor_stats": { "individual": { "instantaneous": {}, "average": {} }, "group": { "instantaneous": {}, "average": {} }, }, } # Initialize recipe state dict, TODO: remove this self.state.recipe = { "recipe_uuid": None, "start_timestamp_minutes": None, "last_update_minute": None, } # Initialize managers self.recipe = RecipeManager(self.state) self.iot = IotManager(self.state, self.recipe) # type: ignore self.recipe.set_iot(self.iot) self.resource = ResourceManager(self.state, self.iot) # type: ignore self.network = NetworkManager(self.state) # type: ignore self.upgrade = UpgradeManager(self.state) # type: ignore # Initialize state machine transitions self.transitions = { modes.INIT: [modes.CONFIG, modes.ERROR, modes.SHUTDOWN], modes.CONFIG: [modes.SETUP, modes.ERROR, modes.SHUTDOWN], modes.SETUP: [modes.NORMAL, modes.ERROR, modes.SHUTDOWN], modes.NORMAL: [modes.LOAD, modes.ERROR, modes.SHUTDOWN], modes.LOAD: [modes.CONFIG, modes.ERROR, modes.SHUTDOWN], modes.RESET: [modes.INIT, modes.SHUTDOWN], modes.ERROR: [modes.RESET, modes.SHUTDOWN], } # Initialize state machine mode self.mode = modes.INIT @property def mode(self) -> str: """Gets mode.""" return self._mode @mode.setter def mode(self, value: str) -> None: """Safely updates mode in state object.""" self._mode = value with self.state.lock: self.state.device["mode"] = value @property def config_uuid(self) -> Optional[str]: """ Gets config uuid from shared state. """ return self.state.device.get("config_uuid") # type: ignore @config_uuid.setter def config_uuid(self, value: Optional[str]) -> None: """ Safely updates config uuid in state. """ with self.state.lock: self.state.device["config_uuid"] = value @property def config_dict(self) -> Dict[str, Any]: """Gets config dict for config uuid in device config table.""" if self.config_uuid == None: return {} config = models.DeviceConfigModel.objects.get(uuid=self.config_uuid) return json.loads(config.json) # type: ignore @property def latest_environment_timestamp(self) -> float: """Gets latest environment timestamp from environment table.""" if not models.EnvironmentModel.objects.all(): return 0.0 else: environment = models.EnvironmentModel.objects.latest() return float(environment.timestamp.timestamp()) @property def manager_modes(self) -> Dict[str, str]: """Gets manager modes.""" self.logger.debug("Getting manager modes") # Get known manager modes modes = { "Coordinator": self.mode, "Recipe": self.recipe.mode, "Network": self.network.mode, "IoT": self.iot.mode, "Resource": self.resource.mode, } # Get peripheral manager modes for peripheral_name, peripheral_manager in self.peripherals.items(): modes[peripheral_name] = peripheral_manager.mode # Get controller manager modes for controller_name, controller_manager in self.controllers.items(): modes[controller_name] = controller_manager.mode # Return modes self.logger.debug("Returning modes: {}".format(modes)) return modes @property def manager_healths(self) -> Dict[str, str]: """Gets manager healths.""" self.logger.debug("Getting manager healths") # Initialize healths healths = {} # Get peripheral manager modes for peripheral_name, peripheral_manager in self.peripherals.items(): healths[ peripheral_name] = peripheral_manager.health # type: ignore # Return modes self.logger.debug("Returning healths: {}".format(healths)) return healths ##### STATE MACHINE FUNCTIONS ###################################################### def run(self) -> None: """Runs device state machine.""" # Loop forever while True: # Check if thread is shutdown if self.is_shutdown: break # Check for transitions if self.mode == modes.INIT: self.run_init_mode() elif self.mode == modes.CONFIG: self.run_config_mode() elif self.mode == modes.SETUP: self.run_setup_mode() elif self.mode == modes.NORMAL: self.run_normal_mode() elif self.mode == modes.LOAD: self.run_load_mode() elif self.mode == modes.ERROR: self.run_error_mode() elif self.mode == modes.RESET: self.run_reset_mode() elif self.mode == modes.SHUTDOWN: self.run_shutdown_mode() else: self.logger.critical("Invalid state machine mode") self.mode = modes.INVALID self.is_shutdown = True break def run_init_mode(self) -> None: """Runs init mode. Loads local data files and stored database state then transitions to config mode.""" self.logger.info("Entered INIT") # Load local data files and stored db state self.load_local_data_files() self.load_database_stored_state() # Transition to config mode on next state machine update self.mode = modes.CONFIG def run_config_mode(self) -> None: """Runs configuration mode. If device config is not set, loads 'unspecified' config then transitions to setup mode.""" self.logger.info("Entered CONFIG") # Check device config specifier file exists in repo try: with open(DEVICE_CONFIG_PATH) as f: config_name = f.readline().strip() except: env_dev_type = os.getenv("OPEN_AG_DEVICE_TYPE") if env_dev_type is None: config_name = "unspecified" message = "Unable to read {}, using unspecified config".format( DEVICE_CONFIG_PATH) else: config_name = env_dev_type message = "Unable to read {}, using {} config from env".format( DEVICE_CONFIG_PATH, config_name) self.logger.warning(message) # Create the directories if needed os.makedirs(os.path.dirname(DEVICE_CONFIG_PATH), exist_ok=True) # Write `unspecified` to device.txt with open(DEVICE_CONFIG_PATH, "w") as f: f.write("{}\n".format(config_name)) # Load device config self.logger.debug("Loading device config file: {}".format(config_name)) device_config = json.load( open("data/devices/{}.json".format(config_name))) # Check if config uuid changed, if so, adjust state if self.config_uuid != device_config["uuid"]: with self.state.lock: self.state.peripherals = {} self.state.controllers = {} set_nested_dict_safely( self.state.environment, ["reported_sensor_stats"], {}, self.state.lock, ) set_nested_dict_safely(self.state.environment, ["sensor", "reported"], {}, self.state.lock) self.config_uuid = device_config["uuid"] # Transition to setup mode on next state machine update self.mode = modes.SETUP def run_setup_mode(self) -> None: """Runs setup mode. Creates and spawns recipe, peripheral, and controller threads, waits for all threads to initialize then transitions to normal mode.""" self.logger.info("Entered SETUP") config_uuid = self.state.device["config_uuid"] # Spawn managers if not self.new_config: self.recipe.spawn() self.iot.spawn() self.resource.spawn() self.network.spawn() self.upgrade.spawn() # Create and spawn peripherals self.logger.debug("Creating and spawning peripherals") self.create_peripherals() self.spawn_peripherals() # Create and spawn controllers self.create_controllers() self.spawn_controllers() # Wait for all threads to initialize while not self.all_managers_initialized(): time.sleep(0.2) # Unset new config flag self.new_config = False # Transition to normal mode on next state machine update self.mode = modes.NORMAL def run_normal_mode(self) -> None: """Runs normal operation mode. Updates device state summary and stores device state in database, checks for new events and transitions.""" self.logger.info("Entered NORMAL") while True: # Overwrite system state in database every 100ms self.update_state() # Store environment state in every 10 minutes if time.time() - self.latest_environment_timestamp > 60 * 10: self.store_environment() # Check for events self.check_events() # Check for transitions if self.new_transition(modes.NORMAL): break # Update every 100ms time.sleep(0.1) def run_load_mode(self) -> None: """Runs load mode, shutsdown peripheral and controller threads then transitions to config mode.""" self.logger.info("Entered LOAD") # Shutdown peripherals and controllers self.shutdown_peripheral_threads() self.shutdown_controller_threads() # Initialize timeout parameters timeout = 10 start_time = time.time() # Loop forever while True: # Check if peripherals and controllers are shutdown if self.all_peripherals_shutdown( ) and self.all_controllers_shutdown(): self.logger.debug("All peripherals and controllers shutdown") break # Check for timeout if time.time() - start_time > timeout: self.logger.critical("Config threads did not shutdown") self.mode = modes.ERROR return # Update every 100ms time.sleep(0.1) # Set new config flag self.new_config = True # Transition to config mode on next state machine update self.mode = modes.CONFIG def run_reset_mode(self) -> None: """Runs reset mode. Shutsdown child threads then transitions to init.""" self.logger.info("Entered RESET") # Shutdown managers self.shutdown_peripheral_threads() self.shutdown_controller_threads() self.recipe.shutdown() self.iot.shutdown() # Transition to init mode on next state machine update self.mode = modes.INIT def run_error_mode(self) -> None: """Runs error mode. Shutsdown child threads, waits for new events and transitions.""" self.logger.info("Entered ERROR") # Shutsdown peripheral and controller threads self.shutdown_peripheral_threads() self.shutdown_controller_threads() # Loop forever while True: # Check for events self.check_events() # Check for transitions if self.new_transition(modes.ERROR): break # Update every 100ms time.sleep(0.1) ##### SUPPORT FUNCTIONS ############################################################ def update_state(self) -> None: """Updates stored state in database. If state does not exist, creates it.""" # TODO: Move this to state manager if not models.StateModel.objects.filter(pk=1).exists(): models.StateModel.objects.create( id=1, device=json.dumps(self.state.device), recipe=json.dumps(self.state.recipe), environment=json.dumps(self.state.environment), peripherals=json.dumps(self.state.peripherals), controllers=json.dumps(self.state.controllers), iot=json.dumps(self.state.iot), resource=json.dumps(self.state.resource), connect=json.dumps(self.state.network), upgrade=json.dumps(self.state.upgrade), ) else: models.StateModel.objects.filter(pk=1).update( device=json.dumps(self.state.device), recipe=json.dumps(self.state.recipe), environment=json.dumps(self.state.environment), peripherals=json.dumps(self.state.peripherals), controllers=json.dumps(self.state.controllers), iot=json.dumps(self.state.iot), resource=json.dumps(self.state.resource), connect=json.dumps(self.state.network), # TODO: migrate this upgrade=json.dumps(self.state.upgrade), ) def load_local_data_files(self) -> None: """ Loads local data files. """ self.logger.info("Loading local data files") # Load files with no verification dependencies first self.load_sensor_variables_file() self.load_actuator_variables_file() self.load_cultivars_file() self.load_cultivation_methods_file() # Load recipe files after sensor/actuator variables, cultivars, and # cultivation methods since verification depends on them self.load_recipe_files() # Load peripheral setup files after sensor/actuator variable since verification # depends on them self.load_peripheral_setup_files() # Load controller setup files after sensor/actuator variable since verification # depends on them self.load_controller_setup_files() # Load device config after peripheral setups since verification # depends on them self.load_device_config_files() def load_sensor_variables_file(self) -> None: """ Loads sensor variables file into database after removing all existing entries. """ self.logger.debug("Loading sensor variables file") # Load sensor variables and schema sensor_variables = json.load(open(SENSOR_VARIABLES_PATH)) sensor_variables_schema = json.load(open(SENSOR_VARIABLES_SCHEMA_PATH)) # Validate sensor variables with schema jsonschema.validate(sensor_variables, sensor_variables_schema) # Delete sensor variables tables models.SensorVariableModel.objects.all().delete() # Create sensor variables table for sensor_variable in sensor_variables: models.SensorVariableModel.objects.create( json=json.dumps(sensor_variable)) def load_actuator_variables_file(self) -> None: """ Loads actuator variables file into database after removing all existing entries. """ self.logger.debug("Loading actuator variables file") # Load actuator variables and schema actuator_variables = json.load(open(ACTUATOR_VARIABLES_PATH)) actuator_variables_schema = json.load( open(ACTUATOR_VARIABLES_SCHEMA_PATH)) # Validate actuator variables with schema jsonschema.validate(actuator_variables, actuator_variables_schema) # Delete actuator variables tables models.ActuatorVariableModel.objects.all().delete() # Create actuator variables table for actuator_variable in actuator_variables: models.ActuatorVariableModel.objects.create( json=json.dumps(actuator_variable)) def load_cultivars_file(self) -> None: """ Loads cultivars file into database after removing all existing entries.""" self.logger.debug("Loading cultivars file") # Load cultivars and schema cultivars = json.load(open(CULTIVARS_PATH)) cultivars_schema = json.load(open(CULTIVARS_SCHEMA_PATH)) # Validate cultivars with schema jsonschema.validate(cultivars, cultivars_schema) # Delete cultivars tables models.CultivarModel.objects.all().delete() # Create cultivars table for cultivar in cultivars: models.CultivarModel.objects.create(json=json.dumps(cultivar)) def load_cultivation_methods_file(self) -> None: """ Loads cultivation methods file into database after removing all existing entries. """ self.logger.debug("Loading cultivation methods file") # Load cultivation methods and schema cultivation_methods = json.load(open(CULTIVATION_METHODS_PATH)) cultivation_methods_schema = json.load( open(CULTIVATION_METHODS_SCHEMA_PATH)) # Validate cultivation methods with schema jsonschema.validate(cultivation_methods, cultivation_methods_schema) # Delete cultivation methods tables models.CultivationMethodModel.objects.all().delete() # Create cultivation methods table for cultivation_method in cultivation_methods: models.CultivationMethodModel.objects.create( json=json.dumps(cultivation_method)) def load_recipe_files(self) -> None: """Loads recipe files into database via recipe manager create or update function.""" self.logger.debug("Loading recipe files") # Get recipes for filepath in glob.glob(RECIPES_PATH): self.logger.debug("Loading recipe file: {}".format(filepath)) with open(filepath, "r") as f: json_ = f.read().replace("\n", "") message, code = self.recipe.create_or_update_recipe(json_) if code != 200: filename = filepath.split("/")[-1] error = "Unable to load {} -> {}".format(filename, message) self.logger.error(error) def load_peripheral_setup_files(self) -> None: """Loads peripheral setup files from codebase into database by creating new entries after deleting existing entries. Verification depends on sensor and actuator variables.""" self.logger.info("Loading peripheral setup files") # Get peripheral setups peripheral_setups = [] for filepath in glob.glob(PERIPHERAL_SETUP_FILES_PATH): self.logger.debug( "Loading peripheral setup file: {}".format(filepath)) peripheral_setups.append(json.load(open(filepath))) # Get get peripheral setup schema # TODO: Finish schema peripheral_setup_schema = json.load(open(PERIPHERAL_SETUP_SCHEMA_PATH)) # Validate peripheral setups with schema for peripheral_setup in peripheral_setups: jsonschema.validate(peripheral_setup, peripheral_setup_schema) # Delete all peripheral setup entries from database models.PeripheralSetupModel.objects.all().delete() # TODO: Validate peripheral setup variables with database variables # Create peripheral setup entries in database for peripheral_setup in peripheral_setups: models.PeripheralSetupModel.objects.create( json=json.dumps(peripheral_setup)) def load_controller_setup_files(self) -> None: """Loads controller setup files from codebase into database by creating new entries after deleting existing entries. Verification depends on sensor and actuator variables.""" self.logger.info("Loading controller setup files") # Get controller setups controller_setups = [] for filepath in glob.glob(CONTROLLER_SETUP_FILES_PATH): self.logger.debug( "Loading controller setup file: {}".format(filepath)) controller_setups.append(json.load(open(filepath))) # Get get controller setup schema controller_setup_schema = json.load(open(CONTROLLER_SETUP_SCHEMA_PATH)) # Validate peripheral setups with schema for controller_setup in controller_setups: jsonschema.validate(controller_setup, controller_setup_schema) # Delete all peripheral setup entries from database models.ControllerSetupModel.objects.all().delete() # TODO: Validate controller setup variables with database variables # Create peripheral setup entries in database for controller_setup in controller_setups: models.ControllerSetupModel.objects.create( json=json.dumps(controller_setup)) def load_device_config_files(self) -> None: """Loads device config files from codebase into database by creating new entries after deleting existing entries. Verification depends on peripheral setups. """ self.logger.info("Loading device config files") # Get devices device_configs = [] for filepath in glob.glob(DEVICE_CONFIG_FILES_PATH): self.logger.debug( "Loading device config file: {}".format(filepath)) device_configs.append(json.load(open(filepath))) # Get get device config schema # TODO: Finish schema (see optional objects) device_config_schema = json.load(open(DEVICE_CONFIG_SCHEMA_PATH)) # Validate device configs with schema for device_config in device_configs: jsonschema.validate(device_config, device_config_schema) # TODO: Validate device config with peripherals # TODO: Validate device config with varibles # Delete all device config entries from database models.DeviceConfigModel.objects.all().delete() # Create device config entry if new or update existing for device_config in device_configs: models.DeviceConfigModel.objects.create( json=json.dumps(device_config)) def load_database_stored_state(self) -> None: """ Loads stored state from database if it exists. """ self.logger.info("Loading database stored state") # Get stored state from database if not models.StateModel.objects.filter(pk=1).exists(): self.logger.info("No stored state in database") self.config_uuid = None return stored_state = models.StateModel.objects.filter(pk=1).first() # Load device state stored_device_state = json.loads(stored_state.device) # Load recipe state stored_recipe_state = json.loads(stored_state.recipe) self.recipe.recipe_uuid = stored_recipe_state["recipe_uuid"] self.recipe.recipe_name = stored_recipe_state["recipe_name"] self.recipe.duration_minutes = stored_recipe_state["duration_minutes"] self.recipe.start_timestamp_minutes = stored_recipe_state[ "start_timestamp_minutes"] self.recipe.last_update_minute = stored_recipe_state[ "last_update_minute"] self.recipe.stored_mode = stored_recipe_state["mode"] # Load peripherals state stored_peripherals_state = json.loads(stored_state.peripherals) for peripheral_name in stored_peripherals_state: self.state.peripherals[peripheral_name] = {} if "stored" in stored_peripherals_state[peripheral_name]: stored = stored_peripherals_state[peripheral_name]["stored"] self.state.peripherals[peripheral_name]["stored"] = stored # Load controllers state stored_controllers_state = json.loads(stored_state.controllers) for controller_name in stored_controllers_state: self.state.controllers[controller_name] = {} if "stored" in stored_controllers_state[controller_name]: stored = stored_controllers_state[controller_name]["stored"] self.state.controllers[controller_name]["stored"] = stored # Load iot state stored_iot_state = json.loads(stored_state.iot) self.state.iot["stored"] = stored_iot_state.get("stored", {}) def store_environment(self) -> None: """ Stores current environment state in environment table. """ models.EnvironmentModel.objects.create(state=self.state.environment) def create_peripherals(self) -> None: """ Creates peripheral managers. """ self.logger.info("Creating peripheral managers") # Verify peripherals are configured if self.config_dict.get("peripherals") == None: self.logger.info("No peripherals configured") return # Set var type mux_simulator: Optional[MuxSimulator] # Inintilize simulation parameters if os.environ.get("SIMULATE") == "true": simulate = True mux_simulator = MuxSimulator() else: simulate = False mux_simulator = None # Create thread locks i2c_lock = threading.RLock() # Create peripheral managers self.peripherals = {} peripheral_config_dicts = self.config_dict.get("peripherals", {}) for peripheral_config_dict in peripheral_config_dicts: self.logger.debug("Creating {}".format( peripheral_config_dict["name"])) # Get peripheral setup dict peripheral_uuid = peripheral_config_dict["uuid"] peripheral_setup_dict = self.get_peripheral_setup_dict( peripheral_uuid) self.logger.debug("UUID {}".format(peripheral_uuid)) # Verify valid peripheral config dict if peripheral_setup_dict == {}: self.logger.critical( "Invalid peripheral uuid in device " "config. Validator should have caught this.") continue # Get peripheral module and class name module_name = ("device.peripherals.modules." + peripheral_setup_dict["module_name"]) class_name = peripheral_setup_dict["class_name"] # Import peripheral library module_instance = __import__(module_name, fromlist=[class_name]) class_instance = getattr(module_instance, class_name) # Create peripheral manager peripheral_name = peripheral_config_dict["name"] peripheral = class_instance( name=peripheral_name, state=self.state, config=peripheral_config_dict, simulate=simulate, i2c_lock=i2c_lock, mux_simulator=mux_simulator, ) self.peripherals[peripheral_name] = peripheral def get_peripheral_setup_dict(self, uuid: str) -> Dict[str, Any]: """Gets peripheral setup dict for uuid in peripheral setup table.""" if not models.PeripheralSetupModel.objects.filter(uuid=uuid).exists(): return {} else: json_ = models.PeripheralSetupModel.objects.get(uuid=uuid).json peripheral_setup_dict = json.loads(json_) return peripheral_setup_dict # type: ignore def get_controller_setup_dict(self, uuid: str) -> Dict[str, Any]: """Gets controller setup dict for uuid in peripheral setup table.""" if not models.ControllerSetupModel.objects.filter(uuid=uuid).exists(): return {} else: json_ = models.ControllerSetupModel.objects.get(uuid=uuid).json controller_setup_dict = json.loads(json_) return controller_setup_dict # type: ignore def spawn_peripherals(self) -> None: """ Spawns peripherals. """ if self.peripherals == {}: self.logger.info("No peripheral threads to spawn") else: self.logger.info("Spawning peripherals") for name, manager in self.peripherals.items(): manager.spawn() def create_controllers(self) -> None: """ Creates controller managers. """ self.logger.info("Creating controller managers") # Verify controllers are configured if self.config_dict.get("controllers") == None: self.logger.info("No controllers configured") return # Create controller managers self.controllers = {} controller_config_dicts = self.config_dict.get("controllers", {}) for controller_config_dict in controller_config_dicts: self.logger.debug("Creating {}".format( controller_config_dict["name"])) # Get controller setup dict controller_uuid = controller_config_dict["uuid"] controller_setup_dict = self.get_controller_setup_dict( controller_uuid) # Verify valid controller config dict if controller_setup_dict == None: self.logger.critical( "Invalid controller uuid in device " "config. Validator should have caught this.") continue # Get controller module and class name module_name = ("device.controllers.modules." + controller_setup_dict["module_name"]) class_name = controller_setup_dict["class_name"] # Import controller library module_instance = __import__(module_name, fromlist=[class_name]) class_instance = getattr(module_instance, class_name) # Create controller manager controller_name = controller_config_dict["name"] controller_manager = class_instance(controller_name, self.state, controller_config_dict) self.controllers[controller_name] = controller_manager def spawn_controllers(self) -> None: """ Spawns controllers. """ if self.controllers == {}: self.logger.info("No controller threads to spawn") else: self.logger.info("Spawning controllers") for name, manager in self.controllers.items(): self.logger.debug("Spawning {}".format(name)) manager.spawn() def all_managers_initialized(self) -> bool: """Checks if all managers have initialized.""" if self.recipe.mode == modes.INIT: return False elif not self.all_peripherals_initialized(): return False elif not self.all_controllers_initialized(): return False return True def all_peripherals_initialized(self) -> bool: """Checks if all peripherals have initialized.""" for name, manager in self.peripherals.items(): if manager.mode == modes.INIT: return False return True def all_controllers_initialized(self) -> bool: """Checks if all controllers have initialized.""" for name, manager in self.controllers.items(): if manager.mode == modes.INIT: return False return True def shutdown_peripheral_threads(self) -> None: """Shutsdown all peripheral threads.""" for name, manager in self.peripherals.items(): manager.shutdown() def shutdown_controller_threads(self) -> None: """Shutsdown all controller threads.""" for name, manager in self.controllers.items(): manager.shutdown() def all_peripherals_shutdown(self) -> bool: """Check if all peripherals are shutdown.""" for name, manager in self.peripherals.items(): if manager.thread.is_alive(): return False return True def all_controllers_shutdown(self) -> bool: """Check if all controllers are shutdown.""" for name, manager in self.controllers.items(): if manager.thread.is_alive(): return False return True ##### EVENT FUNCTIONS ############################################################## def check_events(self) -> None: """Checks for a new event. Only processes one event per call, even if there are multiple in the queue. Events are processed first-in-first-out (FIFO).""" # Check for new events if self.event_queue.empty(): return # Get request request = self.event_queue.get() self.logger.debug("Received new request: {}".format(request)) # Get request parameters try: type_ = request["type"] except KeyError as e: message = "Invalid request parameters: {}".format(e) self.logger.exception(message) return # Execute request if type_ == events.RESET: self._reset() # Defined in parent class elif type_ == events.SHUTDOWN: self._shutdown() # Defined in parent class elif type_ == events.LOAD_DEVICE_CONFIG: self._load_device_config(request) else: self.logger.error( "Invalid event request type in queue: {}".format(type_)) def load_device_config(self, uuid: str) -> Tuple[str, int]: """Pre-processes load device config event request.""" self.logger.debug("Pre-processing load device config request") # Get filename of corresponding uuid filename = None for filepath in glob.glob(DEVICE_CONFIG_FILES_PATH): self.logger.debug(filepath) device_config = json.load(open(filepath)) if device_config["uuid"] == uuid: filename = filepath.split("/")[-1].replace(".json", "") # Verify valid config uuid if filename == None: message = "Invalid config uuid, corresponding filepath not found" self.logger.debug(message) return message, 400 # Check valid mode transition if enabled if not self.valid_transition(self.mode, modes.LOAD): message = "Unable to load device config from {} mode".format( self.mode) self.logger.debug(message) return message, 400 # Add load device config event request to event queue request = {"type": events.LOAD_DEVICE_CONFIG, "filename": filename} self.event_queue.put(request) # Successfully added load device config request to event queue message = "Loading config" return message, 200 def _load_device_config(self, request: Dict[str, Any]) -> None: """Processes load device config event request.""" self.logger.debug("Processing load device config request") # Get request parameters filename = request.get("filename") # Write config filename to device config path with open(DEVICE_CONFIG_PATH, "w") as f: f.write(str(filename) + "\n") # Transition to init mode on next state machine update self.mode = modes.LOAD
# Import device utilities from device.utilities.logger import Logger # Import driver elements from device.peripherals.modules.bacnet import exceptions # Conditionally import the bacpypes wrapper class, or use the simulator. # The brain that runs on PFCs doesn't have or need BACnet communications, # only the LGHC (running on linux) does. try: from device.peripherals.modules.bacnet import bnet_wrapper as BACNET except Exception as e: l = Logger("\n\nBACNet.driver", __name__) l.critical(e) from device.peripherals.modules.bacnet import bnet_simulator as BACNET class BacnetDriver: """Driver for BACNet communications to HVAC.""" # -------------------------------------------------------------------------- def __init__(self, name: str, simulate: bool = False, ini_file: str = None, config_file: str = None, debug: bool = False) -> None: """Initializes bacpypes.""" self.logger = Logger(name + ".BACNet", __name__)
class AtlasDriver: """Parent class for atlas drivers.""" def __init__( self, name: str, i2c_lock: threading.RLock, bus: int, address: int, mux: Optional[int] = None, channel: Optional[int] = None, simulate: bool = False, mux_simulator: Optional[MuxSimulator] = None, Simulator: Optional[PeripheralSimulator] = None, ) -> None: """ Initializes atlas driver. """ # Initialize parameters self.simulate = simulate # Initialize logger logname = "Driver({})".format(name) self.logger = Logger(logname, "peripherals") # Initialize I2C try: self.i2c = I2C( name=name, i2c_lock=i2c_lock, bus=bus, address=address, mux=mux, channel=channel, mux_simulator=mux_simulator, PeripheralSimulator=Simulator, ) except I2CError as e: raise exceptions.InitError(logger=self.logger) def setup(self, retry: bool = True) -> None: """Setsup sensor.""" self.logger.debug("Setting up sensor") try: self.enable_led() info = self.read_info() if info.firmware_version > 1.94: self.enable_protocol_lock() except Exception as e: raise exceptions.SetupError(logger=self.logger) from e def process_command( self, command_string: str, process_seconds: float, num_bytes: int = 31, retry: bool = True, read_response: bool = True, ) -> Optional[str]: """Sends command string to device, waits for processing seconds, then tries to read num response bytes with optional retry if device returns a `still processing` response code. Read retry is enabled by default. Returns response string on success or raises exception on error.""" self.logger.debug("Processing command: {}".format(command_string)) try: # Send command to device byte_array = bytearray(command_string + "\00", "utf8") self.i2c.write(bytes(byte_array), retry=retry) # Check if reading response if read_response: return self.read_response(process_seconds, num_bytes, retry=retry) # Otherwise return none return None except Exception as e: raise exceptions.ProcessCommandError(logger=self.logger) from e def read_response(self, process_seconds: float, num_bytes: int, retry: bool = True) -> str: """Reads response from from device. Waits processing seconds then tries to read num response bytes with optional retry. Returns response string on success or raises exception on error.""" # Give device time to process self.logger.debug("Waiting for {} seconds".format(process_seconds)) time.sleep(process_seconds) # Read device dataSet try: self.logger.debug("Reading response") data = self.i2c.read(num_bytes) except Exception as e: raise exceptions.ReadResponseError(logger=self.logger) from e # Format response code response_code = int(data[0]) # Check for invalid syntax if response_code == 2: message = "invalid command string syntax" raise exceptions.ReadResponseError(message=message, logger=self.logger) # Check if still processing elif response_code == 254: # Try to read one more time if retry enabled if retry == True: self.logger.debug("Sensor still processing, retrying read") return self.read_response(process_seconds, num_bytes, retry=False) else: message = "insufficient processing time" raise exceptions.ReadResponseError(message, logger=self.logger) # Check if device has no data to send elif response_code == 255: # Try to read one more time if retry enabled if retry == True: self.logger.warning( "Sensor reported no data to read, retrying read") return self.read_response(process_seconds, num_bytes, retry=False) else: message = "insufficient processing time" raise exceptions.ReadResponseError(message=message, logger=self.logger) # Invalid response code elif response_code != 1: message = "invalid response code" raise exceptions.ReadResponseError(message=message, logger=self.logger) # Successfully read response response_message = str(data[1:].decode("utf-8").strip("\x00")) self.logger.debug("Response:`{}`".format(response_message)) return response_message def read_info(self, retry: bool = True) -> Info: """Read sensor info register containing sensor type and firmware version. e.g. EC, 2.0.""" self.logger.debug("Reading info register") # Send command try: response = self.process_command("i", process_seconds=0.3, retry=retry) except Exception as e: raise exceptions.ReadInfoError(logger=self.logger) from e # Parse response _, sensor_type, firmware_version = response.split(",") # type: ignore firmware_version = float(firmware_version) # Store firmware version self.firmware_version = firmware_version # Create info dataclass info = Info(sensor_type=sensor_type.lower(), firmware_version=firmware_version) # Successfully read info self.logger.debug(str(info)) return info def read_status(self, retry: bool = True) -> Status: """Reads status from device.""" self.logger.debug("Reading status register") try: response = self.process_command("Status", process_seconds=0.3, retry=retry) except Exception as e: raise exceptions.ReadStatusError(logger=self.logger) from e # Parse response message command, code, voltage = response.split(",") # type: ignore # Break out restart code if code == "P": prev_restart_reason = "Powered off" self.logger.debug("Device previous restart due to powered off") elif code == "S": prev_restart_reason = "Software reset" self.logger.debug("Device previous restart due to software reset") elif code == "B": prev_restart_reason = "Browned out" self.logger.critical("Device browned out on previous restart") elif code == "W": prev_restart_reason = "Watchdog" self.logger.debug("Device previous restart due to watchdog") elif code == "U": self.prev_restart_reason = "Unknown" self.logger.warning("Device previous restart due to unknown") # Build status data class status = Status(prev_restart_reason=prev_restart_reason, voltage=float(voltage)) # Successfully read status self.logger.debug(str(status)) return status def enable_protocol_lock(self, retry: bool = True) -> None: """Enables protocol lock.""" self.logger.debug("Enabling protocol lock") try: self.process_command("Plock,1", process_seconds=0.9, retry=retry) except Exception as e: raise exceptions.EnableProtocolLockError(logger=self.logger) from e def disable_protocol_lock(self, retry: bool = True) -> None: """Disables protocol lock.""" self.logger.debug("Disabling protocol lock") try: self.process_command("Plock,0", process_seconds=0.9, retry=retry) except Exception as e: raise exceptions.DisableProtocolLockError( logger=self.logger) from e def enable_led(self, retry: bool = True) -> None: """Enables led.""" self.logger.debug("Enabling led") try: self.process_command("L,1", process_seconds=1.8, retry=retry) except Exception as e: raise exceptions.EnableLEDError(logger=self.logger) from e def disable_led(self, retry: bool = True) -> None: """Disables led.""" self.logger.debug("Disabling led") try: self.process_command("L,0", process_seconds=1.8, retry=retry) except Exception as e: raise exceptions.DisableLEDError(logger=self.logger) from e def enable_sleep_mode(self, retry: bool = True) -> None: """Enables sleep mode, sensor will wake up by sending any command to it.""" self.logger.debug("Enabling sleep mode") # Send command try: self.process_command("Sleep", process_seconds=0.3, read_response=False, retry=retry) except Exception as e: raise exceptions.EnableSleepModeError(logger=self.logger) from e def set_compensation_temperature(self, temperature: float, retry: bool = True) -> None: """Sets compensation temperature.""" self.logger.debug("Setting compensation temperature") try: command = "T,{}".format(temperature) self.process_command(command, process_seconds=0.3, retry=retry) except Exception as e: raise exceptions.SetCompensationTemperatureError( logger=self.logger) from e def calibrate_low(self, value: float, retry: bool = True) -> None: """Takes a low point calibration reading.""" self.logger.debug("Taking low point calibration reading") try: command = "Cal,low,{}".format(value) self.process_command(command, process_seconds=0.9, retry=retry) except Exception as e: raise exceptions.TakeLowPointCalibrationError( logger=self.logger) from e def calibrate_mid(self, value: float, retry: bool = True) -> None: """Takes a mid point calibration reading.""" self.logger.debug("Taking mid point calibration reading") try: command = "Cal,mid,{}".format(value) self.process_command(command, process_seconds=0.9, retry=retry) except Exception as e: raise exceptions.TakeMidPointCalibrationError( logger=self.logger) from e def calibrate_high(self, value: float, retry: bool = True) -> None: """Takes a high point calibration reading.""" self.logger.debug("Taking high point calibration reading") try: command = "Cal,high,{}".format(value) self.process_command(command, process_seconds=0.9, retry=retry) except Exception as e: raise exceptions.TakeHighPointCalibrationError( logger=self.logger) from e def clear_calibrations(self, retry: bool = True) -> None: """Clears calibration readings.""" self.logger.debug("Clearing calibration readings") try: self.process_command("Cal,clear", process_seconds=0.9, retry=retry) except Exception as e: raise exceptions.ClearCalibrationError(logger=self.logger) from e def factory_reset(self, retry: bool = True) -> None: """Resets sensor to factory config.""" self.logger.debug("Performing factory reset") try: self.process_command("Factory", process_seconds=0.3, read_response=False, retry=retry) except Exception as e: raise exceptions.FactoryResetError(logger=self.logger) from e
class RecipeManager(StateMachineManager): """Manages recipe state machine thread.""" def __init__(self, state: State) -> None: """Initializes recipe manager.""" # Initialize parent class super().__init__() # Initialize logger self.logger = Logger("Recipe", "recipe") # Initialize state self.state = state # Initialize state machine transitions self.transitions = { modes.INIT: [modes.NORECIPE, modes.ERROR], modes.NORECIPE: [modes.START, modes.ERROR], modes.START: [modes.QUEUED, modes.ERROR], modes.QUEUED: [modes.NORMAL, modes.STOP, modes.ERROR], modes.NORMAL: [modes.PAUSE, modes.STOP, modes.ERROR], modes.PAUSE: [modes.START, modes.ERROR], modes.STOP: [modes.NORECIPE, modes.ERROR], modes.ERROR: [modes.RESET], modes.RESET: [modes.INIT], } # Start state machine from init mode self.mode = modes.INIT @property def mode(self) -> str: """Gets mode value. Important to keep this local so all state transitions only occur from within thread.""" return self._mode @mode.setter def mode(self, value: str) -> None: """Safely updates recipe mode in shared state.""" self._mode = value with self.state.lock: self.state.recipe["mode"] = value @property def stored_mode(self) -> Optional[str]: """Gets the stored mode from shared state.""" value = self.state.recipe.get("stored_mode") if value != None: return str(value) else: return None @stored_mode.setter def stored_mode(self, value: Optional[str]) -> None: """Safely updates stored mode in shared state.""" with self.state.lock: self.state.recipe["stored_mode"] = value @property def recipe_uuid(self) -> Optional[str]: """Gets recipe uuid from shared state.""" value = self.state.recipe.get("recipe_uuid") if value != None: return str(value) else: return None @recipe_uuid.setter def recipe_uuid(self, value: Optional[str]) -> None: """Safely updates recipe uuid in shared state.""" with self.state.lock: self.state.recipe["recipe_uuid"] = value @property def recipe_name(self) -> Optional[str]: """Gets recipe name from shared state.""" value = self.state.recipe.get("recipe_name") if value != None: return str(value) else: return None @recipe_name.setter def recipe_name(self, value: Optional[str]) -> None: """ afely updates recipe name in shared state.""" with self.state.lock: self.state.recipe["recipe_name"] = value @property def is_active(self) -> bool: """Gets value.""" return self.state.recipe.get("is_active", False) # type: ignore @is_active.setter def is_active(self, value: bool) -> None: """Safely updates value in shared state.""" with self.state.lock: self.state.recipe["is_active"] = value @property def current_timestamp_minutes(self) -> int: """ Get current timestamp in minutes. """ return int(time.time() / 60) @property def start_timestamp_minutes(self) -> Optional[int]: """ Gets start timestamp minutes from shared state. """ value = self.state.recipe.get("start_timestamp_minutes") if value != None: return int(value) # type: ignore else: return None @start_timestamp_minutes.setter def start_timestamp_minutes(self, value: Optional[int]) -> None: """Generates start datestring then safely updates start timestamp minutes and datestring in shared state.""" # Define var type start_datestring: Optional[str] # Generate start datestring if value != None: val_int = int(value) # type: ignore start_datestring = ( datetime.datetime.fromtimestamp(val_int * 60).strftime( "%Y-%m-%d %H:%M:%S" ) + " UTC" ) else: start_datestring = None # Update start timestamp minutes and datestring in shared state with self.state.lock: self.state.recipe["start_timestamp_minutes"] = value self.state.recipe["start_datestring"] = start_datestring @property def start_datestring(self) -> Optional[str]: """Gets start datestring value from shared state.""" return self.state.recipe.get("start_datestring") # type: ignore @property def duration_minutes(self) -> Optional[int]: """Gets recipe duration in minutes from shared state.""" return self.state.recipe.get("duration_minutes") # type: ignore @duration_minutes.setter def duration_minutes(self, value: Optional[int]) -> None: """Generates duration string then safely updates duration string and minutes in shared state. """ # Define var type duration_string: Optional[str] # Generate duation string if value != None: duration_string = self.get_duration_string(value) # type: ignore else: duration_string = None # Safely update duration minutes and string in shared state with self.state.lock: self.state.recipe["duration_minutes"] = value self.state.recipe["duration_string"] = duration_string @property def last_update_minute(self) -> Optional[int]: """Gets the last update minute from shared state.""" return self.state.recipe.get("last_update_minute") # type: ignore @last_update_minute.setter def last_update_minute(self, value: Optional[int]) -> None: """Generates percent complete, percent complete string, time remaining minutes, time remaining string, and time elapsed string then safely updates last update minute and aforementioned values in shared state. """ # Define var types percent_complete_string: Optional[float] time_remaining_string: Optional[str] time_elapsed_string: Optional[str] # Generate values if value != None and self.duration_minutes != None: percent_complete = ( float(value) / self.duration_minutes * 100 # type: ignore ) percent_complete_string = "{0:.2f} %".format( # type: ignore percent_complete ) time_remaining_minutes = self.duration_minutes - value # type: ignore time_remaining_string = self.get_duration_string(time_remaining_minutes) time_elapsed_string = self.get_duration_string(value) # type: ignore else: percent_complete = None # type: ignore percent_complete_string = None time_remaining_minutes = None time_remaining_string = None time_elapsed_string = None # Safely update values in shared state with self.state.lock: self.state.recipe["last_update_minute"] = value self.state.recipe["percent_complete"] = percent_complete self.state.recipe["percent_complete_string"] = percent_complete_string self.state.recipe["time_remaining_minutes"] = time_remaining_minutes self.state.recipe["time_remaining_string"] = time_remaining_string self.state.recipe["time_elapsed_string"] = time_elapsed_string @property def percent_complete(self) -> Optional[float]: """Gets percent complete from shared state.""" return self.state.recipe.get("percent_complete") # type: ignore @property def percent_complete_string(self) -> Optional[str]: """Gets percent complete string from shared state.""" return self.state.recipe.get("percent_complete_string") # type: ignore @property def time_remaining_minutes(self) -> Optional[int]: """Gets time remaining minutes from shared state.""" return self.state.recipe.get("time_remaining_minutes") # type: ignore @property def time_remaining_string(self) -> Optional[str]: """Gets time remaining string from shared state.""" return self.state.recipe.get("time_remaining_string") # type: ignore @property def time_elapsed_string(self) -> Optional[str]: """Gets time elapsed string from shared state.""" value = self.state.recipe.get("time_elapsed_string") if value != None: return str(value) else: return None @property def current_phase(self) -> Optional[str]: """Gets the recipe current phase from shared state.""" value = self.state.recipe.get("current_phase") if value != None: return str(value) else: return None @current_phase.setter def current_phase(self, value: str) -> None: """Safely updates current phase in shared state.""" with self.state.lock: self.state.recipe["current_phase"] = value @property def current_cycle(self) -> Optional[str]: """Gets the current cycle from shared state.""" value = self.state.recipe.get("current_cycle") if value != None: return str(value) else: return None @current_cycle.setter def current_cycle(self, value: Optional[str]) -> None: """Safely updates current cycle in shared state.""" with self.state.lock: self.state.recipe["current_cycle"] = value @property def current_environment_name(self) -> Optional[str]: """Gets the current environment name from shared state""" value = self.state.recipe.get("current_environment_name") if value != None: return str(value) else: return None @current_environment_name.setter def current_environment_name(self, value: Optional[str]) -> None: """Safely updates current environment name in shared state.""" with self.state.lock: self.state.recipe["current_environment_name"] = value @property def current_environment_state(self) -> Any: """Gets the current environment state from shared state.""" return self.state.recipe.get("current_environment_name") @current_environment_state.setter def current_environment_state(self, value: Optional[Dict]) -> None: """ Safely updates current environment state in shared state. """ with self.state.lock: self.state.recipe["current_environment_state"] = value self.set_desired_sensor_values(value) # type: ignore ##### STATE MACHINE FUNCTIONS ###################################################### def run(self) -> None: """Runs state machine.""" # Loop forever while True: # Check if thread is shutdown if self.is_shutdown: break # Check for mode transitions if self.mode == modes.INIT: self.run_init_mode() if self.mode == modes.NORECIPE: self.run_norecipe_mode() elif self.mode == modes.START: self.run_start_mode() elif self.mode == modes.QUEUED: self.run_queued_mode() elif self.mode == modes.NORMAL: self.run_normal_mode() elif self.mode == modes.PAUSE: self.run_pause_mode() elif self.mode == modes.STOP: self.run_stop_mode() elif self.mode == modes.RESET: self.run_reset_mode() elif self.mode == modes.ERROR: self.run_error_mode() elif self.mode == modes.SHUTDOWN: self.run_shutdown_mode() else: self.logger.critical("Invalid state machine mode") self.mode = modes.INVALID self.is_shutdown = True break def run_init_mode(self) -> None: """Runs initialization mode. Checks for stored recipe mode and transitions to mode if exists, else transitions to no recipe mode.""" self.logger.info("Entered INIT") # Initialize state self.is_active = False # Check for stored mode mode = self.state.recipe.get("stored_mode") if mode != None: self.logger.debug("Returning to stored mode: {}".format(mode)) self.mode = mode # type: ignore else: self.mode = modes.NORECIPE def run_norecipe_mode(self) -> None: """Runs no recipe mode. Clears recipe and desired sensor state then waits for new events and transitions.""" self.logger.info("Entered NORECIPE") # Set run state self.is_active = False # Clear state self.clear_recipe_state() self.clear_desired_sensor_state() # Loop forever while True: # Check for events self.check_events() # Check for transitions if self.new_transition(modes.NORECIPE): break # Update every 100ms time.sleep(0.1) def run_start_mode(self) -> None: """Runs start mode. Loads commanded recipe uuid into shared state, retrieves recipe json from recipe table, generates recipe transitions, stores them in the recipe transitions table, extracts recipe duration and start time then transitions to queued mode.""" # Set run state self.is_active = True try: self.logger.info("Entered START") # Get recipe json from recipe uuid recipe_json = models.RecipeModel.objects.get(uuid=self.recipe_uuid).json recipe_dict = json.loads(recipe_json) # Parse recipe transitions transitions = self.parse(recipe_dict) # Store recipe transitions in database self.store_recipe_transitions(transitions) # Set recipe duration self.duration_minutes = transitions[-1]["minute"] # Set recipe name self.recipe_name = recipe_dict["name"] self.logger.info("Started: {}".format(self.recipe_name)) # Transition to queued mode self.mode = modes.QUEUED except Exception as e: message = "Unable to start recipe, unhandled exception {}".format(e) self.logger.critical(message) self.mode = modes.NORECIPE def run_queued_mode(self) -> None: """Runs queued mode. Waits for recipe start timestamp to be greater than or equal to current timestamp then transitions to NORMAL.""" self.logger.info("Entered QUEUED") # Set state self.is_active = True # Initialize time counter prev_time_seconds = 0.0 # Loop forever while True: # Check if recipe is ready to run current = self.current_timestamp_minutes start = self.start_timestamp_minutes if current >= start: # type: ignore self.mode = modes.NORMAL break # Calculate remaining delay time delay_minutes = start - current # type: ignore # Log remaining delay time every hour if remaining time > 1 hour if delay_minutes > 60 and time.time() > prev_time_seconds + 3600: prev_time_seconds = time.time() delay_hours = int(delay_minutes / 60.0) self.logger.debug("Starting recipe in {} hours".format(delay_hours)) # Log remaining delay time every minute if remaining time < 1 hour elif delay_minutes < 60 and time.time() > prev_time_seconds + 60: prev_time_seconds = time.time() self.logger.debug("Starting recipe in {} minutes".format(delay_minutes)) # Check for events self.check_events() # Check for transitions if self.new_transition(modes.QUEUED): break # Update every 100ms time.sleep(0.1) def run_normal_mode(self) -> None: """ Runs normal mode. Updates recipe and environment states every minute. Checks for events and transitions.""" self.logger.info("Entered NORMAL") # Set state self.is_active = True # Update recipe environment on first entry self.update_recipe_environment() # Loop forever while True: # Update recipe and environment states every minute if self.new_minute(): self.update_recipe_environment() # Check for recipe end if self.current_phase == "End" and self.current_cycle == "End": self.logger.info("Recipe is over, so transitions from NORMAL to STOP") self.mode = modes.STOP break # Check for events self.check_events() # Check for transitions if self.new_transition(modes.NORMAL): break # Update every 100ms time.sleep(0.1) def run_pause_mode(self) -> None: """Runs pause mode. Clears recipe and desired sensor state, waits for new events and transitions.""" self.logger.info("Entered PAUSE") # Set state self.is_active = True # Clear recipe and desired sensor state self.clear_recipe_state() self.clear_desired_sensor_state() # Loop forever while True: # Check for events self.check_events() # Check for transitions if self.new_transition(modes.PAUSE): break # Update every 100ms time.sleep(0.1) def run_stop_mode(self) -> None: """Runs stop mode. Clears recipe and desired sensor state then transitions to no recipe mode.""" self.logger.info("Entered STOP") # Clear recipe and desired sensor states self.clear_recipe_state() self.clear_desired_sensor_state() # Set state self.is_active = False # Transition to NORECIPE self.mode = modes.NORECIPE def run_error_mode(self) -> None: """Runs error mode. Clears recipe state and desired sensor state then waits for new events and transitions.""" self.logger.info("Entered ERROR") # Clear recipe and desired sensor states self.clear_recipe_state() self.clear_desired_sensor_state() # Set state self.is_active = False # Loop forever while True: # Check for events self.check_events() # Check for transitions if self.new_transition(modes.ERROR): break # Update every 100ms time.sleep(0.1) def run_reset_mode(self) -> None: """Runs reset mode. Clears error state then transitions to init mode.""" self.logger.info("Entered RESET") # Transition to INIT self.mode = modes.INIT ##### HELPER FUNCTIONS ############################################################# def get_recipe_environment(self, minute: int) -> Any: """Gets environment object from database for provided minute.""" return ( models.RecipeTransitionModel.objects.filter(minute__lte=minute).order_by( "-minute" ).first() ) def store_recipe_transitions(self, recipe_transitions: List) -> None: """Stores recipe transitions in database.""" # Clear recipe transitions table in database models.RecipeTransitionModel.objects.all().delete() # Create recipe transitions entries for transitions in recipe_transitions: models.RecipeTransitionModel.objects.create( minute=transitions["minute"], phase=transitions["phase"], cycle=transitions["cycle"], environment_name=transitions["environment_name"], environment_state=transitions["environment_state"], ) def update_recipe_environment(self) -> None: """ Updates recipe environment. """ self.logger.debug("Updating recipe environment") current = self.current_timestamp_minutes start = self.start_timestamp_minutes self.last_update_minute = current - start # type: ignore environment = self.get_recipe_environment(self.last_update_minute) self.current_phase = environment.phase self.current_cycle = environment.cycle self.current_environment_name = environment.environment_name self.current_environment_state = environment.environment_state def clear_desired_sensor_state(self) -> None: """ Sets desired sensor state to null values. """ with self.state.lock: for variable in self.state.environment["sensor"]["desired"]: self.state.environment["sensor"]["desired"][variable] = None def clear_recipe_state(self) -> None: """Sets recipe state to null values.""" self.recipe_name = None self.recipe_uuid = None self.duration_minutes = None self.last_update_minute = None self.start_timestamp_minutes = None self.current_phase = None self.current_cycle = None self.current_environment_name = None self.current_environment_state = {} self.stored_mode = None def new_minute(self) -> bool: """Check if system clock is on a new minute.""" current = self.current_timestamp_minutes start = self.start_timestamp_minutes current_minute = current - start # type: ignore last_update_minute = self.state.recipe["last_update_minute"] if current_minute > last_update_minute: return True else: return False def get_duration_string(self, duration_minutes: int) -> str: """Converts duration in minutes to duration day-hour-minute string.""" days = int(float(duration_minutes) / (60 * 24)) hours = int((float(duration_minutes) - days * 60 * 24) / 60) minutes = int(duration_minutes - days * 60 * 24 - hours * 60) string = "{} Days {} Hours {} Minutes".format(days, hours, minutes) return string def set_desired_sensor_values(self, environment_dict: Dict) -> None: """Sets desired sensor values from provided environment dict.""" with self.state.lock: for variable in environment_dict: value = environment_dict[variable] self.state.environment["sensor"]["desired"][variable] = value def validate( self, json_: str, should_exist: Optional[bool] = None ) -> Tuple[bool, Optional[str]]: """Validates a recipe. Returns true if valid.""" # Load recipe schema schema = json.load(open(RECIPE_SCHEMA_PATH)) # Check valid json and try to parse recipe try: # Decode json recipe = json.loads(json_) # Validate recipe against schema jsonschema.validate(recipe, schema) # Get top level recipe parameters format_ = recipe["format"] version = recipe["version"] name = recipe["name"] uuid = recipe["uuid"] cultivars = recipe["cultivars"] cultivation_methods = recipe["cultivation_methods"] environments = recipe["environments"] phases = recipe["phases"] except json.decoder.JSONDecodeError as e: message = "Invalid recipe json encoding: {}".format(e) self.logger.debug(message) return False, message except jsonschema.exceptions.ValidationError as e: message = "Invalid recipe json schema: {}".format(e.message) self.logger.debug(message) return False, message except KeyError as e: self.logger.critical("Recipe schema did not ensure `{}` exists".format(e)) message = "Invalid recipe json schema: `{}` is requred".format(e) return False, message except Exception as e: self.logger.critical("Invalid recipe, unhandled exception: {}".format(e)) return False, "Unhandled exception: {}".format(type(e)) # Check valid uuid if uuid == None or len(uuid) == 0: return False, "Invalid uuid" # Check recipe existance criteria, does not check if should_exist == None recipe_exists = models.RecipeModel.objects.filter(uuid=uuid).exists() if should_exist == True and not recipe_exists: return False, "UUID does not exist" elif should_exist == False and recipe_exists: return False, "UUID already exists" # Check cycle environment key names are valid try: for phase in phases: for cycle in phase["cycles"]: cycle_name = cycle["name"] environment_key = cycle["environment"] if environment_key not in environments: message = "Invalid environment key `{}` in cycle `{}`".format( environment_key, cycle_name ) self.logger.debug(message) return False, message except KeyError as e: self.logger.critical("Recipe schema did not ensure `{}` exists".format(e)) message = "Invalid recipe json schema: `{}` is requred".format(e) return False, message # Build list of environment variables env_vars: List[str] = [] for env_key, env_dict in environments.items(): for env_var, _ in env_dict.items(): if env_var != "name" and env_var not in env_vars: env_vars.append(env_var) # Check environment variables are valid sensor variables for env_var in env_vars: if not models.SensorVariableModel.objects.filter(key=env_var).exists(): message = "Invalid recipe environment variable: `{}`".format(env_var) self.logger.debug(message) return False, message """ TODO: Reinstate these checks once cloud system has support for enforcing uniqueness of cultivars and cultivation methods. While we are at it, my as well do the same for variable types so can create "scientific" recipes from the cloud UI and send complete recipes. Cloud system will need a way to manage recipes and recipe derivatives. R.e. populating cultivars table, might just want to scrape seedsavers or leverage another existing organism database. Probably also want to think about organismal groups (i.e. classifications). Classifications could the standard scientific (Kingdom , Phylum, etc.) or a more user-friendly group (e.g. Leafy Greens, Six-Week Grows, Exotic Plants, Pre-Historic Plants, etc.) # Check cultivars are valid for cultivar in cultivars: cultivar_name = cultivar["name"] cultivar_uuid = cultivar["uuid"] if not models.CultivarModel.objects.filter(uuid=cultivar_uuid).exists(): message = "Invalid recipe cultivar: `{}`".format(cultivar_name) self.logger.debug(message) return False, message # Check cultivation methods are valid for method in cultivation_methods: method_name = method["name"] method_uuid = method["uuid"] if not models.CultivationMethodModel.objects.filter( uuid=method_uuid ).exists(): message = "Invalid recipe cultivation method: `{}`".format(method_name) self.logger.debug(message) return False, message """ # Recipe is valid return True, None def parse(self, recipe: Dict[str, Any]) -> List[Dict[str, Any]]: """ Parses recipe into state transitions. """ transitions = [] minute_counter = 0 for phase in recipe["phases"]: phase_name = phase["name"] for i in range(phase["repeat"]): for cycle in phase["cycles"]: # Get environment object and cycle name environment_key = cycle["environment"] environment = recipe["environments"][environment_key] cycle_name = cycle["name"] # Get duration if "duration_hours" in cycle: duration_hours = cycle["duration_hours"] duration_minutes = duration_hours * 60 elif "duration_minutes" in cycle: duration_minutes = cycle["duration_minutes"] else: raise KeyError( "Could not find 'duration_minutes' or 'duration_hours' in cycle" ) # Make shallow copy of env so we can pop a property locally environment_copy = dict(environment) environment_name = environment_copy["name"] del environment_copy["name"] environment_state = environment_copy # Write recipe transition to database transitions.append( { "minute": minute_counter, "phase": phase_name, "cycle": cycle_name, "environment_name": environment_name, "environment_state": environment_state, } ) # Increment minute counter minute_counter += duration_minutes # Set recipe end transitions.append( { "minute": minute_counter, "phase": "End", "cycle": "End", "environment_name": "End", "environment_state": {}, } ) # Return state transitions return transitions def check_events(self) -> None: """Checks for a new event. Only processes one event per call, even if there are multiple in the queue. Events are processed first-in-first-out (FIFO).""" # Check for new events if self.event_queue.empty(): return # Get request request = self.event_queue.get() self.logger.debug("Received new request: {}".format(request)) # Get request parameters try: type_ = request["type"] except KeyError as e: message = "Invalid request parameters: {}".format(e) self.logger.exception(message) return # Execute request if type_ == events.START: self._start_recipe(request) elif type_ == events.STOP: self._stop_recipe() else: self.logger.error("Invalid event request type in queue: {}".format(type_)) def start_recipe( self, uuid: str, timestamp: Optional[float] = None, check_mode: bool = True ) -> Tuple[str, int]: """Adds a start recipe event to event queue.""" self.logger.debug("Adding start recipe event to event queue") self.logger.debug("Recipe UUID: {}, timestamp: {}".format(uuid, timestamp)) # Check recipe uuid exists if not models.RecipeModel.objects.filter(uuid=uuid).exists(): message = "Unable to start recipe, invalid uuid" return message, 400 # Check timestamp is valid if provided if timestamp != None and timestamp < time.time(): # type: ignore message = "Unable to start recipe, timestamp must be in the future" return message, 400 # Check valid mode transition if enabled if check_mode and not self.valid_transition(self.mode, modes.START): message = "Unable to start recipe from {} mode".format(self.mode) self.logger.debug(message) return message, 400 # Add start recipe event request to event queue request = {"type": events.START, "uuid": uuid, "timestamp": timestamp} self.event_queue.put(request) # Successfully added recipe to event queue message = "Starting recipe" return message, 202 def _start_recipe(self, request: Dict[str, Any]) -> None: """Starts a recipe. Assumes request has been verified in public start recipe function.""" self.logger.debug("Starting recipe") # Get request parameters uuid = request.get("uuid") timestamp = request.get("timestamp") # Convert timestamp to minutes if not None if timestamp != None: timestamp_minutes = int(timestamp / 60.0) # type: ignore else: timestamp_minutes = int(time.time() / 60.0) # Check valid mode transition if not self.valid_transition(self.mode, modes.START): self.logger.critical("Tried to start recipe from {} mode".format(self.mode)) return # Start recipe on next state machine update self.recipe_uuid = uuid self.start_timestamp_minutes = timestamp_minutes self.mode = modes.START def stop_recipe(self, check_mode: bool = True) -> Tuple[str, int]: """Adds stop recipe event to event queue.""" self.logger.debug("Adding stop recipe event to event queue") # Check valid mode transition if enabled if check_mode and not self.valid_transition(self.mode, modes.STOP): message = "Unable to stop recipe from {} mode".format(self.mode) self.logger.debug(message) return message, 400 # Put request into queue request = {"type": events.STOP} self.event_queue.put(request) # Successfully added stop recipe to event queue message = "Stopping recipe" return message, 200 def _stop_recipe(self) -> None: """Stops a recipe. Assumes request has been verified in public stop recipe function.""" self.logger.debug("Stopping recipe") # Check valid mode transition if not self.valid_transition(self.mode, modes.STOP): self.logger.critical("Tried to stop recipe from {} mode".format(self.mode)) return # Stop recipe on next state machine update self.mode = modes.STOP def create_recipe(self, json_: str) -> Tuple[str, int]: """Creates a recipe into database.""" self.logger.debug("Creating recipe") # Check if recipe is valid is_valid, error = self.validate(json_, should_exist=False) if not is_valid: message = "Unable to create recipe. {}".format(error) self.logger.debug(message) return message, 400 # Create recipe in database try: recipe = json.loads(json_) models.RecipeModel.objects.create(json=json.dumps(recipe)) message = "Successfully created recipe" return message, 200 except: message = "Unable to create recipe, unhandled exception" self.logger.exception(message) return message, 500 def update_recipe(self, json_: str) -> Tuple[str, int]: """Updates an existing recipe in database.""" # Check if recipe is valid is_valid, error = self.validate(json_, should_exist=False) if not is_valid: message = "Unable to update recipe. {}".format(error) self.logger.debug(message) return message, 400 # Update recipe in database try: recipe = json.loads(json_) r = models.RecipeModel.objects.get(uuid=recipe["uuid"]) r.json = json.dumps(recipe) r.save() message = "Successfully updated recipe" return message, 200 except: message = "Unable to update recipe, unhandled exception" self.logger.exception(message) return message, 500 def create_or_update_recipe(self, json_: str) -> Tuple[str, int]: """Creates or updates an existing recipe in database.""" # Check if recipe is valid is_valid, error = self.validate(json_, should_exist=None) if not is_valid: message = "Unable to create/update recipe -> {}".format(error) return message, 400 # Check if creating or updating recipe in database recipe = json.loads(json_) if not models.RecipeModel.objects.filter(uuid=recipe["uuid"]).exists(): # Create recipe try: recipe = json.loads(json_) models.RecipeModel.objects.create(json=json.dumps(recipe)) message = "Successfully created recipe" return message, 200 except: message = "Unable to create recipe, unhandled exception" self.logger.exception(message) return message, 500 else: # Update recipe try: r = models.RecipeModel.objects.get(uuid=recipe["uuid"]) r.json = json.dumps(recipe) r.save() message = "Successfully updated recipe" return message, 200 except: message = "Unable to update recipe, unhandled exception" self.logger.exception(message) return message, 500 def recipe_exists(self, uuid: str) -> bool: """Checks if a recipe exists.""" return models.RecipeModel.objects.filter(uuid=uuid).exists()