Exemplo n.º 1
0
class IotManager(manager.StateMachineManager):
    """Manages IoT communications to the Google cloud backend MQTT service."""

    # Keep track of the previous values that we have published.
    # We only publish a value if it changes.
    prev_environment_variables: Dict[str, Any] = {}
    last_status = datetime.datetime.utcnow()

    def __init__(self, state: State, recipe: RecipeManager) -> None:
        """Initializes iot manager."""

        # Initialize parent class
        super().__init__()

        # Initialize parameters
        self.state = state
        self.recipe = recipe

        # Initialize logger
        self.logger = logger.Logger("IotManager", "iot")
        self.logger.debug("Initializing manager")

        # Initialize our state variables
        self.received_message_count = 0
        self.published_message_count = 0

        # Initialize device info
        self.device_id = registration.device_id()

        # Initialize topics
        self.config_topic = "/devices/{}/config".format(self.device_id)
        self.command_topic = "/devices/{}/commands".format(self.device_id)
        self.telemetry_topic = "/devices/{}/events".format(self.device_id)

        # Initialize pubsub handler
        self.pubsub = PubSub(
            ref_self=self,
            on_connect=on_connect,
            on_disconnect=on_disconnect,
            on_publish=on_publish,
            on_message=on_message,
            on_subscribe=on_subscribe,
            on_log=on_log,
        )

        # Initialize state machine transitions
        self.transitions: Dict[str, List[str]] = {
            modes.INIT: [
                modes.CONNECTED,
                modes.DISCONNECTED,
                modes.ERROR,
                modes.SHUTDOWN,
            ],
            modes.CONNECTED: [
                modes.INIT,
                modes.DISCONNECTED,
                modes.ERROR,
                modes.SHUTDOWN,
            ],
            modes.DISCONNECTED: [
                modes.INIT,
                modes.CONNECTED,
                modes.SHUTDOWN,
                modes.ERROR,
            ],
            modes.ERROR: [modes.SHUTDOWN],
        }

        # Initialize state machine mode
        self.mode = modes.INIT

        # Initialize recipe modes
        self.previous_recipe_mode = recipe_modes.NORECIPE
        

    ##### INTERNAL STATE DECORATORS ####################################################

    @property
    def is_connected(self) -> bool:
        """Gets value."""
        return self.state.iot.get("is_connected", False)  # type: ignore

    @is_connected.setter
    def is_connected(self, value: bool) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["is_connected"] = value

    @property
    def is_registered(self) -> bool:
        """Gets value."""
        return self.state.iot.get("is_registered", False)  # type: ignore

    @is_registered.setter
    def is_registered(self, value: bool) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["is_registered"] = value

    @property
    def device_id(self) -> str:
        """Gets value."""
        return self.state.iot.get("device_id", "UNKNOWN")  # type: ignore

    @device_id.setter
    def device_id(self, value: bool) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["device_id"] = value

    @property
    def verification_code(self) -> str:
        """Gets value."""
        return self.state.iot.get("verification_code", "UNKNOWN")  # type: ignore

    @verification_code.setter
    def verification_code(self, value: str) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["verification_code"] = value

    @property
    def prev_message_id(self) -> str:
        """Gets value."""
        stored = self.state.iot.get("stored", {})
        return stored.get("prev_message_id")  # type: ignore

    @prev_message_id.setter
    def prev_message_id(self, value: str) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            if "stored" not in self.state.iot:
                self.state.iot["stored"] = {}
            self.state.iot["stored"]["prev_message_id"] = value

    @property
    def received_message_count(self) -> int:
        """Gets value."""
        return self.state.iot.get("received_message_count", 0)  # type: ignore

    @received_message_count.setter
    def received_message_count(self, value: int) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["received_message_count"] = value

    @property
    def published_message_count(self) -> int:
        """Gets value."""
        return self.state.iot.get("published_message_count", 0)  # type: ignore

    @published_message_count.setter
    def published_message_count(self, value: int) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["published_message_count"] = value
    
    @property
    def recipe_mode(self) -> str:
        """Gets recipe mode."""
        return self.state.recipe.get("mode", recipe_modes.NORECIPE)

    ##### EXTERNAL STATE DECORATORS ####################################################

    @property
    def network_is_connected(self) -> bool:
        """Gets value."""
        return self.state.network.get("is_connected", False)  # type: ignore

    ##### STATE MACHINE FUNCTIONS ######################################################

    def run(self) -> None:
        """Runs state machine."""

        # Loop forever
        while True:

            # Check if manager is shutdown
            if self.is_shutdown:
                break

            # Check for mode transitions
            if self.mode == modes.INIT:
                self.run_init_mode()
            elif self.mode == modes.CONNECTED:
                self.run_connected_mode()
            elif self.mode == modes.DISCONNECTED:
                self.run_disconnected_mode()
            elif self.mode == modes.ERROR:
                self.run_error_mode()  # defined in parent classs
            elif self.mode == modes.SHUTDOWN:
                self.run_shutdown_mode()  # defined in parent class
            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."""
        self.logger.debug("Entered INIT")

        # Connect to pubsub service
        self.pubsub = PubSub(
            self,
            on_connect,
            on_disconnect,
            on_publish,
            on_message,
            on_subscribe,
            on_log,
        )

        # Initialize iot connection state
        self.is_connected = False

        # Initialize registration state
        self.is_registered = registration.is_registered()
        self.device_id = registration.device_id()
        self.verification_code = registration.verification_code()

        # Check if network is connected
        if not self.network_is_connected:
            self.logger.info("Waiting for network to come online")

        # Loop forever
        while True:

            # Check if network is connected
            if self.network_is_connected:
                self.logger.debug("Network came online")
                break

            # Update every 100ms
            time.sleep(0.1)

        # Give the network time to initialize
        self.logger.debug("Waiting 30 seconds for network to initialize")
        time.sleep(30)

        # Check if device is registered
        if not self.is_registered:
            self.logger.debug("Device not registered, registering device")
            registration.register()

            # Update registation state
            self.is_registered = registration.is_registered()
            self.device_id = registration.device_id()
            self.verification_code = registration.verification_code()
            self.config_topic = "/devices/{}/config".format(self.device_id)
            self.command_topic = "/devices/{}/commands".format(self.device_id)
            self.telemetry_topic = "/devices/{}/events".format(self.device_id)

        # Initialize pubsub client
        self.pubsub.initialize()

        # Transition to disconnected mode on next state machine update
        self.mode = modes.DISCONNECTED

    def run_disconnected_mode(self) -> None:
        """Runs disconnected mode."""
        self.logger.info("Entered DISCONNECTED")

        start_time = time.time()
        update_interval = 1  # seconds
        max_update_interval = 10

        # Loop forever
        while True:

            # Update mqtt broker
            try:
                self.pubsub.update()
            except:
                self.pubsub.initialize()

            # Check if connected, transition if so
            if self.is_connected:
                self.mode = modes.CONNECTED

            # Try to reconnect client every update interval
            if time.time() - start_time > update_interval:
                try:
                    start_time = time.time()
                    self.pubsub.client.reconnect()
                except Exception as e:
                    message = "Unable to reconnect, unhandled exception: {}".format(type(e))
                    self.logger.exception(message)
                    if update_interval < max_update_interval:
                        update_interval = update_interval*2


            # Check for events
            self.check_events()

            # Check for transitions
            if self.new_transition(modes.DISCONNECTED):
                break

            # Update every 100ms
            time.sleep(0.1)

    def run_connected_mode(self) -> None:
        """Runs connected mode."""
        self.logger.info("Entered CONNECTED")

        # Subscribe to topics # This is handled in the on_connect callback
        # self.pubsub.subscribe_to_topics()

        # Publish a boot message
        self.publish_boot_message()

        # Initialize timing variables
        last_update_time = 0
        update_interval = 300  # seconds -> 5 minutes
        last_update_all_time = 0
        update_all_interval = 900  # seconds -> 15 minutes

        # Loop forever
        while True:

            # Publish all environment data
            if self.new_recipe() or (time.time() - last_update_all_time > update_all_interval):
                last_update_all_time = time.time()
                last_update_time = last_update_all_time
                self.publish_system_summary()
                self.publish_environment_variables(publish_all=True)

            # Publish changes in environment data
            if time.time() - last_update_time > update_interval:
                last_update_time = time.time()
                self.publish_system_summary()
                self.publish_environment_variables()
                if self.new_images(): # we only need to check for new images every update_interval
                    self.logger.info("Found new images")
                    self.publish_images()

            # Update pubsub
            self.pubsub.update()

            # Check for events
            self.check_events()

            # Check for transitions
            if self.new_transition(modes.CONNECTED):
                break

            # Update every 100ms
            time.sleep(0.1)

    ##### HELPER FUNCTIONS ##################################################

    def new_recipe(self) -> bool:
        """Checks if a new recipe has been started."""
        if self.recipe_mode != self.previous_recipe_mode:
            self.previous_recipe_mode = self.recipe_mode
            if self.recipe_mode == recipe_modes.NORMAL:
                self.logger.debug("Started new recipe")
                return True
        # self.previous_recipe_mode = self.recipe_mode
        return False

    def new_images(self) -> bool:
        """Checks if there are new images that are not currently open in another process."""
        image_files = glob.glob(IMAGES_DIR + "*.png")
        if len(image_files) > 0:
            return True
        #for image_file in image_files:
        #    lsof_result = os.system(f"lsof -f -- {image_file} > /dev/null 2>&1")
        #    if lsof_result != 0:
        #        return True
        return False
        

    ##### PUBLISHER FUNCTIONS ###############################################

    def publish_message(self, name: str, message: str) -> None:
        """Send a command reply. TODO: Fix this."""
        self.pubsub.publish_command_reply(name, message)

    def publish_recipe_event(self, action: str, name: str) -> None:
        """Send a recipe event message."""
        self.pubsub.publish_recipe_event(self.device_id, action, name)

    def publish_boot_message(self) -> None:
        """Publishes boot message."""
        self.logger.debug("Publishing boot message")

        # Build boot message
        message = {
            "device_config": system.device_config_name(),
            "package_version": self.state.upgrade.get("current_version"),
            "IP": self.state.network.get("ip_address"),
            "access_point": os.getenv("WIFI_ACCESS_POINT"),
            "serial_number": os.getenv("SERIAL_NUMBER"),
            "remote_URL": os.getenv("REMOTE_DEVICE_UI_URL"),
            "bbb_serial": "DEPRECATED",
        }

        # Publish boot message
        self.logger.debug("Boot message: {}".format(message))
        self.pubsub.publish_boot_message(message)

    def publish_system_summary(self) -> None:
        """Publishes status message."""
        self.logger.debug("Publishing system summary")

        # Build summary
        recipe_percent_complete_string = self.state.recipe.get(
            "percent_complete_string"
        )
        recipe_time_remaining_minutes = self.state.recipe.get("time_remaining_minutes")
        recipe_time_remaining_string = self.state.recipe.get("time_remaining_string")
        recipe_time_elapsed_string = self.state.recipe.get("time_elapsed_string")
        message = {
            "timestamp": time.strftime("%FT%XZ", time.gmtime()),
            "IP": self.state.network.get("ip_address"),
            "package_version": self.state.upgrade.get("current_version"),
            "device_config": system.device_config_name(),
            "internet_connection": self.state.network.get("is_connected"),
            "memory_available": self.state.resource.get("free_memory"),
            "disk_available": self.state.resource.get("available_disk_space"),
            "iot_received_message_count": self.received_message_count,
            "iot_published_message_count": self.published_message_count,
            "recipe_percent_complete": self.state.recipe.get("percent_complete"),
            "recipe_percent_complete_string": recipe_percent_complete_string,
            "recipe_time_remaining_minutes": recipe_time_remaining_minutes,
            "recipe_time_remaining_string": recipe_time_remaining_string,
            "recipe_time_elapsed_string": recipe_time_elapsed_string,
        }

        # Publish system summary as a status message
        self.pubsub.publish_status_message(message)

    def publish_environment_variables(self, publish_all: bool = False) -> None:
        """Publishes environment variables."""
        self.logger.debug(f"Publishing {'all' if publish_all else 'changed'} environment variables")

        # Get environment variables
        keys = ["reported_sensor_stats", "individual", "instantaneous"]
        environment_variables = accessors.get_nested_dict_safely(
            self.state.environment, keys
        )

        # Ensure environment variables is a dict
        if environment_variables == None:
            environment_variables = {}

        # For each value, only publish the ones that have changed.
        for name, value in environment_variables.items():
            if publish_all:
                self.prev_environment_variables[name] = copy.deepcopy(value)
                self.pubsub.publish_environment_variable(name, value)
            elif self.prev_environment_variables.get(name) != value:
                self.prev_environment_variables[name] = copy.deepcopy(value)
                self.pubsub.publish_environment_variable(name, value)

    def publish_images(self) -> None:
        """Publishes images in the images directory. On successful publish, moves them 
        to the stored images directory."""
        self.logger.debug("Publishing images")

        # Check for images to publish
        published_image_count = 0
        try:
            image_file_list = glob.glob(IMAGES_DIR + "*.png")
            self.logger.debug("Found {} images".format(len(image_file_list)))
            for image_file in image_file_list:

                # TODO: Fix this for fswebcam (i.e. non-picam)
                # Is this file open by a process? (fswebcam)
                #self.logger.info("lsof -f -- {} > /dev/null 2>&1".format(image_file))
                #lsof_result = os.system("lsof -f -- {} > /dev/null 2>&1".format(image_file))
                #self.logger.info(f"lsof_result: {lsof_result}")
                #if lsof_result == 0:
                #    self.logger.info(f"Skipping {image_file} because it's still open by a process")
                #    continue  # Yes, so skip it and try the next one.

                # Check the file size
                fsize = os.path.getsize(image_file)
                # If the size is < 200KB, then it is garbage we delete
                # (based on the 1280x1024 average file size)
                if fsize < 500:  # in KB
                    self.logger.debug(f"Removing {image_file} due to small size")
                    os.remove(image_file)
                    continue

                # Upload the image and publish a message it was done
                self.pubsub.upload_image(image_file)

                # Check if stored directory exists, if not create it
                if not os.path.isdir(STORED_IMAGES_DIR):
                    os.mkdir(STORED_IMAGES_DIR)

                # Move image from image directory once processed
                stored_image_file = image_file.replace(IMAGES_DIR, STORED_IMAGES_DIR)
                shutil.move(image_file, stored_image_file)

                # Increment count
                published_image_count += 1

        except Exception as e:
            message = "Unable to publish images, unhandled exception: {}".format(e)
            self.logger.exception(message)
        
        self.logger.debug(f"Published {published_image_count} image(s)")

    ##### DEVICE EVENT FUNCTIONS ##############################################

    def reregister(self) -> Tuple[str, int]:
        """Unregisters device by deleting iot registration data. TODO: This needs to go 
        into the event queue, deleting reg data in middle of a registration update 
        creates an unstable state."""
        self.logger.info("Re-registering device")

        # Check network connection
        if not self.network_is_connected:
            return "Unable to re-register, network disconnected", 400

        # Re-register device and update state
        try:
            registration.delete()
            registration.register()
            self.device_id = registration.device_id()
            self.verification_code = registration.verification_code()
        except Exception as e:
            message = "Unable to re-register, unhandled exception: {}".format(type(e))
            self.logger.exception(message)
            self.mode = modes.ERROR
            return message, 500

        # Transition to init mode on next state machine update
        self.mode = modes.INIT

        # Successfully deleted registration data
        return "Successfully re-registered device", 200

    #### SUBSCRIBER FUNCTIONS ################################################

    def on_command_message(self, message: mqtt.MQTTMessage) -> None:
        """Processes command messages received from iot cloud."""
        self.logger.debug("Processing command message")

        # Route command type
        if "recipe/start" in message.topic:
            self.start_recipe(message)
        elif "recipe/stop" in message.topic:
            self.logger.debug("Received stop recipe command")
            self.stop_recipe(message)
        else:
            self.logger.error("Received unknown command: {}".format(command.topic))

    def on_config_message(self, message: mqtt.MQTTMessage) -> None:
        """Processes config messages received from iot cloud."""

        # NOTE: This method needs to get deprecated...

        self.logger.debug(
            "Processing config message, payload: {}".format(message.payload)
        )

        # Decode and parse message
        try:
            payload = message.payload.decode("utf-8")
            payload_dict = json.loads(payload)
        except json.decoder.JSONDecodeError:
            self.logger.warning("Unable to process message, payload is invalid json")
            self.logger.warning("payload = `{}`".format(payload))
            return
        except KeyError:
            self.logger.warning("Unable to get message version, setting to 0")
            message_version = 0
        except Exception as e:
            message = "Unable to parse payload, unhandled exception".format(type(e))
            self.logger.exception(message)
            return

        # Get message fields
        try:
            command_messages = payload_dict["commands"]
            message_id = payload_dict["messageId"]
        except KeyError as e:
            message = "Unable to get command messages, `{}` key is required".format(e)
            self.logger.error(message)
            return

        # Check if message is old
        if message_id == self.prev_message_id:
            self.logger.debug("Received old message, not processing")
            return
        else:
            self.prev_message_id = message_id

        # Process all command messages
        for command_message in command_messages:
            self.process_config_command_message(command_message)

    def process_config_command_message(self, message: Dict[str, Any]) -> None:
        """Process commands received from the backend (UI). This is a callback
        that is called by the IoTPubSub class when this device receives
        commands from the UI."""
        self.logger.debug("Processing command message")

        # Get command parameters
        try:
            command = message["command"].upper()  # TODO: Fix this, shouldn't need upper
            arg0 = message["arg0"]
            arg1 = message["arg1"]
        except KeyError as e:
            error_message = "Unable to process command, `{}` key is required".format(e)
            self.logger.error(error_message)
            return

        # Process command
        if command == commands.START_RECIPE:
            self.forcibly_create_and_start_recipe(command, arg0)
        elif command == commands.DOWNLOAD_AND_START_RECIPE:
            self.download_and_start_recipe(command, arg0)
        elif command == commands.STOP_RECIPE:
            self.stop_recipe(command)
        else:
            self.unknown_command(command)

    ##### COMMAND FUNCTIONS ###############################################

    def start_recipe(self, message):
        self.logger.info("Received start recipe command")

        # Get parameters
        try:
            payload = message.payload.decode("utf-8")
            payload_dict = json.loads(payload)
            recipe_uuid = payload_dict.get("recipe_uuid")
            recipe_dict = payload_dict.get("recipe_dict")
            recipe_url = payload_dict.get("recipe_url")
        except json.decoder.JSONDecodeError:
            self.logger.warning("Invalid json in payload: {}".format(payload))
            return
        # self.logger.debug("payload_dict = {}".format(payload_dict))
        # self.logger.debug("recipe_uuid = {}".format(recipe_uuid))
        # self.logger.debug("recipe_dict = {}".format(recipe_dict))

        # Check if downloading recipe, or forcibly starting recipe
        # Note: Neither of these options should be used in future architectures...
        if recipe_dict != None:
            # Note: Converting recipe dict back to json is a temporary hack
            recipe_json = json.dumps(recipe_dict)
            self.forcibly_create_and_start_recipe(commands.START_RECIPE, recipe_json)
        elif recipe_url != None:
            self.download_and_start_recipe(
                commands.DOWNLOAD_AND_START_RECIPE, recipe_url
            )
        else:
            self.logger.error("Received invalid start recipe request")
            return

    def download_and_start_recipe(self, command: str, URL: str) -> None:
        """ Download the recipe from storage and forcibly start it.
            Initially used with the recipe generator service for the LGHC.
            But generally useful because there is a 64K limit to the message
            size we can send to a device over IoT.
        """
        recipe = urllib.request.urlopen(URL)
        recipe_json = recipe.read().decode("utf-8")
        self.logger.debug(f"Downloaded recipe {URL}")
        self.forcibly_create_and_start_recipe(command, recipe_json)

    def forcibly_create_and_start_recipe(self, command: str, recipe_json: str) -> None:
        """ Forcibly starts and creates recipe. TODO: This method should be
            depricated by a cadence of iot commands between iot cloud and this
            device. Cloud backend should look at device state and check if a
            recipe is already running as well as have insight onto what recipes
            alread exist on the device, etc."""
        self.logger.warning("Forcibly creating and starting recipe")

        # Validate recipe
        is_valid, error = self.recipe.validate(recipe_json)
        if not is_valid:
            error_message = "Received invalid recipe"
            self.logger.warning(error_message)
            self.pubsub.publish_command_reply(command, error_message)
            return

        # Get recipe uuid
        recipe_dict = json.loads(recipe_json)
        recipe_uuid = recipe_dict["uuid"]

        # Check if recipe already exists on device
        if self.recipe.recipe_exists(recipe_uuid):
            self.logger.warning("Overwriting previously existing recipe")

        # Create or update recipe
        message, status = self.recipe.create_or_update_recipe(recipe_json)

        # Check for create or update recipe errors
        if status != 200:
            error_message = "Unable to create/update recipe, error: {}".format(message)
            self.logger.warning(error_message)
            self.pubsub.publish_command_reply(command, error_message)
            return

        # Check if recipe is active, if so stop it
        if self.recipe.is_active:
            self.logger.warning("Forcibly stopping currently running recipe")
            message, status = self.recipe.stop_recipe()

            # Check for stop recipe errors
            if status != 200:
                error_message = "Unable to stop recipe, error: {}".format(message)
                self.logger.warning(error_message)
                self.pubsub.publish_command_reply(command, error_message)
                return

            # Wait for recipe to stop
            self.logger.debug("Waiting for recipe to stop")

            # Initialize recipe stop timeout parameters
            timeout = 5  # seconds
            start_time = time.time()

            # Loop forever
            while True:

                # Check if recipe manager entered no recipe mode
                if self.recipe.mode == recipe_modes.NORECIPE:
                    self.logger.debug("Recipe successfully stopped")
                    break

                # Check for timeout
                if time.time() - start_time > timeout:
                    error_message = "Unable to start recipe, recipe did not stop within"
                    error_message += " {} seconds of issuing stop command"
                    self.logger.warning(error_message)
                    return

                # Update every 100ms
                time.sleep(0.1)

        # Start recipe
        self.logger.debug("Starting recipe")
        message, status = self.recipe.start_recipe(recipe_uuid)

        # Check for start recipe errors
        if status != 202:
            error_message = "Unable to start recipe, error: {}".format(message)
            self.logger.warning(error_message)
            self.pubsub.publish_command_reply("error", error_message)

    def stop_recipe(self, command: str) -> None:
        """Processes stop recipe command."""
        self.logger.info("Stopping recipe")

        # Stop recipe
        message, status = self.recipe.stop_recipe()

        # Check for stop recipe errors
        if status != 200:
            error_message = "Unable to stop recipe, error: {}".format(message)
            self.logger.warning(error_message)
            self.pubsub.publish_command_reply("error", error_message)

    def unknown_command(self, command: str) -> None:
        """Processes unknown command."""
        message = "Received unknown command"
        self.logger.warning(message)
        self.pubsub.publish_command_reply("error", message)
Exemplo n.º 2
0
class IotManager(manager.StateMachineManager):
    """Manages IoT communications to the Google cloud backend MQTT service."""

    # Keep track of the previous values that we have published.
    # We only publish a value if it changes.
    prev_environment_variables: Dict[str, Any] = {}
    last_status = datetime.datetime.utcnow()

    def __init__(self, state: State, recipe: RecipeManager) -> None:
        """Initializes iot manager."""

        # Initialize parent class
        super().__init__()

        # Initialize parameters
        self.state = state
        self.recipe = recipe

        # Initialize logger
        self.logger = logger.Logger("IotManager", "iot")
        self.logger.debug("Initializing manager")

        # Initialize our state variables
        self.received_message_count = 0
        self.published_message_count = 0

        # Initialize pubsub handler
        self.pubsub = PubSub(
            ref_self=self,
            on_connect=on_connect,
            on_disconnect=on_disconnect,
            on_publish=on_publish,
            on_message=on_message,
            on_subscribe=on_subscribe,
            on_log=on_log,
        )

        # Initialize state machine transitions
        self.transitions: Dict[str, List[str]] = {
            modes.INIT:
            [modes.CONNECTED, modes.DISCONNECTED, modes.ERROR, modes.SHUTDOWN],
            modes.CONNECTED:
            [modes.INIT, modes.DISCONNECTED, modes.ERROR, modes.SHUTDOWN],
            modes.DISCONNECTED:
            [modes.INIT, modes.CONNECTED, modes.SHUTDOWN, modes.ERROR],
            modes.ERROR: [modes.SHUTDOWN],
        }

        # Initialize state machine mode
        self.mode = modes.INIT

    ##### INTERNAL STATE DECORATORS ####################################################

    @property
    def is_connected(self) -> bool:
        """Gets value."""
        return self.state.iot.get("is_connected", False)  # type: ignore

    @is_connected.setter
    def is_connected(self, value: bool) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["is_connected"] = value

    @property
    def is_registered(self) -> bool:
        """Gets value."""
        return self.state.iot.get("is_registered", False)  # type: ignore

    @is_registered.setter
    def is_registered(self, value: bool) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["is_registered"] = value

    @property
    def device_id(self) -> str:
        """Gets value."""
        return self.state.iot.get("device_id", "UNKNOWN")  # type: ignore

    @device_id.setter
    def device_id(self, value: bool) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["device_id"] = value

    @property
    def verification_code(self) -> str:
        """Gets value."""
        return self.state.iot.get("verification_code",
                                  "UNKNOWN")  # type: ignore

    @verification_code.setter
    def verification_code(self, value: str) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["verification_code"] = value

    @property
    def prev_message_id(self) -> str:
        """Gets value."""
        stored = self.state.iot.get("stored", {})
        return stored.get("prev_message_id")  # type: ignore

    @prev_message_id.setter
    def prev_message_id(self, value: str) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            if "stored" not in self.state.iot:
                self.state.iot["stored"] = {}
            self.state.iot["stored"]["prev_message_id"] = value

    @property
    def received_message_count(self) -> int:
        """Gets value."""
        return self.state.iot.get("received_message_count", 0)  # type: ignore

    @received_message_count.setter
    def received_message_count(self, value: int) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["received_message_count"] = value

    @property
    def published_message_count(self) -> int:
        """Gets value."""
        return self.state.iot.get("published_message_count", 0)  # type: ignore

    @published_message_count.setter
    def published_message_count(self, value: int) -> None:
        """Safely updates value in shared state."""
        with self.state.lock:
            self.state.iot["published_message_count"] = value

    ##### EXTERNAL STATE DECORATORS ####################################################

    @property
    def network_is_connected(self) -> bool:
        """Gets value."""
        return self.state.network.get("is_connected", False)  # type: ignore

    ##### STATE MACHINE FUNCTIONS ######################################################

    def run(self) -> None:
        """Runs state machine."""

        # Loop forever
        while True:

            # Check if manager is shutdown
            if self.is_shutdown:
                break

            # Check for mode transitions
            if self.mode == modes.INIT:
                self.run_init_mode()
            elif self.mode == modes.CONNECTED:
                self.run_connected_mode()
            elif self.mode == modes.DISCONNECTED:
                self.run_disconnected_mode()
            elif self.mode == modes.ERROR:
                self.run_error_mode()  # defined in parent classs
            elif self.mode == modes.SHUTDOWN:
                self.run_shutdown_mode()  # defined in parent class
            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."""
        self.logger.debug("Entered INIT")

        # Connect to pubsub service
        self.pubsub = PubSub(
            self,
            on_connect,
            on_disconnect,
            on_publish,
            on_message,
            on_subscribe,
            on_log,
        )

        # Initialize iot connection state
        self.is_connected = False

        # Initialize registration state
        self.is_registered = registration.is_registered()
        self.device_id = registration.device_id()
        self.verification_code = registration.verification_code()

        # Check if network is connected
        if not self.network_is_connected:
            self.logger.info("Waiting for network to come online")

        # Loop forever
        while True:

            # Check if network is connected
            if self.network_is_connected:
                self.logger.debug("Network came online")
                break

            # Update every 100ms
            time.sleep(0.1)

        # Give the network time to initialize
        time.sleep(30)

        # Check if device is registered
        if not self.is_registered:
            self.logger.debug("Device not registered, registering device")
            registration.register()

            # Update registration state
            self.is_registered = registration.is_registered()
            self.device_id = registration.device_id()
            self.verification_code = registration.verification_code()

        # Initialize pubsub client
        self.pubsub.initialize()

        # Transition to disconnected mode on next state machine update
        self.mode = modes.DISCONNECTED

    def run_disconnected_mode(self) -> None:
        """Runs disconnected mode."""
        self.logger.debug("Entered DISCONNECTED")

        start_time = time.time()
        update_interval = 1  # seconds

        # Loop forever
        while True:

            # Update mqtt broker
            try:
                self.pubsub.update()
            except:
                self.pubsub.initialize()

            # Check if connected, transition if so
            if self.is_connected:
                self.mode = modes.CONNECTED

            # Try to reconnect client every update interval
            if time.time() - start_time > update_interval:
                self.pubsub.client.reconnect()
                start_time = time.time()

            # Check for events
            self.check_events()

            # Check for transitions
            if self.new_transition(modes.DISCONNECTED):
                break

            # Update every 100ms
            time.sleep(0.1)

    def run_connected_mode(self) -> None:
        """Runs connected mode."""
        self.logger.debug("Entered CONNECTED")

        # Publish a boot message
        self.publish_boot_message()

        # Initialize timing variables
        last_update_time = 0.0
        update_interval = 300  # seconds -> 5 minutes

        # Loop forever
        while True:

            # Publish messages
            if time.time() - last_update_time > update_interval:
                last_update_time = time.time()
                self.publish_system_summary()
                self.publish_environment_variables()
                self.publish_images()

            # Update pubsub
            self.pubsub.update()

            # Check for events
            self.check_events()

            # Check for transitions
            if self.new_transition(modes.CONNECTED):
                break

            # Update every 100ms
            time.sleep(0.1)

    ##### IOT PUBLISH FUNCTIONS ########################################################

    def publish_message(self, name: str, message: str) -> None:
        """Send a command reply. TODO: Fix this."""
        self.pubsub.publish_command_reply(name, message)

    def publish_boot_message(self) -> None:
        """Publishes boot message."""
        self.logger.debug("Publishing boot message")

        # Build boot message
        message = {
            "device_config": system.device_config_name(),
            "package_version": self.state.upgrade.get("current_version"),
            "IP": self.state.network.get("ip_address"),
            "access_point": os.getenv("WIFI_ACCESS_POINT"),
            "serial_number": os.getenv("SERIAL_NUMBER"),
            "remote_URL": os.getenv("REMOTE_DEVICE_UI_URL"),
            "bbb_serial": "DEPRECATED",
        }

        # Publish boot message
        self.logger.debug("Boot message: {}".format(message))
        self.pubsub.publish_boot_message(message)

    def publish_system_summary(self) -> None:
        """Publishes status message."""
        self.logger.debug("Publishing system summary")

        # Build summary
        recipe_percent_complete_string = self.state.recipe.get(
            "percent_complete_string")
        recipe_time_remaining_minutes = self.state.recipe.get(
            "time_remaining_minutes")
        recipe_time_remaining_string = self.state.recipe.get(
            "time_remaining_string")
        recipe_time_elapsed_string = self.state.recipe.get(
            "time_elapsed_string")
        message = {
            "timestamp": time.strftime("%FT%XZ", time.gmtime()),
            "IP": self.state.network.get("ip_address"),
            "package_version": self.state.upgrade.get("current_version"),
            "device_config": system.device_config_name(),
            "internet_connection": self.state.network.get("is_connected"),
            "memory_available": self.state.resource.get("free_memory"),
            "disk_available": self.state.resource.get("available_disk_space"),
            "iot_received_message_count": self.received_message_count,
            "iot_published_message_count": self.published_message_count,
            "recipe_percent_complete":
            self.state.recipe.get("percent_complete"),
            "recipe_percent_complete_string": recipe_percent_complete_string,
            "recipe_time_remaining_minutes": recipe_time_remaining_minutes,
            "recipe_time_remaining_string": recipe_time_remaining_string,
            "recipe_time_elapsed_string": recipe_time_elapsed_string,
        }

        # Publish system summary as a status message
        self.pubsub.publish_status_message(message)

    def publish_environment_variables(self) -> None:
        """Publishes environment variables."""
        self.logger.debug("Publishing environment variables")

        # Get environment variables
        keys = ["reported_sensor_stats", "individual", "instantaneous"]
        environment_variables = accessors.get_nested_dict_safely(
            self.state.environment, keys)

        # Ensure environment variables is a dict
        if environment_variables == None:
            environment_variables = {}

        # Keep a copy of the first set of values (usually None). Why?
        if self.prev_environment_variables == {}:
            self.environment_variables = copy.deepcopy(environment_variables)

        # For each value, only publish the ones that have changed.
        for name, value in environment_variables.items():
            if self.prev_environment_variables.get(name) != value:
                self.environment_variables[name] = copy.deepcopy(value)
                self.pubsub.publish_environment_variable(name, value)

    def publish_images(self) -> None:
        """Publishes images in the images directory. On successful publish, moves them 
        to the stored images directory."""
        self.logger.debug("Publishing images")

        # Check for images to publish
        try:
            image_file_list = glob.glob(IMAGES_DIR + "*.png")
            for image_file in image_file_list:

                # Is this file open by a process? (fswebcam)
                if os.system("lsof -f -- {} > /dev/null 2>&1".format(
                        image_file)) == 0:
                    continue  # Yes, so skip it and try the next one.

                # 2018-06-15-T18:34:45Z_Camera-Top.png
                fn1 = image_file.split("_")
                fn2 = fn1[1]  # Camera-Top.png
                fn3 = fn2.split(".")
                camera_name = fn3[0]  # Camera-Top

                # Get the file contents
                f = open(image_file, "rb")
                file_bytes = f.read()
                f.close()

                # If the size is < 200KB, then it is garbage we delete
                # (based on the 1280x1024 average file size)
                if len(file_bytes) < 200000:
                    os.remove(image_file)
                    continue

                self.pubsub.publish_binary_image(camera_name, "png",
                                                 file_bytes)

                # Check if stored directory exists, if not create it
                if not os.path.isdir(STORED_IMAGES_DIR):
                    os.mkdir(STORED_IMAGES_DIR)

                # Move image from image directory once processed
                stored_image_file = image_file.replace(IMAGES_DIR,
                                                       STORED_IMAGES_DIR)
                shutil.move(image_file, stored_image_file)

        except Exception as e:
            message = "Unable to publish images, unhandled exception: {}".format(
                e)
            self.logger.exception(message)

    ##### DEVICE EVENT FUNCTIONS #######################################################

    def reregister(self) -> Tuple[str, int]:
        """Unregisters device by deleting iot registration data. TODO: This needs to go 
        into the event queue, deleting reg data in middle of a registration update 
        creates an unstable state."""
        self.logger.info("Re-registering device")

        # Check network connection
        if not self.network_is_connected:
            return "Unable to re-register, network disconnected", 400

        # Re-register device and update state
        try:
            registration.delete()
            registration.register()
            self.device_id = registration.device_id()
            self.verification_code = registration.verification_code()
        except Exception as e:
            message = "Unable to re-register, unhandled exception: {}".format(
                type(e))
            self.logger.exception(message)
            self.mode = modes.ERROR
            return message, 500

        # Transition to init mode on next state machine update
        self.mode = modes.INIT

        # Successfully deleted registration data
        return "Successfully re-registered device", 200

    #### IOT MESSAGE FUNCTIONS #########################################################

    def process_message(self, message: mqtt.MQTTMessage) -> None:
        """Processes messages from iot cloud."""
        self.logger.debug("Processing message")

        # Decode and parse message
        try:
            payload = message.payload.decode("utf-8")
            payload_dict = json.loads(payload)
        except json.decoder.JSONDecodeError:
            self.logger.warning(
                "Unable to process message, payload is invalid json")
            self.logger.warning("payload = `{}`".format(payload))
            return
        except KeyError:
            self.logger.warning("Unable to get message version, setting to 0")
            message_version = 0
        except Exception as e:
            message = "Unable to parse payload, unhandled exception".format(
                type(e))
            self.logger.exception(message)
            return

        # Get message fields
        try:
            command_messages = payload_dict["commands"]
            message_id = payload_dict["messageId"]
        except KeyError as e:
            message = "Unable to get command messages, `{}` key is required".format(
                e)
            self.logger.error(message)
            return

        # Check if message is old
        if message_id == self.prev_message_id:
            self.logger.debug("Received old message, not processing")
            return
        else:
            self.prev_message_id = message_id

        # Process all command messages
        for command_message in command_messages:
            self.process_command_message(command_message)

    def process_command_message(self, message: Dict[str, Any]) -> None:
        """Process commands received from the backend (UI). This is a callback that is 
        called by the IoTPubSub class when this device receives commands from the UI."""
        self.logger.debug("Processing command message")

        # Get command parameters
        try:
            command = message["command"].upper(
            )  # TODO: Fix this, shouldn't need upper
            arg0 = message["arg0"]
            arg1 = message["arg1"]
        except KeyError as e:
            error_message = "Unable to process command, `{}` key is required".format(
                e)
            self.logger.error(error_message)
            return

        # Process command
        if command == commands.START_RECIPE:
            self.forcibly_create_and_start_recipe(command, arg0)
        elif command == commands.STOP_RECIPE:
            self.stop_recipe(command)
        else:
            self.unknown_command(command)

    ##### IOT COMMAND FUNCTIONS ########################################################

    def forcibly_create_and_start_recipe(self, command: str,
                                         recipe_json: str) -> None:
        """Forcible starts and creates recipe. TODO: This method should be depricated
        by a cadence of iot commands between iot cloud and this device. Cloud backend
        should look at device state and check if a recipe is already running as well as
        have insight onto what recipes alread exist on the device, etc."""
        self.logger.warning("Forcibly creating and starting recipe")

        # Validate recipe
        is_valid, error = self.recipe.validate(recipe_json)
        if not is_valid:
            error_message = "Received invalid recipe"
            self.logger.warning(error_message)
            self.pubsub.publish_command_reply(command, error_message)
            return

        # Get recipe uuid
        recipe_dict = json.loads(recipe_json)
        recipe_uuid = recipe_dict["uuid"]

        # Check if recipe already exists on device
        if self.recipe.recipe_exists(recipe_uuid):
            self.logger.warning("Overwriting previously existing recipe")

        # Create or update recipe
        message, status = self.recipe.create_or_update_recipe(recipe_json)

        # Check for create or update recipe errors
        if status != 200:
            error_message = "Unable to create/update recipe, error: {}".format(
                message)
            self.logger.warning(error_message)
            self.pubsub.publish_command_reply(command, error_message)
            return

        # Check if recipe is active, if so stop it
        if self.recipe.is_active:
            self.logger.warning("Forcibly stopping currently running recipe")
            message, status = self.recipe.stop_recipe()

            # Check for stop recipe errors
            if status != 200:
                error_message = "Unable to stop recipe, error: {}".format(
                    message)
                self.logger.warning(error_message)
                self.pubsub.publish_command_reply(command, error_message)
                return

            # Wait for recipe to stop
            self.logger.debug("Waiting for recipe to stop")

            # Initialize recipe stop timeout parameters
            timeout = 5  # seconds
            start_time = time.time()

            # Loop forever
            while True:

                # Check if recipe manager entered no recipe mode
                if self.recipe.mode == recipe_modes.NORECIPE:
                    self.logger.debug("Recipe successfully stopped")
                    break

                # Check for timeout
                if time.time() - start_time > timeout:
                    error_message = "Unable to start recipe, recipe did not stop within"
                    error_message += " {} seconds of issuing stop command"
                    self.logger.warning(error_message)
                    return

                # Update every 100ms
                time.sleep(0.1)

        # Start recipe
        self.logger.debug("Starting recipe")
        message, status = self.recipe.start_recipe(recipe_uuid)

        # Check for start recipe errors
        if status != 202:
            error_message = "Unable to start recipe, error: {}".format(message)
            self.logger.warning(error_message)

        # Publish command reply
        self.pubsub.publish_command_reply(command, message)

    def stop_recipe(self, command: str) -> None:
        """Processes stop recipe command."""
        self.logger.debug("Stopping recipe")

        # Stop recipe
        message, status = self.recipe.stop_recipe()

        # Check for stop recipe errors
        if status != 200:
            error_message = "Unable to stop recipe, error: {}".format(message)
            self.logger.warning(error_message)

        # Publish command reply
        self.pubsub.publish_command_reply(command, message)

    def unknown_command(self, command: str) -> None:
        """Processes unknown command."""
        message = "Received unknown command"
        self.logger.warning(message)
        self.pubsub.publish_command_reply(command, message)