class MqttClient: """ Basic wrapper around Paho mqtt """ _mqtt_client = None def __init__(self, config: dict, connect_callback: Callable, message_callback: Callable) -> None: if self.valid_arguments(config=config, callbacks=[connect_callback, message_callback]): self._config = config self._on_connect_callback = connect_callback self._on_message_callback = message_callback self.log = Logging(owner=__file__, config=True) def __del__(self) -> None: pass @staticmethod def valid_arguments(config: dict, callbacks: list) -> bool: callables = [callable(callback) for callback in callbacks] valid = 'broker' in config and any(callables) if valid: return True raise IllegalArgumentError def connect(self) -> bool: self._mqtt_client = paho_mqtt.Client() try: self._mqtt_client.connect(host=self._config.get("broker"), port=self._config.get("port", None), keepalive=self._config.get("stay_alive", None)) self._mqtt_client.on_connect = self._on_connect self._mqtt_client.on_message = self._on_message self._mqtt_client.loop_start() except (ConnectionRefusedError, TimeoutError) as err: self.log.error(f'Failed to connect to MQTT broker ({self._config.get("broker", None)}). Error: {err}') return False return True def _on_connect(self, _client, _userdata, _flags, _rc) -> None: self.log.success(f'Connected to MQTT broker ({self._config.get("broker")})') self._on_connect_callback() def _on_message(self, _client, _userdata, message) -> None: self.log.debug(f'Received message on topic {message.topic!r}') topic = message.topic payload = message.payload.decode("utf-8") self._on_message_callback(topic=topic, payload=payload) def publish(self, topic: str, msg: Union[dict, str]) -> bool: self.log.debug(f'Publishing message {msg!r} on topic {topic!r}') message_info = self._mqtt_client.publish(topic=topic, payload=dumps(msg), qos=1) if message_info.rc != 0: self.log.warning('Message did not get published successfully') return False return True def subscribe(self, topics: list) -> None: for topic in topics: self._mqtt_client.subscribe(topic=topic, qos=1)
class GBridge(threading.Thread): subscribed_event = ['device_state_changed'] mqtt_client = None g_bridge_connected = False attached_devices = [] pending_messages = [] pending_subscribed_topics = [] def __init__(self, queue, thread_event: threading.Event): threading.Thread.__init__(self) self.log = Logging(owner=__file__, log_mode='terminal', min_log_lvl=LogLevels.debug) gateway_configuration = MqttGatewayConfiguration() self.observer_notify_queue = Queue(maxsize=100) self.observer_publish_queue = queue self._thread_ready = thread_event keys_dir = get_keys_dir() gateway_configuration.private_key_file = Path(keys_dir, gateway_configuration.private_key_file) gateway_configuration.ca_certs = Path(keys_dir, gateway_configuration.ca_certs) self.gateway_id = gateway_configuration.gateway_id self.connect_to_iot_core_broker(gateway_configuration) def __del__(self): self.detach_all_devices() self.mqtt_client.disconnect() self.mqtt_client.loop_stop() def run(self): self.mqtt_client.loop_start() self.wait_for_connection(5) self._thread_ready.set() while self.g_bridge_connected: queue_item = self.observer_notify_queue.get() self.send_data(msg=queue_item) time.sleep(ONE_MILLISECOND_SECONDS) def notify(self, msg, _) -> None: self.observer_notify_queue.put(item=msg) @staticmethod def poll_events(): return [] def connect_to_iot_core_broker(self, conf): # Create the MQTT client and connect to Cloud IoT. gateway_id = f'projects/{conf.project_id}/locations/{conf.cloud_region}/registries/' \ f'{conf.registry_id}/devices/{conf.gateway_id}' self.mqtt_client = mqtt.Client(gateway_id) jwt_pwd = create_jwt(conf.project_id, conf.private_key_file, conf.algorithm) self.mqtt_client.username_pw_set(username='******', password=jwt_pwd) self.mqtt_client.tls_set(ca_certs=conf.ca_certs, tls_version=ssl.PROTOCOL_TLSv1_2) self.mqtt_client.on_connect = self.on_connect self.mqtt_client.on_publish = self.on_publish self.mqtt_client.on_disconnect = self.on_disconnect self.mqtt_client.on_subscribe = self.on_subscribe self.mqtt_client.on_message = self.on_message self.mqtt_client.connect(conf.mqtt_bridge_hostname, conf.mqtt_bridge_port) def wait_for_connection(self, timeout): total_time = 0 while not self.g_bridge_connected and total_time < timeout: time.sleep(1) total_time += 1 if not self.g_bridge_connected: self.log.critical('Could not connect to Iot Core MQTT bridge') raise RuntimeError() def on_connect(self, _unused_client, _unused_userdata, _unused_flags, rc): self.log.success(f'Connected to GCP IoT core MQTT Broker with connection Result: {error_str(rc)}') self.g_bridge_connected = True self.subscribe_to_topics(self.gateway_id, True) if self.attached_devices: # Not empty list, Previously already had connected devices self.log.warning('Re-connect occurred! Re-attaching all connected devices.') def subscribe_to_topics(self, dev_id, gateway): config_topic = f'/devices/{dev_id}/config' command_topic = f'/devices/{dev_id}/commands/#' subscriptions = [{'topic': config_topic, 'qos': 1}, {'topic': command_topic, 'qos': 1}] if gateway: gateway_error_topic = f'/devices/{dev_id}/errors' subscriptions.append({'topic': gateway_error_topic, 'qos': 0}) for subscription in subscriptions: self.subscribe(subscription.get('topic'), subscription.get('qos')) def subscribe(self, topic, qos): _, mid = self.mqtt_client.subscribe(topic, qos) self.pending_subscribed_topics.append(mid) while topic in self.pending_subscribed_topics: time.sleep(0.01) self.log.debug(f'Successfully subscribed to topic {topic!r} with Qos {qos!r}.') def on_disconnect(self, _unused_client, _unused_userdata, rc): self.log.warning(f'Disconnected: {error_str(rc)!r}') self.g_bridge_connected = False def on_publish(self, _unused_client, _unused_userdata, mid): self.log.debug(f'ACK received for message {mid!r}') if mid in self.pending_messages: self.pending_messages.remove(mid) def on_subscribe(self, _unused_client, _unused_userdata, mid, granted_qos): if granted_qos[0] == 128: self.log.error(f'Subscription result: {granted_qos[0]!r} - Subscription failed') else: if mid in self.pending_subscribed_topics: self.pending_subscribed_topics.remove(mid) def on_message(self, _unused_client, _unused_userdata, message): payload = message.payload.decode('utf-8') self.log.info(f'Received message {payload!r} on topic {message.topic!r}.') if not payload: return # todo: fix this so that is better if message.topic.split('/')[3] == "commands": device_id = GBridge.get_id_from_topic(message.topic) queue_message = {'device_id': device_id, 'event_type': 'command', 'payload': payload} item = {'event': 'gcp_state_changed', 'message': queue_message} self.observer_publish_queue.put(item) def attach_device(self, device_id): self.log.debug(f'Attaching device {device_id!r}.') attach_topic = f'/devices/{device_id}/attach' if device_id not in self.attached_devices: self.attached_devices.append(device_id) self.publish(attach_topic, "") # Message content is empty because gateway auth-method=ASSOCIATION_ONLY self.subscribe_to_topics(device_id, False) def detach_device(self, device_id): self.log.warning(f'Detaching device {device_id!r}.') detach_topic = f'/devices/{device_id}/detach' if device_id in self.attached_devices: self.attached_devices.remove(device_id) self.publish(detach_topic, "") # Message content is empty because gateway auth-method=ASSOCIATION_ONLY def detach_all_devices(self): self.log.info(f'Detaching all devices. Currently all connected devices: {self.attached_devices}.') for device in self.attached_devices[:]: self.detach_device(device) while self.attached_devices: # Make sure all devices have been detached time.sleep(0.01) def publish(self, topic, payload): message_info = self.mqtt_client.publish(topic, payload, qos=1) self.pending_messages.append(message_info.mid) self.log.info(f'Publishing payload: {payload!r} on Topic {topic!r} with mid {message_info.mid!r}.') while message_info.mid in self.pending_messages: # Waiting for message ACK to arrive time.sleep(0.01) def send_data(self, msg): device_id = msg.get('device_id') event_type = msg.get('event_type') payload = msg.get('payload') if device_id not in self.attached_devices: self.attach_device(device_id=device_id) if event_type == 'telemetry': topic = f'/devices/{device_id}/events' elif event_type == 'state': topic = f'/devices/{device_id}/state' else: self.log.error(f'Unknown event type {event_type}.') return self.publish(topic, payload) @staticmethod def get_id_from_topic(topic): index_device_id = 2 dir_tree = topic.split('/') if len(dir_tree) != 4 or dir_tree[1] != "devices": return None return dir_tree[index_device_id] def reattach_devices(self): for device in self.attached_devices: self.log.info(f'Re-attaching device {device}.') self.attach_device(device)
class HealthMonitor(Thread): running = True update_time_sec = 600 subscribed_event = [] def __init__(self, queue: Queue, thread_event: Event) -> None: Thread.__init__(self) self.observer_publish_queue = queue self._thread_ready = thread_event self.observer_notify_queue = Queue(maxsize=100) self.log = Logging(owner=__file__, config=True) def __del__(self) -> None: self.running = False def run(self) -> None: self.log.info(f'Updating system information every {self.update_time_sec} seconds.') self._thread_ready.set() while self.running: start_time = time() host_data = self._fetch_host_data() msg = ObserverMessage(event="host_health", data=host_data) self.observer_publish_queue.put(msg) sleep_time = self.update_time_sec - ((time() - start_time) % self.update_time_sec) sleep(sleep_time) def notify(self, msg: ObserverMessage) -> None: pass def _fetch_host_data(self) -> dict: data = { 'timestamp': self._get_timestamp(), 'temperature': self.poll_system_temp(), 'cpu_load': self.poll_cpu_load() } return data @staticmethod def _get_timestamp() -> datetime: return datetime.now() def poll_system_temp(self) -> float: temp_file = self._get_temperature_file() try: with open(temp_file) as file: return float(file.readline()) / 1000 except FileNotFoundError: self.log.critical(f'Temperature file {temp_file!r} does not exist') return 0 @staticmethod def _get_temperature_file() -> Path: return Path('/sys/class/thermal/thermal_zone0/temp') def poll_cpu_load(self) -> float: cpu_command = ["cat", "/proc/stat"] try: with subprocess.Popen(cpu_command, stdout=subprocess.PIPE) as process_result: proc_stat, _ = process_result.communicate() cpu_data = proc_stat.decode('utf-8').split('\n')[0].split()[1:-1] cpu_data = [int(field) for field in cpu_data] cpu_usage = ((cpu_data[0] + cpu_data[2]) * 100 / (cpu_data[0] + cpu_data[2] + cpu_data[3])) return round(cpu_usage, 3) except FileNotFoundError as error: self.log.critical(f'Command {" ".join(cpu_command)!r} was not found on the system: {error}') except ValueError as error: self.log.error(f'Parsing of the data went wrong: {error}') return 0