Example #1
0
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__)
Example #3
0
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
Example #4
0
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()