diff --git a/meshtastic_utils.py b/meshtastic_utils.py index 5ff0b7d..2576fec 100644 --- a/meshtastic_utils.py +++ b/meshtastic_utils.py @@ -1,4 +1,5 @@ import asyncio +import time import meshtastic.tcp_interface import meshtastic.serial_interface from typing import List @@ -22,14 +23,56 @@ def connect_meshtastic(): return meshtastic_client # Initialize Meshtastic interface connection_type = relay_config["meshtastic"]["connection_type"] + retry_limit = ( + relay_config["meshtastic"]["retry_limit"] + if "retry_limit" in relay_config["meshtastic"] + else 3 + ) + attempts = 1 + successful = False if connection_type == "serial": serial_port = relay_config["meshtastic"]["serial_port"] logger.info(f"Connecting to serial port {serial_port} ...") - meshtastic_client = meshtastic.serial_interface.SerialInterface(serial_port) + while not successful and attempts <= retry_limit: + try: + meshtastic_client = meshtastic.serial_interface.SerialInterface( + serial_port + ) + successful = True + except Exception as e: + attempts += 1 + if attempts <= retry_limit: + logger.warn( + f"Attempt #{attempts-1} failed. Retrying in {attempts} secs {e}" + ) + time.sleep(attempts) + else: + logger.error(f"Could not connect: {e}") + return None else: target_host = relay_config["meshtastic"]["host"] logger.info(f"Connecting to host {target_host} ...") - meshtastic_client = meshtastic.tcp_interface.TCPInterface(hostname=target_host) + while not successful and attempts <= retry_limit: + try: + meshtastic_client = meshtastic.tcp_interface.TCPInterface( + hostname=target_host + ) + successful = True + except Exception as e: + attempts += 1 + if attempts <= retry_limit: + logger.warn( + f"Attempt #{attempts-1} failed. Retrying in {attempts} secs... {e}" + ) + time.sleep(attempts) + else: + logger.error(f"Could not connect: {e}") + return None + + nodeInfo = meshtastic_client.getMyNodeInfo() + logger.info( + f"Connected to {nodeInfo['user']['shortName']} / {nodeInfo['user']['hwModel']}" + ) return meshtastic_client diff --git a/plugin_loader.py b/plugin_loader.py index 6f32fbf..4a7364d 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -42,8 +42,8 @@ def load_plugins(): if "priority" in plugin.config else plugin.priority ) - logger.info(f"Loaded {plugin.plugin_name} ({plugin.priority})") active_plugins.append(plugin) + plugin.start() sorted_active_plugins = sorted(active_plugins, key=lambda plugin: plugin.priority) return sorted_active_plugins diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py index e5c0e70..fc0bbc2 100644 --- a/plugins/base_plugin.py +++ b/plugins/base_plugin.py @@ -1,4 +1,7 @@ import markdown +import schedule +import threading +import time from abc import ABC, abstractmethod from log_utils import get_logger from config import relay_config @@ -26,6 +29,49 @@ class BasePlugin(ABC): if "plugins" in relay_config and self.plugin_name in relay_config["plugins"]: self.config = relay_config["plugins"][self.plugin_name] + def start(self): + if "schedule" not in self.config or ( + "at" not in self.config["schedule"] + and "hours" not in self.config["schedule"] + and "minutes" not in self.config["schedule"] + ): + self.logger.debug(f"Started with priority={self.priority}") + return + + # Schedule the email-checking function to run every minute + if "at" in self.config["schedule"] and "hours" in self.config["schedule"]: + schedule.every(self.config["schedule"]["hours"]).hours.at( + self.config["schedule"]["at"] + ).do(self.background_job) + elif "at" in self.config["schedule"] and "minutes" in self.config["schedule"]: + schedule.every(self.config["schedule"]["minutes"]).minutes.at( + self.config["schedule"]["at"] + ).do(self.background_job) + elif "hours" in self.config["schedule"]: + schedule.every(self.config["schedule"]["hours"]).hours.do( + self.background_job + ) + elif "minutes" in self.config["schedule"]: + schedule.every(self.config["schedule"]["minutes"]).minutes.do( + self.background_job + ) + + # Function to execute the scheduled tasks + def run_schedule(): + while True: + schedule.run_pending() + time.sleep(1) + + # Create a thread for executing the scheduled tasks + schedule_thread = threading.Thread(target=run_schedule) + + # Start the thread + schedule_thread.start() + self.logger.debug(f"Scheduled with priority={self.priority}") + + def background_job(self): + pass + def strip_raw(self, data): if type(data) is not dict: return data diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index 2e8982f..c65cf07 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -1,4 +1,5 @@ import staticmaps +import s2sphere import math import random import io @@ -8,6 +9,139 @@ from nio import AsyncClient, UploadResponse from plugins.base_plugin import BasePlugin +class TextLabel(staticmaps.Object): + def __init__(self, latlng: s2sphere.LatLng, text: str, fontSize: int = 12) -> None: + staticmaps.Object.__init__(self) + self._latlng = latlng + self._text = text + self._margin = 4 + self._arrow = 16 + self._font_size = fontSize + print(self._font_size) + + def latlng(self) -> s2sphere.LatLng: + return self._latlng + + def bounds(self) -> s2sphere.LatLngRect: + return s2sphere.LatLngRect.from_point(self._latlng) + + def extra_pixel_bounds(self) -> staticmaps.PixelBoundsT: + # Guess text extents. + tw = len(self._text) * self._font_size * 0.5 + th = self._font_size * 1.2 + w = max(self._arrow, tw + 2.0 * self._margin) + return (int(w / 2.0), int(th + 2.0 * self._margin + self._arrow), int(w / 2), 0) + + def render_pillow(self, renderer: staticmaps.PillowRenderer) -> None: + x, y = renderer.transformer().ll2pixel(self.latlng()) + x = x + renderer.offset_x() + + tw, th = renderer.draw().textsize(self._text) + w = max(self._arrow, tw + 2 * self._margin) + h = th + 2 * self._margin + + path = [ + (x, y), + (x + self._arrow / 2, y - self._arrow), + (x + w / 2, y - self._arrow), + (x + w / 2, y - self._arrow - h), + (x - w / 2, y - self._arrow - h), + (x - w / 2, y - self._arrow), + (x - self._arrow / 2, y - self._arrow), + ] + + renderer.draw().polygon(path, fill=(255, 255, 255, 255)) + renderer.draw().line(path, fill=(255, 0, 0, 255)) + renderer.draw().text( + (x - tw / 2, y - self._arrow - h / 2 - th / 2), + self._text, + fill=(0, 0, 0, 255), + ) + + def render_cairo(self, renderer: staticmaps.CairoRenderer) -> None: + x, y = renderer.transformer().ll2pixel(self.latlng()) + + ctx = renderer.context() + ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + + ctx.set_font_size(self._font_size) + x_bearing, y_bearing, tw, th, _, _ = ctx.text_extents(self._text) + + w = max(self._arrow, tw + 2 * self._margin) + h = th + 2 * self._margin + + path = [ + (x, y), + (x + self._arrow / 2, y - self._arrow), + (x + w / 2, y - self._arrow), + (x + w / 2, y - self._arrow - h), + (x - w / 2, y - self._arrow - h), + (x - w / 2, y - self._arrow), + (x - self._arrow / 2, y - self._arrow), + ] + + ctx.set_source_rgb(1, 1, 1) + ctx.new_path() + for p in path: + ctx.line_to(*p) + ctx.close_path() + ctx.fill() + + ctx.set_source_rgb(1, 0, 0) + ctx.set_line_width(1) + ctx.new_path() + for p in path: + ctx.line_to(*p) + ctx.close_path() + ctx.stroke() + + ctx.set_source_rgb(0, 0, 0) + ctx.set_line_width(1) + ctx.move_to( + x - tw / 2 - x_bearing, y - self._arrow - h / 2 - y_bearing - th / 2 + ) + ctx.show_text(self._text) + ctx.stroke() + + def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: + x, y = renderer.transformer().ll2pixel(self.latlng()) + + # guess text extents + tw = len(self._text) * self._font_size * 0.5 + th = self._font_size * 1.2 + + w = max(self._arrow, tw + 2 * self._margin) + h = th + 2 * self._margin + + path = renderer.drawing().path( + fill="#ffffff", + stroke="#ff0000", + stroke_width=1, + opacity=1.0, + ) + path.push(f"M {x} {y}") + path.push(f" l {self._arrow / 2} {-self._arrow}") + path.push(f" l {w / 2 - self._arrow / 2} 0") + path.push(f" l 0 {-h}") + path.push(f" l {-w} 0") + path.push(f" l 0 {h}") + path.push(f" l {w / 2 - self._arrow / 2} 0") + path.push("Z") + renderer.group().add(path) + + renderer.group().add( + renderer.drawing().text( + self._text, + text_anchor="middle", + dominant_baseline="central", + insert=(x, y - self._arrow - h / 2), + font_family="sans-serif", + font_size=f"{self._font_size}px", + fill="#000000", + ) + ) + + def anonymize_location(lat, lon, radius=1000): # Generate random offsets for latitude and longitude lat_offset = random.uniform(-radius / 111320, radius / 111320) @@ -42,7 +176,7 @@ def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000) radio = staticmaps.create_latlng( float(location["lat"]), float(location["lon"]) ) - context.add_object(staticmaps.Marker(radio, size=10)) + context.add_object(TextLabel(radio, location["label"], fontSize=50)) # render non-anti-aliased png if image_size: @@ -148,6 +282,7 @@ class Plugin(BasePlugin): { "lat": info["position"]["latitude"], "lon": info["position"]["longitude"], + "label": info["user"]["shortName"], } )