commit
						e0ce1e9660
					
				| 
						 | 
					@ -2,7 +2,7 @@
 | 
				
			||||||
<project version="4">
 | 
					<project version="4">
 | 
				
			||||||
  <component name="SqlDialectMappings">
 | 
					  <component name="SqlDialectMappings">
 | 
				
			||||||
    <file url="file://$PROJECT_DIR$/docker/postgres/init.sql" dialect="PostgreSQL" />
 | 
					    <file url="file://$PROJECT_DIR$/docker/postgres/init.sql" dialect="PostgreSQL" />
 | 
				
			||||||
    <file url="file://$PROJECT_DIR$/exporter/processors.py" dialect="PostgreSQL" />
 | 
					    <file url="file://$PROJECT_DIR$/exporter/processor_base.py" dialect="PostgreSQL" />
 | 
				
			||||||
    <file url="PROJECT" dialect="PostgreSQL" />
 | 
					    <file url="PROJECT" dialect="PostgreSQL" />
 | 
				
			||||||
  </component>
 | 
					  </component>
 | 
				
			||||||
</project>
 | 
					</project>
 | 
				
			||||||
| 
						 | 
					@ -1 +1 @@
 | 
				
			||||||
from .processors import MessageProcessor
 | 
					from .processor_base import MessageProcessor
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										37
									
								
								exporter/client_details.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								exporter/client_details.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,37 @@
 | 
				
			||||||
 | 
					from meshtastic.config_pb2 import Config
 | 
				
			||||||
 | 
					from meshtastic.mesh_pb2 import HardwareModel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ClientDetails:
 | 
				
			||||||
 | 
					    def __init__(self, node_id, short_name='Unknown', long_name='Unknown', hardware_model=HardwareModel.UNSET,
 | 
				
			||||||
 | 
					                 role=None):
 | 
				
			||||||
 | 
					        self.node_id = node_id
 | 
				
			||||||
 | 
					        self.short_name = short_name
 | 
				
			||||||
 | 
					        self.long_name = long_name
 | 
				
			||||||
 | 
					        self.hardware_model: HardwareModel = hardware_model
 | 
				
			||||||
 | 
					        self.role: Config.DeviceConfig.Role = role
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_dict(self):
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					            'node_id': self.node_id,
 | 
				
			||||||
 | 
					            'short_name': self.short_name,
 | 
				
			||||||
 | 
					            'long_name': self.long_name,
 | 
				
			||||||
 | 
					            'hardware_model': self.get_hardware_model_name_from_code(self.hardware_model),
 | 
				
			||||||
 | 
					            'role': self.get_role_name_from_role(self.role)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def get_role_name_from_role(role):
 | 
				
			||||||
 | 
					        descriptor = Config.DeviceConfig.Role.DESCRIPTOR
 | 
				
			||||||
 | 
					        for enum_value in descriptor.values:
 | 
				
			||||||
 | 
					            if enum_value.number == role:
 | 
				
			||||||
 | 
					                return enum_value.name
 | 
				
			||||||
 | 
					        return 'UNKNOWN_ROLE'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def get_hardware_model_name_from_code(hardware_model):
 | 
				
			||||||
 | 
					        descriptor = HardwareModel.DESCRIPTOR
 | 
				
			||||||
 | 
					        for enum_value in descriptor.values:
 | 
				
			||||||
 | 
					            if enum_value.number == hardware_model:
 | 
				
			||||||
 | 
					                return enum_value.name
 | 
				
			||||||
 | 
					        return 'UNKNOWN_HARDWARE_MODEL'
 | 
				
			||||||
							
								
								
									
										271
									
								
								exporter/processor_base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										271
									
								
								exporter/processor_base.py
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,271 @@
 | 
				
			||||||
 | 
					import base64
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from cryptography.hazmat.backends import default_backend
 | 
				
			||||||
 | 
					from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 | 
				
			||||||
 | 
					from meshtastic.mesh_pb2 import MeshPacket, Data, HardwareModel
 | 
				
			||||||
 | 
					from meshtastic.portnums_pb2 import PortNum
 | 
				
			||||||
 | 
					from prometheus_client import CollectorRegistry, Counter, Histogram, Gauge
 | 
				
			||||||
 | 
					from psycopg_pool import ConnectionPool
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from exporter.client_details import ClientDetails
 | 
				
			||||||
 | 
					from exporter.processors import ProcessorRegistry
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class MessageProcessor:
 | 
				
			||||||
 | 
					    def __init__(self, registry: CollectorRegistry, db_pool: ConnectionPool):
 | 
				
			||||||
 | 
					        self.rx_rssi_gauge = None
 | 
				
			||||||
 | 
					        self.channel_counter = None
 | 
				
			||||||
 | 
					        self.packet_id_counter = None
 | 
				
			||||||
 | 
					        self.hop_start_gauge = None
 | 
				
			||||||
 | 
					        self.via_mqtt_counter = None
 | 
				
			||||||
 | 
					        self.want_ack_counter = None
 | 
				
			||||||
 | 
					        self.hop_limit_counter = None
 | 
				
			||||||
 | 
					        self.rx_snr_gauge = None
 | 
				
			||||||
 | 
					        self.rx_time_histogram = None
 | 
				
			||||||
 | 
					        self.total_packets_counter = None
 | 
				
			||||||
 | 
					        self.destination_message_type_counter = None
 | 
				
			||||||
 | 
					        self.source_message_type_counter = None
 | 
				
			||||||
 | 
					        self.registry = registry
 | 
				
			||||||
 | 
					        self.db_pool = db_pool
 | 
				
			||||||
 | 
					        self.init_metrics()
 | 
				
			||||||
 | 
					        self.processor_registry = ProcessorRegistry()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def init_metrics(self):
 | 
				
			||||||
 | 
					        common_labels = [
 | 
				
			||||||
 | 
					            'source_id', 'source_short_name', 'source_long_name', 'source_hardware_model', 'source_role',
 | 
				
			||||||
 | 
					            'destination_id', 'destination_short_name', 'destination_long_name', 'destination_hardware_model',
 | 
				
			||||||
 | 
					            'destination_role'
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.source_message_type_counter = Counter(
 | 
				
			||||||
 | 
					            'mesh_packet_source_types',
 | 
				
			||||||
 | 
					            'Types of mesh packets processed by source',
 | 
				
			||||||
 | 
					            common_labels + ['portnum'],
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Destination-related counters
 | 
				
			||||||
 | 
					        self.destination_message_type_counter = Counter(
 | 
				
			||||||
 | 
					            'mesh_packet_destination_types',
 | 
				
			||||||
 | 
					            'Types of mesh packets processed by destination',
 | 
				
			||||||
 | 
					            common_labels + ['portnum'],
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Counters for the total number of packets
 | 
				
			||||||
 | 
					        self.total_packets_counter = Counter(
 | 
				
			||||||
 | 
					            'mesh_packet_total',
 | 
				
			||||||
 | 
					            'Total number of mesh packets processed',
 | 
				
			||||||
 | 
					            common_labels,
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Histogram for the rx_time (time in seconds)
 | 
				
			||||||
 | 
					        self.rx_time_histogram = Histogram(
 | 
				
			||||||
 | 
					            'mesh_packet_rx_time',
 | 
				
			||||||
 | 
					            'Receive time of mesh packets (seconds since 1970)',
 | 
				
			||||||
 | 
					            common_labels,
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Gauge for the rx_snr (signal-to-noise ratio)
 | 
				
			||||||
 | 
					        self.rx_snr_gauge = Gauge(
 | 
				
			||||||
 | 
					            'mesh_packet_rx_snr',
 | 
				
			||||||
 | 
					            'Receive SNR of mesh packets',
 | 
				
			||||||
 | 
					            common_labels,
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Counter for hop_limit
 | 
				
			||||||
 | 
					        self.hop_limit_counter = Counter(
 | 
				
			||||||
 | 
					            'mesh_packet_hop_limit',
 | 
				
			||||||
 | 
					            'Hop limit of mesh packets',
 | 
				
			||||||
 | 
					            common_labels,
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Counter for want_ack (occurrences of want_ack set to true)
 | 
				
			||||||
 | 
					        self.want_ack_counter = Counter(
 | 
				
			||||||
 | 
					            'mesh_packet_want_ack',
 | 
				
			||||||
 | 
					            'Occurrences of want ACK for mesh packets',
 | 
				
			||||||
 | 
					            common_labels,
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Counter for via_mqtt (occurrences of via_mqtt set to true)
 | 
				
			||||||
 | 
					        self.via_mqtt_counter = Counter(
 | 
				
			||||||
 | 
					            'mesh_packet_via_mqtt',
 | 
				
			||||||
 | 
					            'Occurrences of mesh packets sent via MQTT',
 | 
				
			||||||
 | 
					            common_labels,
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Gauge for hop_start
 | 
				
			||||||
 | 
					        self.hop_start_gauge = Gauge(
 | 
				
			||||||
 | 
					            'mesh_packet_hop_start',
 | 
				
			||||||
 | 
					            'Hop start of mesh packets',
 | 
				
			||||||
 | 
					            common_labels,
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Counter for unique packet IDs
 | 
				
			||||||
 | 
					        self.packet_id_counter = Counter(
 | 
				
			||||||
 | 
					            'mesh_packet_ids',
 | 
				
			||||||
 | 
					            'Unique IDs for mesh packets',
 | 
				
			||||||
 | 
					            common_labels + ['packet_id'],
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Counter for the channel used
 | 
				
			||||||
 | 
					        self.channel_counter = Counter(
 | 
				
			||||||
 | 
					            'mesh_packet_channel',
 | 
				
			||||||
 | 
					            'Channel used for mesh packets',
 | 
				
			||||||
 | 
					            common_labels + ['channel'],
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # Gauge for the rx_rssi (received signal strength indicator)
 | 
				
			||||||
 | 
					        self.rx_rssi_gauge = Gauge(
 | 
				
			||||||
 | 
					            'mesh_packet_rx_rssi',
 | 
				
			||||||
 | 
					            'Receive RSSI of mesh packets',
 | 
				
			||||||
 | 
					            common_labels,
 | 
				
			||||||
 | 
					            registry=self.registry
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def process(self, mesh_packet: MeshPacket):
 | 
				
			||||||
 | 
					        if getattr(mesh_packet, 'encrypted'):
 | 
				
			||||||
 | 
					            key_bytes = base64.b64decode(os.getenv('MQTT_SERVER_KEY', '1PG7OiApB1nwvP+rz05pAQ==').encode('ascii'))
 | 
				
			||||||
 | 
					            nonce_packet_id = getattr(mesh_packet, "id").to_bytes(8, "little")
 | 
				
			||||||
 | 
					            nonce_from_node = getattr(mesh_packet, "from").to_bytes(8, "little")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Put both parts into a single byte array.
 | 
				
			||||||
 | 
					            nonce = nonce_packet_id + nonce_from_node
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            cipher = Cipher(algorithms.AES(key_bytes), modes.CTR(nonce), backend=default_backend())
 | 
				
			||||||
 | 
					            decryptor = cipher.decryptor()
 | 
				
			||||||
 | 
					            decrypted_bytes = decryptor.update(getattr(mesh_packet, "encrypted")) + decryptor.finalize()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            data = Data()
 | 
				
			||||||
 | 
					            data.ParseFromString(decrypted_bytes)
 | 
				
			||||||
 | 
					            mesh_packet.decoded.CopyFrom(data)
 | 
				
			||||||
 | 
					        port_num = int(mesh_packet.decoded.portnum)
 | 
				
			||||||
 | 
					        payload = mesh_packet.decoded.payload
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        source_node_id = getattr(mesh_packet, 'from')
 | 
				
			||||||
 | 
					        source_client_details = self._get_client_details(source_node_id)
 | 
				
			||||||
 | 
					        if os.getenv('MESH_HIDE_SOURCE_DATA', 'false') == 'true':
 | 
				
			||||||
 | 
					            source_client_details = ClientDetails(node_id=source_client_details.node_id, short_name='Hidden',
 | 
				
			||||||
 | 
					                                                  long_name='Hidden')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        destination_node_id = getattr(mesh_packet, 'to')
 | 
				
			||||||
 | 
					        destination_client_details = self._get_client_details(destination_node_id)
 | 
				
			||||||
 | 
					        if os.getenv('MESH_HIDE_DESTINATION_DATA', 'false') == 'true':
 | 
				
			||||||
 | 
					            destination_client_details = ClientDetails(node_id=destination_client_details.node_id, short_name='Hidden',
 | 
				
			||||||
 | 
					                                                       long_name='Hidden')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if port_num in map(int, os.getenv('FILTERED_PORTS', '1').split(',')):  # Filter out ports
 | 
				
			||||||
 | 
					            return None  # Ignore this packet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.process_simple_packet_details(destination_client_details, mesh_packet, port_num, source_client_details)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        processor = ProcessorRegistry.get_processor(port_num)(self.registry, self.db_pool)
 | 
				
			||||||
 | 
					        processor.process(payload, client_details=source_client_details)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def get_port_name_from_portnum(port_num):
 | 
				
			||||||
 | 
					        descriptor = PortNum.DESCRIPTOR
 | 
				
			||||||
 | 
					        for enum_value in descriptor.values:
 | 
				
			||||||
 | 
					            if enum_value.number == port_num:
 | 
				
			||||||
 | 
					                return enum_value.name
 | 
				
			||||||
 | 
					        return 'UNKNOWN_PORT'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def process_simple_packet_details(self, destination_client_details, mesh_packet, port_num, source_client_details):
 | 
				
			||||||
 | 
					        common_labels = {
 | 
				
			||||||
 | 
					            'source_id': source_client_details.node_id,
 | 
				
			||||||
 | 
					            'source_short_name': source_client_details.short_name,
 | 
				
			||||||
 | 
					            'source_long_name': source_client_details.long_name,
 | 
				
			||||||
 | 
					            'source_hardware_model': source_client_details.hardware_model,
 | 
				
			||||||
 | 
					            'source_role': source_client_details.role,
 | 
				
			||||||
 | 
					            'destination_id': destination_client_details.node_id,
 | 
				
			||||||
 | 
					            'destination_short_name': destination_client_details.short_name,
 | 
				
			||||||
 | 
					            'destination_long_name': destination_client_details.long_name,
 | 
				
			||||||
 | 
					            'destination_hardware_model': destination_client_details.hardware_model,
 | 
				
			||||||
 | 
					            'destination_role': destination_client_details.role,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.source_message_type_counter.labels(
 | 
				
			||||||
 | 
					            **common_labels,
 | 
				
			||||||
 | 
					            portnum=self.get_port_name_from_portnum(port_num)
 | 
				
			||||||
 | 
					        ).inc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.destination_message_type_counter.labels(
 | 
				
			||||||
 | 
					            **common_labels,
 | 
				
			||||||
 | 
					            portnum=self.get_port_name_from_portnum(port_num)
 | 
				
			||||||
 | 
					        ).inc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.total_packets_counter.labels(
 | 
				
			||||||
 | 
					            **common_labels
 | 
				
			||||||
 | 
					        ).inc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.rx_time_histogram.labels(
 | 
				
			||||||
 | 
					            **common_labels
 | 
				
			||||||
 | 
					        ).observe(mesh_packet.rx_time)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.rx_snr_gauge.labels(
 | 
				
			||||||
 | 
					            **common_labels
 | 
				
			||||||
 | 
					        ).set(mesh_packet.rx_snr)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.hop_limit_counter.labels(
 | 
				
			||||||
 | 
					            **common_labels
 | 
				
			||||||
 | 
					        ).inc(mesh_packet.hop_limit)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if mesh_packet.want_ack:
 | 
				
			||||||
 | 
					            self.want_ack_counter.labels(
 | 
				
			||||||
 | 
					                **common_labels
 | 
				
			||||||
 | 
					            ).inc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if mesh_packet.via_mqtt:
 | 
				
			||||||
 | 
					            self.via_mqtt_counter.labels(
 | 
				
			||||||
 | 
					                **common_labels
 | 
				
			||||||
 | 
					            ).inc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.hop_start_gauge.labels(
 | 
				
			||||||
 | 
					            **common_labels
 | 
				
			||||||
 | 
					        ).set(mesh_packet.hop_start)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.packet_id_counter.labels(
 | 
				
			||||||
 | 
					            **common_labels,
 | 
				
			||||||
 | 
					            packet_id=mesh_packet.id
 | 
				
			||||||
 | 
					        ).inc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Increment the channel counter
 | 
				
			||||||
 | 
					        self.channel_counter.labels(
 | 
				
			||||||
 | 
					            **common_labels,
 | 
				
			||||||
 | 
					            channel=mesh_packet.channel
 | 
				
			||||||
 | 
					        ).inc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Set the rx_rssi in the gauge
 | 
				
			||||||
 | 
					        self.rx_rssi_gauge.labels(
 | 
				
			||||||
 | 
					            **common_labels
 | 
				
			||||||
 | 
					        ).set(mesh_packet.rx_rssi)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _get_client_details(self, node_id: int) -> ClientDetails:
 | 
				
			||||||
 | 
					        node_id_str = str(node_id)  # Convert the integer to a string
 | 
				
			||||||
 | 
					        with self.db_pool.connection() as conn:
 | 
				
			||||||
 | 
					            with conn.cursor() as cur:
 | 
				
			||||||
 | 
					                # First, try to select the existing record
 | 
				
			||||||
 | 
					                cur.execute("""
 | 
				
			||||||
 | 
					                    SELECT node_id, short_name, long_name, hardware_model, role 
 | 
				
			||||||
 | 
					                    FROM client_details 
 | 
				
			||||||
 | 
					                    WHERE node_id = %s;
 | 
				
			||||||
 | 
					                """, (node_id_str,))
 | 
				
			||||||
 | 
					                result = cur.fetchone()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if not result:
 | 
				
			||||||
 | 
					                    # If the client is not found, insert a new record
 | 
				
			||||||
 | 
					                    cur.execute("""
 | 
				
			||||||
 | 
					                        INSERT INTO client_details (node_id, short_name, long_name, hardware_model, role)
 | 
				
			||||||
 | 
					                        VALUES (%s, %s, %s, %s, %s)
 | 
				
			||||||
 | 
					                        RETURNING node_id, short_name, long_name, hardware_model, role;
 | 
				
			||||||
 | 
					                    """, (node_id_str, 'Unknown', 'Unknown', HardwareModel.UNSET, None))
 | 
				
			||||||
 | 
					                    conn.commit()
 | 
				
			||||||
 | 
					                    result = cur.fetchone()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # At this point, we should always have a result, either from SELECT or INSERT
 | 
				
			||||||
 | 
					        return ClientDetails(
 | 
				
			||||||
 | 
					            node_id=result[0],
 | 
				
			||||||
 | 
					            short_name=result[1],
 | 
				
			||||||
 | 
					            long_name=result[2],
 | 
				
			||||||
 | 
					            hardware_model=result[3],
 | 
				
			||||||
 | 
					            role=result[4]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
| 
						 | 
					@ -1,269 +1,498 @@
 | 
				
			||||||
import base64
 | 
					 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
 | 
					from abc import ABC, abstractmethod
 | 
				
			||||||
 | 
					from venv import logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from cryptography.hazmat.backends import default_backend
 | 
					import psycopg
 | 
				
			||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 | 
					import unishox2
 | 
				
			||||||
from meshtastic.mesh_pb2 import MeshPacket, Data, HardwareModel
 | 
					from meshtastic.admin_pb2 import AdminMessage
 | 
				
			||||||
 | 
					from meshtastic.mesh_pb2 import Position, User, HardwareModel, Routing, Waypoint, RouteDiscovery, NeighborInfo
 | 
				
			||||||
 | 
					from meshtastic.mqtt_pb2 import MapReport
 | 
				
			||||||
 | 
					from meshtastic.paxcount_pb2 import Paxcount
 | 
				
			||||||
from meshtastic.portnums_pb2 import PortNum
 | 
					from meshtastic.portnums_pb2 import PortNum
 | 
				
			||||||
from prometheus_client import CollectorRegistry, Counter, Histogram, Gauge
 | 
					from meshtastic.remote_hardware_pb2 import HardwareMessage
 | 
				
			||||||
 | 
					from meshtastic.storeforward_pb2 import StoreAndForward
 | 
				
			||||||
 | 
					from meshtastic.telemetry_pb2 import Telemetry, DeviceMetrics, EnvironmentMetrics, AirQualityMetrics, PowerMetrics
 | 
				
			||||||
 | 
					from prometheus_client import CollectorRegistry
 | 
				
			||||||
from psycopg_pool import ConnectionPool
 | 
					from psycopg_pool import ConnectionPool
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from exporter.registry import ProcessorRegistry, ClientDetails
 | 
					from exporter.client_details import ClientDetails
 | 
				
			||||||
 | 
					from exporter.registry import _Metrics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MessageProcessor:
 | 
					class Processor(ABC):
 | 
				
			||||||
    def __init__(self, registry: CollectorRegistry, db_pool: ConnectionPool):
 | 
					    def __init__(self, registry: CollectorRegistry, db_pool: ConnectionPool):
 | 
				
			||||||
        self.rx_rssi_gauge = None
 | 
					 | 
				
			||||||
        self.channel_counter = None
 | 
					 | 
				
			||||||
        self.packet_id_counter = None
 | 
					 | 
				
			||||||
        self.hop_start_gauge = None
 | 
					 | 
				
			||||||
        self.via_mqtt_counter = None
 | 
					 | 
				
			||||||
        self.want_ack_counter = None
 | 
					 | 
				
			||||||
        self.hop_limit_counter = None
 | 
					 | 
				
			||||||
        self.rx_snr_gauge = None
 | 
					 | 
				
			||||||
        self.rx_time_histogram = None
 | 
					 | 
				
			||||||
        self.total_packets_counter = None
 | 
					 | 
				
			||||||
        self.destination_message_type_counter = None
 | 
					 | 
				
			||||||
        self.source_message_type_counter = None
 | 
					 | 
				
			||||||
        self.registry = registry
 | 
					 | 
				
			||||||
        self.db_pool = db_pool
 | 
					        self.db_pool = db_pool
 | 
				
			||||||
        self.init_metrics()
 | 
					        self.metrics = _Metrics(registry)
 | 
				
			||||||
        self.processor_registry = ProcessorRegistry()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def init_metrics(self):
 | 
					    @abstractmethod
 | 
				
			||||||
        common_labels = [
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
            'source_id', 'source_short_name', 'source_long_name', 'source_hardware_model', 'source_role',
 | 
					        pass
 | 
				
			||||||
            'destination_id', 'destination_short_name', 'destination_long_name', 'destination_hardware_model',
 | 
					 | 
				
			||||||
            'destination_role'
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.source_message_type_counter = Counter(
 | 
					    def execute_db_operation(self, operation):
 | 
				
			||||||
            'mesh_packet_source_types',
 | 
					 | 
				
			||||||
            'Types of mesh packets processed by source',
 | 
					 | 
				
			||||||
            common_labels + ['portnum'],
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Destination-related counters
 | 
					 | 
				
			||||||
        self.destination_message_type_counter = Counter(
 | 
					 | 
				
			||||||
            'mesh_packet_destination_types',
 | 
					 | 
				
			||||||
            'Types of mesh packets processed by destination',
 | 
					 | 
				
			||||||
            common_labels + ['portnum'],
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Counters for the total number of packets
 | 
					 | 
				
			||||||
        self.total_packets_counter = Counter(
 | 
					 | 
				
			||||||
            'mesh_packet_total',
 | 
					 | 
				
			||||||
            'Total number of mesh packets processed',
 | 
					 | 
				
			||||||
            common_labels,
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Histogram for the rx_time (time in seconds)
 | 
					 | 
				
			||||||
        self.rx_time_histogram = Histogram(
 | 
					 | 
				
			||||||
            'mesh_packet_rx_time',
 | 
					 | 
				
			||||||
            'Receive time of mesh packets (seconds since 1970)',
 | 
					 | 
				
			||||||
            common_labels,
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Gauge for the rx_snr (signal-to-noise ratio)
 | 
					 | 
				
			||||||
        self.rx_snr_gauge = Gauge(
 | 
					 | 
				
			||||||
            'mesh_packet_rx_snr',
 | 
					 | 
				
			||||||
            'Receive SNR of mesh packets',
 | 
					 | 
				
			||||||
            common_labels,
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Counter for hop_limit
 | 
					 | 
				
			||||||
        self.hop_limit_counter = Counter(
 | 
					 | 
				
			||||||
            'mesh_packet_hop_limit',
 | 
					 | 
				
			||||||
            'Hop limit of mesh packets',
 | 
					 | 
				
			||||||
            common_labels,
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Counter for want_ack (occurrences of want_ack set to true)
 | 
					 | 
				
			||||||
        self.want_ack_counter = Counter(
 | 
					 | 
				
			||||||
            'mesh_packet_want_ack',
 | 
					 | 
				
			||||||
            'Occurrences of want ACK for mesh packets',
 | 
					 | 
				
			||||||
            common_labels,
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Counter for via_mqtt (occurrences of via_mqtt set to true)
 | 
					 | 
				
			||||||
        self.via_mqtt_counter = Counter(
 | 
					 | 
				
			||||||
            'mesh_packet_via_mqtt',
 | 
					 | 
				
			||||||
            'Occurrences of mesh packets sent via MQTT',
 | 
					 | 
				
			||||||
            common_labels,
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Gauge for hop_start
 | 
					 | 
				
			||||||
        self.hop_start_gauge = Gauge(
 | 
					 | 
				
			||||||
            'mesh_packet_hop_start',
 | 
					 | 
				
			||||||
            'Hop start of mesh packets',
 | 
					 | 
				
			||||||
            common_labels,
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Counter for unique packet IDs
 | 
					 | 
				
			||||||
        self.packet_id_counter = Counter(
 | 
					 | 
				
			||||||
            'mesh_packet_ids',
 | 
					 | 
				
			||||||
            'Unique IDs for mesh packets',
 | 
					 | 
				
			||||||
            common_labels + ['packet_id'],
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Counter for the channel used
 | 
					 | 
				
			||||||
        self.channel_counter = Counter(
 | 
					 | 
				
			||||||
            'mesh_packet_channel',
 | 
					 | 
				
			||||||
            'Channel used for mesh packets',
 | 
					 | 
				
			||||||
            common_labels + ['channel'],
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        # Gauge for the rx_rssi (received signal strength indicator)
 | 
					 | 
				
			||||||
        self.rx_rssi_gauge = Gauge(
 | 
					 | 
				
			||||||
            'mesh_packet_rx_rssi',
 | 
					 | 
				
			||||||
            'Receive RSSI of mesh packets',
 | 
					 | 
				
			||||||
            common_labels,
 | 
					 | 
				
			||||||
            registry=self.registry
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def process(self, mesh_packet: MeshPacket):
 | 
					 | 
				
			||||||
        if getattr(mesh_packet, 'encrypted'):
 | 
					 | 
				
			||||||
            key_bytes = base64.b64decode(os.getenv('MQTT_SERVER_KEY', '1PG7OiApB1nwvP+rz05pAQ==').encode('ascii'))
 | 
					 | 
				
			||||||
            nonce_packet_id = getattr(mesh_packet, "id").to_bytes(8, "little")
 | 
					 | 
				
			||||||
            nonce_from_node = getattr(mesh_packet, "from").to_bytes(8, "little")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            # Put both parts into a single byte array.
 | 
					 | 
				
			||||||
            nonce = nonce_packet_id + nonce_from_node
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            cipher = Cipher(algorithms.AES(key_bytes), modes.CTR(nonce), backend=default_backend())
 | 
					 | 
				
			||||||
            decryptor = cipher.decryptor()
 | 
					 | 
				
			||||||
            decrypted_bytes = decryptor.update(getattr(mesh_packet, "encrypted")) + decryptor.finalize()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            data = Data()
 | 
					 | 
				
			||||||
            data.ParseFromString(decrypted_bytes)
 | 
					 | 
				
			||||||
            mesh_packet.decoded.CopyFrom(data)
 | 
					 | 
				
			||||||
        port_num = int(mesh_packet.decoded.portnum)
 | 
					 | 
				
			||||||
        payload = mesh_packet.decoded.payload
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        source_node_id = getattr(mesh_packet, 'from')
 | 
					 | 
				
			||||||
        source_client_details = self._get_client_details(source_node_id)
 | 
					 | 
				
			||||||
        if os.getenv('MESH_HIDE_SOURCE_DATA', 'false') == 'true':
 | 
					 | 
				
			||||||
            source_client_details = ClientDetails(node_id=source_client_details.node_id, short_name='Hidden',
 | 
					 | 
				
			||||||
                                                  long_name='Hidden')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        destination_node_id = getattr(mesh_packet, 'to')
 | 
					 | 
				
			||||||
        destination_client_details = self._get_client_details(destination_node_id)
 | 
					 | 
				
			||||||
        if os.getenv('MESH_HIDE_DESTINATION_DATA', 'false') == 'true':
 | 
					 | 
				
			||||||
            destination_client_details = ClientDetails(node_id=destination_client_details.node_id, short_name='Hidden',
 | 
					 | 
				
			||||||
                                                       long_name='Hidden')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if port_num in map(int, os.getenv('FILTERED_PORTS', '1').split(',')):  # Filter out ports
 | 
					 | 
				
			||||||
            return None  # Ignore this packet
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.process_simple_packet_details(destination_client_details, mesh_packet, port_num, source_client_details)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        processor = ProcessorRegistry.get_processor(port_num)(self.registry, self.db_pool)
 | 
					 | 
				
			||||||
        processor.process(payload, client_details=source_client_details)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_port_name_from_portnum(self, port_num):
 | 
					 | 
				
			||||||
        descriptor = PortNum.DESCRIPTOR
 | 
					 | 
				
			||||||
        for enum_value in descriptor.values:
 | 
					 | 
				
			||||||
            if enum_value.number == port_num:
 | 
					 | 
				
			||||||
                return enum_value.name
 | 
					 | 
				
			||||||
        return 'UNKNOWN_PORT'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def process_simple_packet_details(self, destination_client_details, mesh_packet, port_num, source_client_details):
 | 
					 | 
				
			||||||
        common_labels = {
 | 
					 | 
				
			||||||
            'source_id': source_client_details.node_id,
 | 
					 | 
				
			||||||
            'source_short_name': source_client_details.short_name,
 | 
					 | 
				
			||||||
            'source_long_name': source_client_details.long_name,
 | 
					 | 
				
			||||||
            'source_hardware_model': source_client_details.hardware_model,
 | 
					 | 
				
			||||||
            'source_role': source_client_details.role,
 | 
					 | 
				
			||||||
            'destination_id': destination_client_details.node_id,
 | 
					 | 
				
			||||||
            'destination_short_name': destination_client_details.short_name,
 | 
					 | 
				
			||||||
            'destination_long_name': destination_client_details.long_name,
 | 
					 | 
				
			||||||
            'destination_hardware_model': destination_client_details.hardware_model,
 | 
					 | 
				
			||||||
            'destination_role': destination_client_details.role,
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.source_message_type_counter.labels(
 | 
					 | 
				
			||||||
            **common_labels,
 | 
					 | 
				
			||||||
            portnum=self.get_port_name_from_portnum(port_num)
 | 
					 | 
				
			||||||
        ).inc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.destination_message_type_counter.labels(
 | 
					 | 
				
			||||||
            **common_labels,
 | 
					 | 
				
			||||||
            portnum=self.get_port_name_from_portnum(port_num)
 | 
					 | 
				
			||||||
        ).inc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.total_packets_counter.labels(
 | 
					 | 
				
			||||||
            **common_labels
 | 
					 | 
				
			||||||
        ).inc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.rx_time_histogram.labels(
 | 
					 | 
				
			||||||
            **common_labels
 | 
					 | 
				
			||||||
        ).observe(mesh_packet.rx_time)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.rx_snr_gauge.labels(
 | 
					 | 
				
			||||||
            **common_labels
 | 
					 | 
				
			||||||
        ).set(mesh_packet.rx_snr)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.hop_limit_counter.labels(
 | 
					 | 
				
			||||||
            **common_labels
 | 
					 | 
				
			||||||
        ).inc(mesh_packet.hop_limit)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if mesh_packet.want_ack:
 | 
					 | 
				
			||||||
            self.want_ack_counter.labels(
 | 
					 | 
				
			||||||
                **common_labels
 | 
					 | 
				
			||||||
            ).inc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if mesh_packet.via_mqtt:
 | 
					 | 
				
			||||||
            self.via_mqtt_counter.labels(
 | 
					 | 
				
			||||||
                **common_labels
 | 
					 | 
				
			||||||
            ).inc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.hop_start_gauge.labels(
 | 
					 | 
				
			||||||
            **common_labels
 | 
					 | 
				
			||||||
        ).set(mesh_packet.hop_start)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.packet_id_counter.labels(
 | 
					 | 
				
			||||||
            **common_labels,
 | 
					 | 
				
			||||||
            packet_id=mesh_packet.id
 | 
					 | 
				
			||||||
        ).inc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Increment the channel counter
 | 
					 | 
				
			||||||
        self.channel_counter.labels(
 | 
					 | 
				
			||||||
            **common_labels,
 | 
					 | 
				
			||||||
            channel=mesh_packet.channel
 | 
					 | 
				
			||||||
        ).inc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Set the rx_rssi in the gauge
 | 
					 | 
				
			||||||
        self.rx_rssi_gauge.labels(
 | 
					 | 
				
			||||||
            **common_labels
 | 
					 | 
				
			||||||
        ).set(mesh_packet.rx_rssi)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _get_client_details(self, node_id: int) -> ClientDetails:
 | 
					 | 
				
			||||||
        node_id_str = str(node_id)  # Convert the integer to a string
 | 
					 | 
				
			||||||
        with self.db_pool.connection() as conn:
 | 
					        with self.db_pool.connection() as conn:
 | 
				
			||||||
            with conn.cursor() as cur:
 | 
					            with conn.cursor() as cur:
 | 
				
			||||||
                # First, try to select the existing record
 | 
					                return operation(cur, conn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ProcessorRegistry:
 | 
				
			||||||
 | 
					    _registry = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def register_processor(cls, port_num):
 | 
				
			||||||
 | 
					        def inner_wrapper(wrapped_class):
 | 
				
			||||||
 | 
					            cls._registry[port_num] = wrapped_class
 | 
				
			||||||
 | 
					            return wrapped_class
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return inner_wrapper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @classmethod
 | 
				
			||||||
 | 
					    def get_processor(cls, port_num) -> type(Processor):
 | 
				
			||||||
 | 
					        return cls._registry.get(port_num, UnknownAppProcessor)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					########################################################################################################################
 | 
				
			||||||
 | 
					# PROCESSORS #
 | 
				
			||||||
 | 
					########################################################################################################################
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.UNKNOWN_APP)
 | 
				
			||||||
 | 
					class UnknownAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received UNKNOWN_APP packet")
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.TEXT_MESSAGE_APP)
 | 
				
			||||||
 | 
					class TextMessageAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received TEXT_MESSAGE_APP packet")
 | 
				
			||||||
 | 
					        message = payload.decode('utf-8')
 | 
				
			||||||
 | 
					        if os.getenv('HIDE_MESSAGE', 'true') == 'true':
 | 
				
			||||||
 | 
					            message = 'Hidden'
 | 
				
			||||||
 | 
					        self.metrics.message_length_histogram.labels(
 | 
				
			||||||
 | 
					            **client_details.to_dict()
 | 
				
			||||||
 | 
					        ).observe(len(message))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.REMOTE_HARDWARE_APP)
 | 
				
			||||||
 | 
					class RemoteHardwareAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received REMOTE_HARDWARE_APP packet")
 | 
				
			||||||
 | 
					        hardware_message = HardwareMessage()
 | 
				
			||||||
 | 
					        hardware_message.ParseFromString(payload)
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.POSITION_APP)
 | 
				
			||||||
 | 
					class PositionAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received POSITION_APP packet")
 | 
				
			||||||
 | 
					        position = Position()
 | 
				
			||||||
 | 
					        position.ParseFromString(payload)
 | 
				
			||||||
 | 
					        self.metrics.device_latitude_gauge.labels(
 | 
				
			||||||
 | 
					            **client_details.to_dict()
 | 
				
			||||||
 | 
					        ).set(position.latitude_i)
 | 
				
			||||||
 | 
					        self.metrics.device_longitude_gauge.labels(
 | 
				
			||||||
 | 
					            **client_details.to_dict()
 | 
				
			||||||
 | 
					        ).set(position.longitude_i)
 | 
				
			||||||
 | 
					        self.metrics.device_altitude_gauge.labels(
 | 
				
			||||||
 | 
					            **client_details.to_dict()
 | 
				
			||||||
 | 
					        ).set(position.altitude)
 | 
				
			||||||
 | 
					        self.metrics.device_position_precision_gauge.labels(
 | 
				
			||||||
 | 
					            **client_details.to_dict()
 | 
				
			||||||
 | 
					        ).set(position.precision_bits)
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.NODEINFO_APP)
 | 
				
			||||||
 | 
					class NodeInfoAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received NODEINFO_APP packet")
 | 
				
			||||||
 | 
					        user = User()
 | 
				
			||||||
 | 
					        user.ParseFromString(payload)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def db_operation(cur, conn):
 | 
				
			||||||
 | 
					            # First, try to select the existing record
 | 
				
			||||||
 | 
					            cur.execute("""
 | 
				
			||||||
 | 
					                SELECT short_name, long_name, hardware_model, role
 | 
				
			||||||
 | 
					                FROM client_details
 | 
				
			||||||
 | 
					                WHERE node_id = %s;
 | 
				
			||||||
 | 
					            """, (client_details.node_id,))
 | 
				
			||||||
 | 
					            existing_record = cur.fetchone()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if existing_record:
 | 
				
			||||||
 | 
					                # If record exists, update only the fields that are provided in the new data
 | 
				
			||||||
 | 
					                update_fields = []
 | 
				
			||||||
 | 
					                update_values = []
 | 
				
			||||||
 | 
					                if user.short_name:
 | 
				
			||||||
 | 
					                    update_fields.append("short_name = %s")
 | 
				
			||||||
 | 
					                    update_values.append(user.short_name)
 | 
				
			||||||
 | 
					                if user.long_name:
 | 
				
			||||||
 | 
					                    update_fields.append("long_name = %s")
 | 
				
			||||||
 | 
					                    update_values.append(user.long_name)
 | 
				
			||||||
 | 
					                if user.hw_model != HardwareModel.UNSET:
 | 
				
			||||||
 | 
					                    update_fields.append("hardware_model = %s")
 | 
				
			||||||
 | 
					                    update_values.append(ClientDetails.get_hardware_model_name_from_code(user.hw_model))
 | 
				
			||||||
 | 
					                if user.role is not None:
 | 
				
			||||||
 | 
					                    update_fields.append("role = %s")
 | 
				
			||||||
 | 
					                    update_values.append(ClientDetails.get_role_name_from_role(user.role))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if update_fields:
 | 
				
			||||||
 | 
					                    update_query = f"""
 | 
				
			||||||
 | 
					                        UPDATE client_details
 | 
				
			||||||
 | 
					                        SET {", ".join(update_fields)}
 | 
				
			||||||
 | 
					                        WHERE node_id = %s
 | 
				
			||||||
 | 
					                    """
 | 
				
			||||||
 | 
					                    cur.execute(update_query, update_values + [client_details.node_id])
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                # If record doesn't exist, insert a new one
 | 
				
			||||||
                cur.execute("""
 | 
					                cur.execute("""
 | 
				
			||||||
                    SELECT node_id, short_name, long_name, hardware_model, role 
 | 
					                    INSERT INTO client_details (node_id, short_name, long_name, hardware_model, role)
 | 
				
			||||||
                    FROM client_details 
 | 
					                    VALUES (%s, %s, %s, %s, %s)
 | 
				
			||||||
                    WHERE node_id = %s;
 | 
					                """, (client_details.node_id, user.short_name, user.long_name,
 | 
				
			||||||
                """, (node_id_str,))
 | 
					                      ClientDetails.get_hardware_model_name_from_code(user.hw_model),
 | 
				
			||||||
                result = cur.fetchone()
 | 
					                      ClientDetails.get_role_name_from_role(user.role)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if not result:
 | 
					            conn.commit()
 | 
				
			||||||
                    # If the client is not found, insert a new record
 | 
					 | 
				
			||||||
                    cur.execute("""
 | 
					 | 
				
			||||||
                        INSERT INTO client_details (node_id, short_name, long_name, hardware_model, role)
 | 
					 | 
				
			||||||
                        VALUES (%s, %s, %s, %s, %s)
 | 
					 | 
				
			||||||
                        RETURNING node_id, short_name, long_name, hardware_model, role;
 | 
					 | 
				
			||||||
                    """, (node_id_str, 'Unknown', 'Unknown', HardwareModel.UNSET, None))
 | 
					 | 
				
			||||||
                    conn.commit()
 | 
					 | 
				
			||||||
                    result = cur.fetchone()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # At this point, we should always have a result, either from SELECT or INSERT
 | 
					        self.execute_db_operation(db_operation)
 | 
				
			||||||
        return ClientDetails(
 | 
					
 | 
				
			||||||
            node_id=result[0],
 | 
					
 | 
				
			||||||
            short_name=result[1],
 | 
					@ProcessorRegistry.register_processor(PortNum.ROUTING_APP)
 | 
				
			||||||
            long_name=result[2],
 | 
					class RoutingAppProcessor(Processor):
 | 
				
			||||||
            hardware_model=result[3],
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
            role=result[4]
 | 
					        logger.debug("Received ROUTING_APP packet")
 | 
				
			||||||
        )
 | 
					        routing = Routing()
 | 
				
			||||||
 | 
					        routing.ParseFromString(payload)
 | 
				
			||||||
 | 
					        self.metrics.route_discovery_response_counter.labels(
 | 
				
			||||||
 | 
					            **client_details.to_dict(),
 | 
				
			||||||
 | 
					            response_type=self.get_error_name_from_routing(routing.error_reason)
 | 
				
			||||||
 | 
					        ).inc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def get_error_name_from_routing(error_code):
 | 
				
			||||||
 | 
					        for name, value in Routing.Error.__dict__.items():
 | 
				
			||||||
 | 
					            if isinstance(value, int) and value == error_code:
 | 
				
			||||||
 | 
					                return name
 | 
				
			||||||
 | 
					        return 'UNKNOWN_ERROR'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.ADMIN_APP)
 | 
				
			||||||
 | 
					class AdminAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received ADMIN_APP packet")
 | 
				
			||||||
 | 
					        admin_message = AdminMessage()
 | 
				
			||||||
 | 
					        admin_message.ParseFromString(payload)
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.TEXT_MESSAGE_COMPRESSED_APP)
 | 
				
			||||||
 | 
					class TextMessageCompressedAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received TEXT_MESSAGE_COMPRESSED_APP packet")
 | 
				
			||||||
 | 
					        decompressed_payload = unishox2.decompress(payload, len(payload))
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.WAYPOINT_APP)
 | 
				
			||||||
 | 
					class WaypointAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received WAYPOINT_APP packet")
 | 
				
			||||||
 | 
					        waypoint = Waypoint()
 | 
				
			||||||
 | 
					        waypoint.ParseFromString(payload)
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.AUDIO_APP)
 | 
				
			||||||
 | 
					class AudioAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received AUDIO_APP packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: Audio packet. should probably be processed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.DETECTION_SENSOR_APP)
 | 
				
			||||||
 | 
					class DetectionSensorAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received DETECTION_SENSOR_APP packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: This portnum traffic is not sent to the public MQTT starting at firmware version 2.2.9
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.REPLY_APP)
 | 
				
			||||||
 | 
					class ReplyAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received REPLY_APP packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: Provides a 'ping' service that replies to any packet it receives. This is useful for testing.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.IP_TUNNEL_APP)
 | 
				
			||||||
 | 
					class IpTunnelAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received IP_TUNNEL_APP packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: IP Packet. Handled by the python API, firmware ignores this one and passes it on.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.PAXCOUNTER_APP)
 | 
				
			||||||
 | 
					class PaxCounterAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received PAXCOUNTER_APP packet")
 | 
				
			||||||
 | 
					        paxcounter = Paxcount()
 | 
				
			||||||
 | 
					        paxcounter.ParseFromString(payload)
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.SERIAL_APP)
 | 
				
			||||||
 | 
					class SerialAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received SERIAL_APP packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: Provides a hardware serial interface to send and receive from the Meshtastic network.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.STORE_FORWARD_APP)
 | 
				
			||||||
 | 
					class StoreForwardAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received STORE_FORWARD_APP packet")
 | 
				
			||||||
 | 
					        store_and_forward = StoreAndForward()
 | 
				
			||||||
 | 
					        store_and_forward.ParseFromString(payload)
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.RANGE_TEST_APP)
 | 
				
			||||||
 | 
					class RangeTestAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received RANGE_TEST_APP packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: This portnum traffic is not sent to the public MQTT starting at firmware version 2.2.9
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.TELEMETRY_APP)
 | 
				
			||||||
 | 
					class TelemetryAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def __init__(self, registry: CollectorRegistry, db_connection: psycopg.connection):
 | 
				
			||||||
 | 
					        super().__init__(registry, db_connection)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received TELEMETRY_APP packet")
 | 
				
			||||||
 | 
					        telemetry = Telemetry()
 | 
				
			||||||
 | 
					        telemetry.ParseFromString(payload)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if telemetry.HasField('device_metrics'):
 | 
				
			||||||
 | 
					            device_metrics: DeviceMetrics = telemetry.device_metrics
 | 
				
			||||||
 | 
					            self.metrics.battery_level_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(device_metrics, 'battery_level', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.voltage_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(device_metrics, 'voltage', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.channel_utilization_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(device_metrics, 'channel_utilization', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.air_util_tx_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(device_metrics, 'air_util_tx', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.uptime_seconds_counter.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).inc(getattr(device_metrics, 'uptime_seconds', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if telemetry.HasField('environment_metrics'):
 | 
				
			||||||
 | 
					            environment_metrics: EnvironmentMetrics = telemetry.environment_metrics
 | 
				
			||||||
 | 
					            self.metrics.temperature_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'temperature', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.relative_humidity_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'relative_humidity', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.barometric_pressure_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'barometric_pressure', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.gas_resistance_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'gas_resistance', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.iaq_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'iaq', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.distance_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'distance', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.lux_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'lux', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.white_lux_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'white_lux', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.ir_lux_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'ir_lux', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.uv_lux_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'uv_lux', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.wind_direction_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'wind_direction', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.wind_speed_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'wind_speed', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.weight_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(environment_metrics, 'weight', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if telemetry.HasField('air_quality_metrics'):
 | 
				
			||||||
 | 
					            air_quality_metrics: AirQualityMetrics = telemetry.air_quality_metrics
 | 
				
			||||||
 | 
					            self.metrics.pm10_standard_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'pm10_standard', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.pm25_standard_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'pm25_standard', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.pm100_standard_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'pm100_standard', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.pm10_environmental_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'pm10_environmental', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.pm25_environmental_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'pm25_environmental', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.pm100_environmental_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'pm100_environmental', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.particles_03um_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'particles_03um', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.particles_05um_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'particles_05um', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.particles_10um_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'particles_10um', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.particles_25um_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'particles_25um', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.particles_50um_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'particles_50um', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.particles_100um_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(air_quality_metrics, 'particles_100um', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if telemetry.HasField('power_metrics'):
 | 
				
			||||||
 | 
					            power_metrics: PowerMetrics = telemetry.power_metrics
 | 
				
			||||||
 | 
					            self.metrics.ch1_voltage_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(power_metrics, 'ch1_voltage', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.ch1_current_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(power_metrics, 'ch1_current', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.ch2_voltage_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(power_metrics, 'ch2_voltage', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.ch2_current_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(power_metrics, 'ch2_current', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.ch3_voltage_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(power_metrics, 'ch3_voltage', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.metrics.ch3_current_gauge.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).set(getattr(power_metrics, 'ch3_current', 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.ZPS_APP)
 | 
				
			||||||
 | 
					class ZpsAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received ZPS_APP packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: Experimental tools for estimating node position without a GPS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.SIMULATOR_APP)
 | 
				
			||||||
 | 
					class SimulatorAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received SIMULATOR_APP packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: Used to let multiple instances of Linux native applications communicate as if they did using their LoRa chip.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.TRACEROUTE_APP)
 | 
				
			||||||
 | 
					class TraceRouteAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received TRACEROUTE_APP packet")
 | 
				
			||||||
 | 
					        traceroute = RouteDiscovery()
 | 
				
			||||||
 | 
					        traceroute.ParseFromString(payload)
 | 
				
			||||||
 | 
					        if traceroute.route:
 | 
				
			||||||
 | 
					            route = traceroute.route
 | 
				
			||||||
 | 
					            self.metrics.route_discovery_counter.labels(
 | 
				
			||||||
 | 
					                **client_details.to_dict()
 | 
				
			||||||
 | 
					            ).inc(len(route))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.NEIGHBORINFO_APP)
 | 
				
			||||||
 | 
					class NeighborInfoAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received NEIGHBORINFO_APP packet")
 | 
				
			||||||
 | 
					        neighbor_info = NeighborInfo()
 | 
				
			||||||
 | 
					        neighbor_info.ParseFromString(payload)
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.ATAK_PLUGIN)
 | 
				
			||||||
 | 
					class AtakPluginProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received ATAK_PLUGIN packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: ATAK Plugin
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.MAP_REPORT_APP)
 | 
				
			||||||
 | 
					class MapReportAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received MAP_REPORT_APP packet")
 | 
				
			||||||
 | 
					        map_report = MapReport()
 | 
				
			||||||
 | 
					        map_report.ParseFromString(payload)
 | 
				
			||||||
 | 
					        pass  # Nothing interesting here
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.PRIVATE_APP)
 | 
				
			||||||
 | 
					class PrivateAppProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received PRIVATE_APP packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: Private application portnum
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.ATAK_FORWARDER)
 | 
				
			||||||
 | 
					class AtakForwarderProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received ATAK_FORWARDER packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: ATAK Forwarder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ProcessorRegistry.register_processor(PortNum.MAX)
 | 
				
			||||||
 | 
					class MaxProcessor(Processor):
 | 
				
			||||||
 | 
					    def process(self, payload: bytes, client_details: ClientDetails):
 | 
				
			||||||
 | 
					        logger.debug("Received MAX packet")
 | 
				
			||||||
 | 
					        pass  # NOTE: Maximum portnum value
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,20 +1,4 @@
 | 
				
			||||||
import os
 | 
					 | 
				
			||||||
from abc import ABC, abstractmethod
 | 
					 | 
				
			||||||
from venv import logger
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import psycopg
 | 
					 | 
				
			||||||
import unishox2
 | 
					 | 
				
			||||||
from meshtastic.admin_pb2 import AdminMessage
 | 
					 | 
				
			||||||
from meshtastic.config_pb2 import Config
 | 
					 | 
				
			||||||
from meshtastic.mesh_pb2 import Position, User, Routing, Waypoint, RouteDiscovery, NeighborInfo, HardwareModel
 | 
					 | 
				
			||||||
from meshtastic.mqtt_pb2 import MapReport
 | 
					 | 
				
			||||||
from meshtastic.paxcount_pb2 import Paxcount
 | 
					 | 
				
			||||||
from meshtastic.portnums_pb2 import PortNum
 | 
					 | 
				
			||||||
from meshtastic.remote_hardware_pb2 import HardwareMessage
 | 
					 | 
				
			||||||
from meshtastic.storeforward_pb2 import StoreAndForward
 | 
					 | 
				
			||||||
from meshtastic.telemetry_pb2 import Telemetry, DeviceMetrics, EnvironmentMetrics, AirQualityMetrics, PowerMetrics
 | 
					 | 
				
			||||||
from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram
 | 
					from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram
 | 
				
			||||||
from psycopg_pool import ConnectionPool
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class _Metrics:
 | 
					class _Metrics:
 | 
				
			||||||
| 
						 | 
					@ -37,10 +21,7 @@ class _Metrics:
 | 
				
			||||||
            'node_id', 'short_name', 'long_name', 'hardware_model', 'role'
 | 
					            'node_id', 'short_name', 'long_name', 'hardware_model', 'role'
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _init_metrics(self):  # TODO: Go over the metrics and rethink some of them to be more like the longtitute and
 | 
					    def _init_metrics(self):
 | 
				
			||||||
        # latitude - The values should represent something and we shouldn't just label stuff. Also, the labels should
 | 
					 | 
				
			||||||
        # be less used looked upon like keys for the data
 | 
					 | 
				
			||||||
        # Histogram for the length of messages
 | 
					 | 
				
			||||||
        self._init_metrics_text_message()
 | 
					        self._init_metrics_text_message()
 | 
				
			||||||
        self._init_metrics_telemetry_device()
 | 
					        self._init_metrics_telemetry_device()
 | 
				
			||||||
        self._init_metrics_telemetry_environment()
 | 
					        self._init_metrics_telemetry_environment()
 | 
				
			||||||
| 
						 | 
					@ -357,510 +338,3 @@ class _Metrics:
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_hardware_model_name_from_code(hardware_model):
 | 
					 | 
				
			||||||
    descriptor = HardwareModel.DESCRIPTOR
 | 
					 | 
				
			||||||
    for enum_value in descriptor.values:
 | 
					 | 
				
			||||||
        if enum_value.number == hardware_model:
 | 
					 | 
				
			||||||
            return enum_value.name
 | 
					 | 
				
			||||||
    return 'UNKNOWN_HARDWARE_MODEL'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def get_role_name_from_role(role):
 | 
					 | 
				
			||||||
    descriptor = Config.DeviceConfig.Role.DESCRIPTOR
 | 
					 | 
				
			||||||
    for enum_value in descriptor.values:
 | 
					 | 
				
			||||||
        if enum_value.number == role:
 | 
					 | 
				
			||||||
            return enum_value.name
 | 
					 | 
				
			||||||
    return 'UNKNOWN_ROLE'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ClientDetails:
 | 
					 | 
				
			||||||
    def __init__(self, node_id, short_name='Unknown', long_name='Unknown', hardware_model=HardwareModel.UNSET,
 | 
					 | 
				
			||||||
                 role=None):
 | 
					 | 
				
			||||||
        self.node_id = node_id
 | 
					 | 
				
			||||||
        self.short_name = short_name
 | 
					 | 
				
			||||||
        self.long_name = long_name
 | 
					 | 
				
			||||||
        self.hardware_model: HardwareModel = hardware_model
 | 
					 | 
				
			||||||
        self.role: Config.DeviceConfig.Role = role
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def to_dict(self):
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
            'node_id': self.node_id,
 | 
					 | 
				
			||||||
            'short_name': self.short_name,
 | 
					 | 
				
			||||||
            'long_name': self.long_name,
 | 
					 | 
				
			||||||
            'hardware_model': get_hardware_model_name_from_code(self.hardware_model),
 | 
					 | 
				
			||||||
            'role': get_role_name_from_role(self.role)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Processor(ABC):
 | 
					 | 
				
			||||||
    def __init__(self, registry: CollectorRegistry, db_pool: ConnectionPool):
 | 
					 | 
				
			||||||
        self.db_pool = db_pool
 | 
					 | 
				
			||||||
        self.metrics = _Metrics(registry)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @abstractmethod
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def execute_db_operation(self, operation):
 | 
					 | 
				
			||||||
        with self.db_pool.connection() as conn:
 | 
					 | 
				
			||||||
            with conn.cursor() as cur:
 | 
					 | 
				
			||||||
                return operation(cur, conn)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ProcessorRegistry:
 | 
					 | 
				
			||||||
    _registry = {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @classmethod
 | 
					 | 
				
			||||||
    def register_processor(cls, port_num):
 | 
					 | 
				
			||||||
        def inner_wrapper(wrapped_class):
 | 
					 | 
				
			||||||
            cls._registry[port_num] = wrapped_class
 | 
					 | 
				
			||||||
            return wrapped_class
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return inner_wrapper
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @classmethod
 | 
					 | 
				
			||||||
    def get_processor(cls, port_num) -> type(Processor):
 | 
					 | 
				
			||||||
        return cls._registry.get(port_num, UnknownAppProcessor)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.UNKNOWN_APP)
 | 
					 | 
				
			||||||
class UnknownAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received UNKNOWN_APP packet")
 | 
					 | 
				
			||||||
        return None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.TEXT_MESSAGE_APP)
 | 
					 | 
				
			||||||
class TextMessageAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received TEXT_MESSAGE_APP packet")
 | 
					 | 
				
			||||||
        message = payload.decode('utf-8')
 | 
					 | 
				
			||||||
        if os.getenv('HIDE_MESSAGE', 'true') == 'true':
 | 
					 | 
				
			||||||
            message = 'Hidden'
 | 
					 | 
				
			||||||
        self.metrics.message_length_histogram.labels(
 | 
					 | 
				
			||||||
            **client_details.to_dict()
 | 
					 | 
				
			||||||
        ).observe(len(message))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.REMOTE_HARDWARE_APP)
 | 
					 | 
				
			||||||
class RemoteHardwareAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received REMOTE_HARDWARE_APP packet")
 | 
					 | 
				
			||||||
        hardware_message = HardwareMessage()
 | 
					 | 
				
			||||||
        hardware_message.ParseFromString(payload)
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.POSITION_APP)
 | 
					 | 
				
			||||||
class PositionAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received POSITION_APP packet")
 | 
					 | 
				
			||||||
        position = Position()
 | 
					 | 
				
			||||||
        position.ParseFromString(payload)
 | 
					 | 
				
			||||||
        self.metrics.device_latitude_gauge.labels(
 | 
					 | 
				
			||||||
            **client_details.to_dict()
 | 
					 | 
				
			||||||
        ).set(position.latitude_i)
 | 
					 | 
				
			||||||
        self.metrics.device_longitude_gauge.labels(
 | 
					 | 
				
			||||||
            **client_details.to_dict()
 | 
					 | 
				
			||||||
        ).set(position.longitude_i)
 | 
					 | 
				
			||||||
        self.metrics.device_altitude_gauge.labels(
 | 
					 | 
				
			||||||
            **client_details.to_dict()
 | 
					 | 
				
			||||||
        ).set(position.altitude)
 | 
					 | 
				
			||||||
        self.metrics.device_position_precision_gauge.labels(
 | 
					 | 
				
			||||||
            **client_details.to_dict()
 | 
					 | 
				
			||||||
        ).set(position.precision_bits)
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.NODEINFO_APP)
 | 
					 | 
				
			||||||
class NodeInfoAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received NODEINFO_APP packet")
 | 
					 | 
				
			||||||
        user = User()
 | 
					 | 
				
			||||||
        user.ParseFromString(payload)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        def db_operation(cur, conn):
 | 
					 | 
				
			||||||
            # First, try to select the existing record
 | 
					 | 
				
			||||||
            cur.execute("""
 | 
					 | 
				
			||||||
                SELECT short_name, long_name, hardware_model, role
 | 
					 | 
				
			||||||
                FROM client_details
 | 
					 | 
				
			||||||
                WHERE node_id = %s;
 | 
					 | 
				
			||||||
            """, (client_details.node_id,))
 | 
					 | 
				
			||||||
            existing_record = cur.fetchone()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if existing_record:
 | 
					 | 
				
			||||||
                # If record exists, update only the fields that are provided in the new data
 | 
					 | 
				
			||||||
                update_fields = []
 | 
					 | 
				
			||||||
                update_values = []
 | 
					 | 
				
			||||||
                if user.short_name:
 | 
					 | 
				
			||||||
                    update_fields.append("short_name = %s")
 | 
					 | 
				
			||||||
                    update_values.append(user.short_name)
 | 
					 | 
				
			||||||
                if user.long_name:
 | 
					 | 
				
			||||||
                    update_fields.append("long_name = %s")
 | 
					 | 
				
			||||||
                    update_values.append(user.long_name)
 | 
					 | 
				
			||||||
                if user.hw_model != HardwareModel.UNSET:
 | 
					 | 
				
			||||||
                    update_fields.append("hardware_model = %s")
 | 
					 | 
				
			||||||
                    update_values.append(get_hardware_model_name_from_code(user.hw_model))
 | 
					 | 
				
			||||||
                if user.role is not None:
 | 
					 | 
				
			||||||
                    update_fields.append("role = %s")
 | 
					 | 
				
			||||||
                    update_values.append(get_role_name_from_role(user.role))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                if update_fields:
 | 
					 | 
				
			||||||
                    update_query = f"""
 | 
					 | 
				
			||||||
                        UPDATE client_details
 | 
					 | 
				
			||||||
                        SET {", ".join(update_fields)}
 | 
					 | 
				
			||||||
                        WHERE node_id = %s
 | 
					 | 
				
			||||||
                    """
 | 
					 | 
				
			||||||
                    cur.execute(update_query, update_values + [client_details.node_id])
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                # If record doesn't exist, insert a new one
 | 
					 | 
				
			||||||
                cur.execute("""
 | 
					 | 
				
			||||||
                    INSERT INTO client_details (node_id, short_name, long_name, hardware_model, role)
 | 
					 | 
				
			||||||
                    VALUES (%s, %s, %s, %s, %s)
 | 
					 | 
				
			||||||
                """, (client_details.node_id, user.short_name, user.long_name,
 | 
					 | 
				
			||||||
                      get_hardware_model_name_from_code(user.hw_model), get_role_name_from_role(user.role)))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            conn.commit()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        self.execute_db_operation(db_operation)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.ROUTING_APP)
 | 
					 | 
				
			||||||
class RoutingAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received ROUTING_APP packet")
 | 
					 | 
				
			||||||
        routing = Routing()
 | 
					 | 
				
			||||||
        routing.ParseFromString(payload)
 | 
					 | 
				
			||||||
        self.metrics.route_discovery_response_counter.labels(
 | 
					 | 
				
			||||||
            **client_details.to_dict(),
 | 
					 | 
				
			||||||
            response_type=self.get_error_name_from_routing(routing.error_reason)
 | 
					 | 
				
			||||||
        ).inc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @staticmethod
 | 
					 | 
				
			||||||
    def get_error_name_from_routing(error_code):
 | 
					 | 
				
			||||||
        for name, value in Routing.Error.__dict__.items():
 | 
					 | 
				
			||||||
            if isinstance(value, int) and value == error_code:
 | 
					 | 
				
			||||||
                return name
 | 
					 | 
				
			||||||
        return 'UNKNOWN_ERROR'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.ADMIN_APP)
 | 
					 | 
				
			||||||
class AdminAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received ADMIN_APP packet")
 | 
					 | 
				
			||||||
        admin_message = AdminMessage()
 | 
					 | 
				
			||||||
        admin_message.ParseFromString(payload)
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.TEXT_MESSAGE_COMPRESSED_APP)
 | 
					 | 
				
			||||||
class TextMessageCompressedAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received TEXT_MESSAGE_COMPRESSED_APP packet")
 | 
					 | 
				
			||||||
        decompressed_payload = unishox2.decompress(payload, len(payload))
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.WAYPOINT_APP)
 | 
					 | 
				
			||||||
class WaypointAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received WAYPOINT_APP packet")
 | 
					 | 
				
			||||||
        waypoint = Waypoint()
 | 
					 | 
				
			||||||
        waypoint.ParseFromString(payload)
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.AUDIO_APP)
 | 
					 | 
				
			||||||
class AudioAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received AUDIO_APP packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: Audio packet. should probably be processed
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.DETECTION_SENSOR_APP)
 | 
					 | 
				
			||||||
class DetectionSensorAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received DETECTION_SENSOR_APP packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: This portnum traffic is not sent to the public MQTT starting at firmware version 2.2.9
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.REPLY_APP)
 | 
					 | 
				
			||||||
class ReplyAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received REPLY_APP packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: Provides a 'ping' service that replies to any packet it receives. This is useful for testing.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.IP_TUNNEL_APP)
 | 
					 | 
				
			||||||
class IpTunnelAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received IP_TUNNEL_APP packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: IP Packet. Handled by the python API, firmware ignores this one and passes it on.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.PAXCOUNTER_APP)
 | 
					 | 
				
			||||||
class PaxCounterAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received PAXCOUNTER_APP packet")
 | 
					 | 
				
			||||||
        paxcounter = Paxcount()
 | 
					 | 
				
			||||||
        paxcounter.ParseFromString(payload)
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.SERIAL_APP)
 | 
					 | 
				
			||||||
class SerialAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received SERIAL_APP packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: Provides a hardware serial interface to send and receive from the Meshtastic network.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.STORE_FORWARD_APP)
 | 
					 | 
				
			||||||
class StoreForwardAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received STORE_FORWARD_APP packet")
 | 
					 | 
				
			||||||
        store_and_forward = StoreAndForward()
 | 
					 | 
				
			||||||
        store_and_forward.ParseFromString(payload)
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.RANGE_TEST_APP)
 | 
					 | 
				
			||||||
class RangeTestAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received RANGE_TEST_APP packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: This portnum traffic is not sent to the public MQTT starting at firmware version 2.2.9
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.TELEMETRY_APP)
 | 
					 | 
				
			||||||
class TelemetryAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def __init__(self, registry: CollectorRegistry, db_connection: psycopg.connection):
 | 
					 | 
				
			||||||
        super().__init__(registry, db_connection)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received TELEMETRY_APP packet")
 | 
					 | 
				
			||||||
        telemetry = Telemetry()
 | 
					 | 
				
			||||||
        telemetry.ParseFromString(payload)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if telemetry.HasField('device_metrics'):
 | 
					 | 
				
			||||||
            device_metrics: DeviceMetrics = telemetry.device_metrics
 | 
					 | 
				
			||||||
            self.metrics.battery_level_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(device_metrics, 'battery_level', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.voltage_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(device_metrics, 'voltage', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.channel_utilization_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(device_metrics, 'channel_utilization', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.air_util_tx_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(device_metrics, 'air_util_tx', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.uptime_seconds_counter.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).inc(getattr(device_metrics, 'uptime_seconds', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if telemetry.HasField('environment_metrics'):
 | 
					 | 
				
			||||||
            environment_metrics: EnvironmentMetrics = telemetry.environment_metrics
 | 
					 | 
				
			||||||
            self.metrics.temperature_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'temperature', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.relative_humidity_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'relative_humidity', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.barometric_pressure_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'barometric_pressure', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.gas_resistance_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'gas_resistance', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.iaq_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'iaq', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.distance_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'distance', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.lux_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'lux', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.white_lux_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'white_lux', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.ir_lux_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'ir_lux', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.uv_lux_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'uv_lux', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.wind_direction_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'wind_direction', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.wind_speed_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'wind_speed', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.weight_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(environment_metrics, 'weight', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if telemetry.HasField('air_quality_metrics'):
 | 
					 | 
				
			||||||
            air_quality_metrics: AirQualityMetrics = telemetry.air_quality_metrics
 | 
					 | 
				
			||||||
            self.metrics.pm10_standard_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'pm10_standard', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.pm25_standard_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'pm25_standard', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.pm100_standard_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'pm100_standard', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.pm10_environmental_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'pm10_environmental', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.pm25_environmental_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'pm25_environmental', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.pm100_environmental_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'pm100_environmental', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.particles_03um_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'particles_03um', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.particles_05um_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'particles_05um', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.particles_10um_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'particles_10um', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.particles_25um_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'particles_25um', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.particles_50um_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'particles_50um', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.particles_100um_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(air_quality_metrics, 'particles_100um', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if telemetry.HasField('power_metrics'):
 | 
					 | 
				
			||||||
            power_metrics: PowerMetrics = telemetry.power_metrics
 | 
					 | 
				
			||||||
            self.metrics.ch1_voltage_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(power_metrics, 'ch1_voltage', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.ch1_current_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(power_metrics, 'ch1_current', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.ch2_voltage_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(power_metrics, 'ch2_voltage', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.ch2_current_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(power_metrics, 'ch2_current', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.ch3_voltage_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(power_metrics, 'ch3_voltage', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            self.metrics.ch3_current_gauge.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).set(getattr(power_metrics, 'ch3_current', 0))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.ZPS_APP)
 | 
					 | 
				
			||||||
class ZpsAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received ZPS_APP packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: Experimental tools for estimating node position without a GPS
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.SIMULATOR_APP)
 | 
					 | 
				
			||||||
class SimulatorAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received SIMULATOR_APP packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: Used to let multiple instances of Linux native applications communicate as if they did using their LoRa chip.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.TRACEROUTE_APP)
 | 
					 | 
				
			||||||
class TraceRouteAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received TRACEROUTE_APP packet")
 | 
					 | 
				
			||||||
        traceroute = RouteDiscovery()
 | 
					 | 
				
			||||||
        traceroute.ParseFromString(payload)
 | 
					 | 
				
			||||||
        if traceroute.route:
 | 
					 | 
				
			||||||
            route = traceroute.route
 | 
					 | 
				
			||||||
            self.metrics.route_discovery_counter.labels(
 | 
					 | 
				
			||||||
                **client_details.to_dict()
 | 
					 | 
				
			||||||
            ).inc(len(route))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.NEIGHBORINFO_APP)
 | 
					 | 
				
			||||||
class NeighborInfoAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received NEIGHBORINFO_APP packet")
 | 
					 | 
				
			||||||
        neighbor_info = NeighborInfo()
 | 
					 | 
				
			||||||
        neighbor_info.ParseFromString(payload)
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.ATAK_PLUGIN)
 | 
					 | 
				
			||||||
class AtakPluginProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received ATAK_PLUGIN packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: ATAK Plugin
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.MAP_REPORT_APP)
 | 
					 | 
				
			||||||
class MapReportAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received MAP_REPORT_APP packet")
 | 
					 | 
				
			||||||
        map_report = MapReport()
 | 
					 | 
				
			||||||
        map_report.ParseFromString(payload)
 | 
					 | 
				
			||||||
        pass  # Nothing interesting here
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.PRIVATE_APP)
 | 
					 | 
				
			||||||
class PrivateAppProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received PRIVATE_APP packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: Private application portnum
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.ATAK_FORWARDER)
 | 
					 | 
				
			||||||
class AtakForwarderProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received ATAK_FORWARDER packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: ATAK Forwarder
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ProcessorRegistry.register_processor(PortNum.MAX)
 | 
					 | 
				
			||||||
class MaxProcessor(Processor):
 | 
					 | 
				
			||||||
    def process(self, payload: bytes, client_details: ClientDetails):
 | 
					 | 
				
			||||||
        logger.debug("Received MAX packet")
 | 
					 | 
				
			||||||
        pass  # NOTE: Maximum portnum value
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										2
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								main.py
									
									
									
									
									
								
							| 
						 | 
					@ -10,7 +10,7 @@ from paho.mqtt.enums import CallbackAPIVersion
 | 
				
			||||||
from prometheus_client import CollectorRegistry, start_http_server
 | 
					from prometheus_client import CollectorRegistry, start_http_server
 | 
				
			||||||
from psycopg_pool import ConnectionPool
 | 
					from psycopg_pool import ConnectionPool
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from exporter.processors import MessageProcessor
 | 
					from exporter.processor_base import MessageProcessor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Global connection pool
 | 
					# Global connection pool
 | 
				
			||||||
connection_pool = None
 | 
					connection_pool = None
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in a new issue