diff --git a/.fpm b/.fpm index b96e532..bb58c19 100644 --- a/.fpm +++ b/.fpm @@ -11,3 +11,7 @@ --depends python3-tz --python-disable-dependency pybase64 --depends python3-unpaddedbase64 +--depends libcap-dev +--deb-systemd service/sentinel_mrhat_cam.service +--deb-systemd-enable +--deb-systemd-auto-start diff --git a/.github/workflows/documentation-release.yml b/.github/workflows/documentation-release.yml index 28dafa9..b7a1d2e 100644 --- a/.github/workflows/documentation-release.yml +++ b/.github/workflows/documentation-release.yml @@ -19,9 +19,13 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcap-dev # ADJUST THIS: install all dependencies (including pdoc) - - run: pip install -e . + - run: pip install . - run: pip install pdoc # ADJUST THIS: build your documentation into docs/. # We use a custom build script for pdoc itself, ideally you just run `pdoc -o docs/ ...` here. diff --git a/.github/workflows/python_release.yml b/.github/workflows/python_release.yml deleted file mode 100644 index ef0be11..0000000 --- a/.github/workflows/python_release.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Python release - -on: - push: - tags: - - "v*.*.*" - -jobs: - publish-and-release: - name: Publish and release distributions - - runs-on: ubuntu-latest - - permissions: - contents: write - discussions: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Package and publish - uses: EffectiveRange/python-package-github-action@v2 - with: - use-devcontainer: 'true' - container-config: 'amd64-container' - debian-dist-type: 'fpm-deb' - install-packaging-tools: 'false' - - name: Release - uses: EffectiveRange/version-release-github-action@v1 - - diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml new file mode 100644 index 0000000..d06d602 --- /dev/null +++ b/.github/workflows/test_and_release.yml @@ -0,0 +1,65 @@ +name: Test and Release + +on: + push: + branches: main + tags: v*.*.* + + pull_request: + branches: [ "main" ] + types: + - synchronize + - opened + - reopened + +concurrency: + group: ${{ github.workflow }}-${{ github.sha }} + cancel-in-progress: true + +jobs: + test: + name: Build and test + + runs-on: ubuntu-latest + + permissions: + # Gives the action the necessary permissions for publishing new + # comments in pull requests. + pull-requests: write + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcap-dev + - name: Verify changes + uses: EffectiveRange/python-verify-github-action@v1 + with: + coverage-threshold: '0' + + release: + if: startsWith(github.ref, 'refs/tags/') + needs: test + + name: Publish and release + + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: true + - name: Package and publish + uses: EffectiveRange/python-package-github-action@v2 + with: + use-devcontainer: 'true' + container-config: 'amd64-container' + debian-dist-command: 'sudo apt-get install -y libcap-dev && pack_python . --all' + install-packaging-tools: 'false' + add-wheel-dist: 'false' + - name: Release + uses: EffectiveRange/version-release-github-action@v1 diff --git a/sentinel_mrhat_cam/config.json b/config/config.json similarity index 100% rename from sentinel_mrhat_cam/config.json rename to config/config.json diff --git a/sentinel_mrhat_cam/log_config.yaml b/config/log_config.yaml similarity index 100% rename from sentinel_mrhat_cam/log_config.yaml rename to config/log_config.yaml diff --git a/scripts/daemon.sh b/scripts/daemon.sh deleted file mode 100644 index ba1f8b4..0000000 --- a/scripts/daemon.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -# Define the service file path -SERVICE_FILE="/etc/systemd/system/sentinel_mrhat_cam.service" - -# Create the service file with the necessary content -sudo bash -c "cat > $SERVICE_FILE < None: raise @staticmethod - def get_default_config() -> dict: + def get_default_config() -> dict[str, Any]: """ Defines and returns a default configuration dictionary. @@ -108,12 +110,12 @@ def get_default_config() -> dict: "mode": "periodic", "period": 15, "wakeUpTime": "06:59:31", - "shutDownTime": "22:00:00" + "shutDownTime": "22:00:00", } return default_config @staticmethod - def validate_config(new_config) -> None: + def validate_config(new_config: dict[str, Any]) -> None: """ Validates the new configuration dictionary against the default configuration and checks if specific rules are fulfilled. @@ -123,7 +125,7 @@ def validate_config(new_config) -> None: Parameters ---------- - new_config : any + new_config : dict The configuration dictionary to be validated. Raises @@ -156,13 +158,13 @@ def validate_config(new_config) -> None: Config.validate_time_format(new_config) @staticmethod - def validate_period(period) -> None: + def validate_period(period: int) -> None: """ Validates the period value in the new configuration dictionary. Parameters ---------- - period : any + period : int The time period to be validated. Raises @@ -181,7 +183,7 @@ def validate_period(period) -> None: raise ValueError("Period specified in the config is more than the maximum allowed wait time.") @staticmethod - def validate_time_format(new_config: dict) -> None: + def validate_time_format(new_config: dict[str, Any]) -> None: """ Validates the wake-up and shut-down time formats in the new configuration dictionary. diff --git a/sentinel_mrhat_cam/camera.py b/sentinel_mrhat_cam/camera.py index 2b3607b..8cbff4d 100644 --- a/sentinel_mrhat_cam/camera.py +++ b/sentinel_mrhat_cam/camera.py @@ -1,4 +1,6 @@ +from typing import Optional, Any from unittest.mock import MagicMock + try: from libcamera import controls from picamera2 import Picamera2 @@ -32,7 +34,7 @@ class Camera: The height of the captured image based on the quality setting. """ - def __init__(self, config): + def __init__(self, config: dict[str, str]) -> None: """ Initializes the Camera class with the given configuration. @@ -84,7 +86,7 @@ def start(self) -> None: self.cam.start(show_preview=False) @log_execution_time("Image capture time:") - def capture(self) -> np.ndarray: + def capture(self) -> Optional[np.ndarray[bool, Any]]: """ Captures an image from the camera and returns it as numpy array. @@ -94,7 +96,7 @@ def capture(self) -> np.ndarray: The captured image as a numpy array. """ try: - image = self.cam.capture_array() + image: np.ndarray[bool, Any] = self.cam.capture_array() except Exception as e: logging.error(f"Error during image capture: {e}") return None diff --git a/sentinel_mrhat_cam/logger.py b/sentinel_mrhat_cam/logger.py index fe99cd4..458f843 100644 --- a/sentinel_mrhat_cam/logger.py +++ b/sentinel_mrhat_cam/logger.py @@ -1,5 +1,7 @@ import logging import logging.config +from typing import Any + import yaml import os import threading @@ -50,8 +52,8 @@ def __init__(self, filepath: str): """ super().__init__() self.filepath = filepath - self.log_queue = Queue() - self.mqtt = None + self.log_queue: Queue[str] = Queue() + self.mqtt: Any = None self.start_event = threading.Event() self.pool = ThreadPool(processes=5) @@ -77,7 +79,7 @@ def start_logging(self) -> None: self.create_mqtt_handler() logging.info("Logging started") - except Exception as e: + except Exception: exit(1) def create_mqtt_handler(self) -> None: @@ -89,8 +91,7 @@ def create_mqtt_handler(self) -> None: """ self.setLevel(LOG_LEVEL) formatter = logging.Formatter( - fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) self.setFormatter(formatter) logging.getLogger().addHandler(self) @@ -103,6 +104,7 @@ def start_mqtt_logging(self) -> None: broker, and signals that MQTT logging has started. """ from .mqtt import MQTT + self.mqtt = MQTT() self.mqtt.connect() self.start_event.set() diff --git a/sentinel_mrhat_cam/mqtt.py b/sentinel_mrhat_cam/mqtt.py index 8911238..f65a5f6 100644 --- a/sentinel_mrhat_cam/mqtt.py +++ b/sentinel_mrhat_cam/mqtt.py @@ -1,11 +1,17 @@ import logging import time import shutil -from .static_config import BROKER, CONFIGSUBTOPIC, PORT, QOS, TEMP_CONFIG_PATH, CONFIG_PATH, USERNAME, PASSWORD +from typing import Any + try: from paho.mqtt import client as mqtt_client + from paho.mqtt import enums as mqtt_enums except ImportError: - mqtt_client = None + mqtt_client = None # type: ignore + mqtt_enums = None # type: ignore + +from .static_config import BROKER, CONFIGSUBTOPIC, PORT, QOS, TEMP_CONFIG_PATH, CONFIG_PATH, USERNAME, PASSWORD + import json import socket import threading @@ -30,7 +36,7 @@ class MQTT: The Quality of Service level for MQTT messages. client : mqtt_client.Client The MQTT client instance. - reconnect_counter : int + broker_connect_counter : int A counter to track reconnection attempts. config_received_event : threading.Event An event to signal when a new configuration is received. @@ -44,12 +50,12 @@ class MQTT: - The class uses configuration values from a `static_config` module, which should be present in the same package. """ - def __init__(self): + def __init__(self) -> None: self.broker = BROKER self.subtopic = CONFIGSUBTOPIC self.port = PORT self.qos = QOS - self.client = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2) + self.client = mqtt_client.Client(mqtt_enums.CallbackAPIVersion.VERSION2) self.broker_connect_counter = 0 self.config_received_event = threading.Event() self.config_confirm_message = "config-nok|Confirm message uninitialized" @@ -70,11 +76,13 @@ def init_receive(self) -> None: configuration path, and a confirmation message is set. If an error occurs, an appropriate error message is set. """ - def on_message(client, userdata, msg): + + def on_message(client: Any, userdata: Any, message: Any) -> None: from .app_config import Config + try: # Parse the JSON message - config_data = json.loads(msg.payload) + config_data = json.loads(message.payload) Config.validate_config(config_data) # Write the validated JSON to the temp file @@ -98,7 +106,7 @@ def on_message(client, userdata, msg): self.client.on_message = on_message self.client.subscribe(self.subtopic) - def connect(self): + def connect(self) -> Any: """ Connect to the MQTT broker. @@ -111,11 +119,18 @@ def connect(self): The connected MQTT client instance. """ try: - def on_connect(client, userdata, flags, rc, properties=None): - if rc == 0: + + def on_connect( + client: Any, + userdata: Any, + flags: Any, + reason_code: Any, + properties: Any, + ) -> None: + if reason_code == 0: logging.info("Connected to MQTT Broker!") else: - logging.error(f"Failed to connect, return code {rc}") + logging.error(f"Failed to connect, return code {reason_code}") # Making sure we can reach the broker before trying to connect self.broker_check() @@ -215,7 +230,7 @@ def is_broker_available(self) -> bool: logging.error(f"Error during creating connection: {e}") exit(1) - def publish(self, message, topic) -> None: + def publish(self, message: str, topic: str) -> None: """ Publishes a message to a specified MQTT topic. @@ -251,7 +266,7 @@ def publish(self, message, topic) -> None: except Exception: exit(1) - def disconnect(self): + def disconnect(self) -> None: """ Disconnect the MQTT client from the broker. diff --git a/sentinel_mrhat_cam/schedule.py b/sentinel_mrhat_cam/schedule.py index 73559dd..1ab4f79 100644 --- a/sentinel_mrhat_cam/schedule.py +++ b/sentinel_mrhat_cam/schedule.py @@ -7,11 +7,11 @@ class Schedule: - def __init__(self, period): + def __init__(self, period: float): self.period = period self.time_offset = 2 # Budapest is UTC+2 - def should_shutdown(self, waiting_time) -> bool: + def should_shutdown(self, waiting_time: float) -> bool: """ Determine if the system should shut down based on the waiting time. @@ -38,11 +38,11 @@ def shutdown(self, waiting_time: float, current_time: datetime) -> None: ---------- waiting_time : float The time difference between the period and the runtime of the script. - end_time : datetime + current_time : datetime The time the transmission ended. Basically, the current time. """ shutdown_duration = self.calculate_shutdown_duration(waiting_time) - wake_time = self.get_wake_time(current_time, shutdown_duration) + wake_time = self.get_wake_time(shutdown_duration).isoformat() logging.info(f"Shutting down for {shutdown_duration} seconds") try: @@ -51,7 +51,7 @@ def shutdown(self, waiting_time: float, current_time: datetime) -> None: except Exception as e: logging.error(f"Failed to schedule wake-up: {e}") - def calculate_shutdown_duration(self, waiting_time) -> float: + def calculate_shutdown_duration(self, waiting_time: float) -> float: """ Calculate the duration for which the system should be shut down. @@ -68,14 +68,12 @@ def calculate_shutdown_duration(self, waiting_time) -> float: shutdown_duration = waiting_time - TIME_TO_BOOT_AND_SHUTDOWN return max(shutdown_duration, 0) - def get_wake_time(self, shutdown_duration) -> datetime: + def get_wake_time(self, shutdown_duration: float) -> datetime: """ Calculate the time at which the system should wake up. Parameters ---------- - current_time : datetime - The current time. shutdown_duration : float The duration for which the system will be shut down. @@ -93,13 +91,13 @@ def get_wake_time(self, shutdown_duration) -> datetime: return current_time + timedelta(seconds=shutdown_duration) - def adjust_time(self, time_str): + def adjust_time(self, timestamp: str) -> str: """Adjust the given UTC time string to local time.""" - hours, minutes, seconds = map(int, time_str.split(':')) + hours, minutes, seconds = map(int, timestamp.split(':')) hours = (hours + self.time_offset) % 24 return f"{hours:02d}:{minutes:02d}:{seconds:02d}" - def working_time_check(self, wakeUpTime, shutDownTime) -> None: + def working_time_check(self, wake_up_timestamp: str, shut_down_timestamp: str) -> None: """ Check if the current time is within the operational hours defined in the configuration. @@ -108,18 +106,18 @@ def working_time_check(self, wakeUpTime, shutDownTime) -> None: Parameters ---------- - wakeUpTime : str + wake_up_timestamp : str The wake-up time in "HH:MM:SS" format. - shutDownTime : str + shut_down_timestamp : str The shutdown time in "HH:MM:SS" format. """ - wake_up_time: time = datetime.strptime(wakeUpTime, "%H:%M:%S").time() - shut_down_time: time = datetime.strptime(shutDownTime, "%H:%M:%S").time() + wake_up_time: time = datetime.strptime(wake_up_timestamp, "%H:%M:%S").time() + shut_down_time: time = datetime.strptime(shut_down_timestamp, "%H:%M:%S").time() utc_time: datetime = datetime.fromisoformat(RTC.get_time()) current_time: time = utc_time.time() - local_wake_up_time = self.adjust_time(wakeUpTime) + local_wake_up_time = self.adjust_time(wake_up_timestamp) logging.info( f"wake up time is : {wake_up_time}, shutdown time is : {shut_down_time}, current time is : {current_time}" diff --git a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/PKG-INFO b/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/PKG-INFO deleted file mode 100644 index 77f3fd7..0000000 --- a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/PKG-INFO +++ /dev/null @@ -1,14 +0,0 @@ -Metadata-Version: 2.1 -Name: sentinel_mrhat_cam -Version: 0.3.3 -Summary: Testing python code for Starling detection project -Author: Ferenc Nandor Janky, Attila Gombos, Nyiri Levente, Nyitrai Bence -Author-email: info@effective-range.com -Requires-Dist: pytest -Requires-Dist: PyYAML>=6.0 -Requires-Dist: pillow -Requires-Dist: pytz -Requires-Dist: paho-mqtt -Requires-Dist: numpy -Requires-Dist: pybase64 -Requires-Dist: pdocs diff --git a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/SOURCES.txt b/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/SOURCES.txt deleted file mode 100644 index da319ea..0000000 --- a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/SOURCES.txt +++ /dev/null @@ -1,9 +0,0 @@ -README.md -setup.cfg -setup.py -scripts/sentinel_mrhat_cam.sh -sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/PKG-INFO -sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/SOURCES.txt -sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/dependency_links.txt -sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/requires.txt -sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/top_level.txt \ No newline at end of file diff --git a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/dependency_links.txt b/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/requires.txt b/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/requires.txt deleted file mode 100644 index da4e3a2..0000000 --- a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/requires.txt +++ /dev/null @@ -1,8 +0,0 @@ -pytest -PyYAML>=6.0 -pillow -pytz -paho-mqtt -numpy -pybase64 -pdocs diff --git a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/top_level.txt b/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/top_level.txt deleted file mode 100644 index 8b13789..0000000 --- a/sentinel_mrhat_cam/sentinel_mrhat_cam.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sentinel_mrhat_cam/static_config.py b/sentinel_mrhat_cam/static_config.py index d445194..b5d1f5b 100644 --- a/sentinel_mrhat_cam/static_config.py +++ b/sentinel_mrhat_cam/static_config.py @@ -2,11 +2,11 @@ import logging # Configuration file paths -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -LOG_CONFIG_PATH = os.path.join(SCRIPT_DIR, 'log_config.yaml') -CONFIG_PATH = os.path.join(SCRIPT_DIR, 'config.json') -TEMP_CONFIG_PATH = os.path.join(SCRIPT_DIR, 'temp_config.json') -STATE_FILE_PATH = os.path.join(SCRIPT_DIR, 'state_file.json') +CONFIG_DIR = '/etc/sentinel_mrhat_cam' +LOG_CONFIG_PATH = os.path.join(CONFIG_DIR, 'log_config.yaml') +CONFIG_PATH = os.path.join(CONFIG_DIR, 'config.json') +TEMP_CONFIG_PATH = os.path.join(CONFIG_DIR, 'temp_config.json') +STATE_FILE_PATH = os.path.join(CONFIG_DIR, 'state_file.json') # MQTT Configuration """ BROKER = "192.168.0.105" diff --git a/sentinel_mrhat_cam/system.py b/sentinel_mrhat_cam/system.py index f2c1b42..8655c25 100644 --- a/sentinel_mrhat_cam/system.py +++ b/sentinel_mrhat_cam/system.py @@ -1,9 +1,10 @@ import subprocess import logging from datetime import datetime -from typing import List +from typing import List, Union, Any import pytz import time + try: from gpiozero import CPUTemperature except ImportError: @@ -29,24 +30,24 @@ class System: """ @staticmethod - def shutdown(): + def shutdown() -> None: logging.info("Pi has been shut down") subprocess.run(['sudo', 'shutdown', '-h', 'now']) @staticmethod - def reboot(): + def reboot() -> None: logging.info("System will be rebooted") subprocess.run(['sudo', 'reboot'], check=True) # Still experimental, don't have the real API yet. @staticmethod - def schedule_wakeup(wake_time): + def schedule_wakeup(wake_time: Union[str, int, float]) -> None: """ Schedule a system wake-up at a specified time. Parameters ---------- - wake_time : datetime + wake_time : str, int, float The time at which the system should wake up. Raises @@ -69,7 +70,7 @@ def schedule_wakeup(wake_time): raise ValueError("wake_time must be a str, int, or float") # Execute the command - result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True) + subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True) except subprocess.CalledProcessError as e: logging.error(f"Failed to set RTC wake-up alarm: {e}") @@ -77,7 +78,7 @@ def schedule_wakeup(wake_time): raise @staticmethod - def get_cpu_temperature(): + def get_cpu_temperature() -> float: """ Get the current CPU temperature. @@ -87,10 +88,10 @@ def get_cpu_temperature(): The current CPU temperature in degrees Celsius. """ cpu = CPUTemperature() - return cpu.temperature + return cpu.temperature # type: ignore @staticmethod - def get_battery_info(): + def get_battery_info() -> dict[str, Any]: """ Get information about the battery status. @@ -132,14 +133,14 @@ def get_battery_info(): """ try: # Battery info is path dependant!!!! - result = subprocess.run(['upower', '-i', '/org/freedesktop/UPower/devices/battery_bq2562x_battery'], - stdout=subprocess.PIPE, check=True) + result = subprocess.run( + ['upower', '-i', '/org/freedesktop/UPower/devices/battery_bq2562x_battery'], + stdout=subprocess.PIPE, + check=True, + ) info = result.stdout.decode('utf-8') - battery_info = { - 'temperature': None, - 'percentage': None - } + battery_info: dict[str, Any] = {'temperature': None, 'percentage': None} for line in info.splitlines(): if "temperature:" in line: @@ -154,7 +155,7 @@ def get_battery_info(): exit(1) @staticmethod - def gather_hardware_info(): + def gather_hardware_info() -> Union[dict[str, Any], None]: """ Collect comprehensive hardware information about the system. @@ -214,15 +215,13 @@ def gather_hardware_info(): try: # Get battery info battery_result = subprocess.run( - ['cat', '/sys/class/power_supply/bq2562x-battery/uevent'], - stdout=subprocess.PIPE, check=True + ['cat', '/sys/class/power_supply/bq2562x-battery/uevent'], stdout=subprocess.PIPE, check=True ) battery_info = battery_result.stdout.decode('utf-8') # Get charger info charger_result = subprocess.run( - ['cat', '/sys/class/power_supply/bq2562x-charger/uevent'], - stdout=subprocess.PIPE, check=True + ['cat', '/sys/class/power_supply/bq2562x-charger/uevent'], stdout=subprocess.PIPE, check=True ) charger_info = charger_result.stdout.decode('utf-8') @@ -261,6 +260,7 @@ class RTC: syncing the RTC with the system time, and retrieving the current time. """ + @staticmethod def sync_RTC_to_system() -> None: """ @@ -287,7 +287,7 @@ def sync_RTC_to_system() -> None: logging.error(f"Error syncing RTC: {e}") @staticmethod - def sync_system_to_ntp(max_retries=5, delay=2) -> bool: + def sync_system_to_ntp(max_retries: int = 5, delay: int = 2) -> bool: """ Synchronize the system clock to NTP server. @@ -333,7 +333,7 @@ def sync_system_to_ntp(max_retries=5, delay=2) -> bool: exit(1) @staticmethod - def convert_timestamp(timestamp_str) -> str: + def convert_timestamp(timestamp: str) -> str: """ Convert a timestamp string to ISO 8601 format. @@ -343,7 +343,7 @@ def convert_timestamp(timestamp_str) -> str: Parameters ---------- - timestamp_str : str + timestamp : str The timestamp string to convert. Expected format is: "Day YYYY-MM-DD HH:MM:SS [UTC]", e.g., "Mon 2023-08-14 15:30:45 UTC". @@ -371,15 +371,15 @@ def convert_timestamp(timestamp_str) -> str: """ try: # Remove the 'UTC' part if it exists - parts = timestamp_str.split() + parts = timestamp.split() # if the last element is 'UTC' remove it if parts[-1] == 'UTC': - timestamp_str = ' '.join(parts[:-1]) + timestamp = ' '.join(parts[:-1]) # Parse the timestamp while ignoring the weekday and timezone - timestamp = datetime.strptime(timestamp_str, "%a %Y-%m-%d %H:%M:%S") + parsed_time = datetime.strptime(timestamp, "%a %Y-%m-%d %H:%M:%S") # Localize to UTC - timestamp = pytz.UTC.localize(timestamp, None) - return timestamp.isoformat() + utc_time = pytz.UTC.localize(parsed_time, None) + return utc_time.isoformat() # type: ignore except Exception as e: logging.error(f"Error parsing timestamp: {e}") exit(1) @@ -414,7 +414,7 @@ def get_timedatectl() -> List[str]: return result.stdout.splitlines() @staticmethod - def find_line(lines, target_string) -> str: + def find_line(lines: list[str], target_string: str) -> str: """ Find and return a specific line from `timedatectl` output. diff --git a/sentinel_mrhat_cam/transmit.py b/sentinel_mrhat_cam/transmit.py index 7bc6e8f..d98948f 100644 --- a/sentinel_mrhat_cam/transmit.py +++ b/sentinel_mrhat_cam/transmit.py @@ -1,7 +1,7 @@ import logging import json import io -from typing import Dict, Any, Tuple +from typing import Dict, Any, Optional from PIL import Image import pybase64 from datetime import datetime @@ -75,7 +75,7 @@ def log_hardware_info(self, hardware_info: Dict[str, Any]) -> None: logging.info(f"charger_voltage_now: {hardware_info['charger_voltage_now']}") logging.info(f"charger_current_now: {hardware_info['charger_current_now']}") - def create_base64_image(self, image_array: np.ndarray) -> str: + def create_base64_image(self, image_array: Optional[np.ndarray[bool, Any]]) -> str: """ Converts a numpy array representing an image into a base64-encoded JPEG string. @@ -120,7 +120,7 @@ def create_base64_image(self, image_array: np.ndarray) -> str: return pybase64.b64encode(image_data).decode("utf-8") @log_execution_time("Creating the json message") - def create_message(self, image_array: np.ndarray, timestamp: str) -> str: + def create_message(self, image_array: Optional[np.ndarray[bool, Any]], timestamp: str) -> str: """ Creates a JSON message containing image data, timestamp, CPU temperature, battery temperature, and battery charge percentage. @@ -151,18 +151,21 @@ def create_message(self, image_array: np.ndarray, timestamp: str) -> str: - This method is decorated with `@log_execution_time`, which logs the time taken to execute the method. """ try: - battery_info: Dict[str, Any] = System.get_battery_info() - hardware_info: Dict[str, Any] = System.gather_hardware_info() + battery_info = System.get_battery_info() + hardware_info = System.gather_hardware_info() cpu_temp: float = System.get_cpu_temperature() logging.info( - f"Battery temp: {battery_info['temperature']}°C, percentage: {battery_info['percentage']} %, CPU temp: {cpu_temp}°C") + f"Battery temp: {battery_info['temperature']}°C, " + f"percentage: {battery_info['percentage']} %, " + f"CPU temp: {cpu_temp}°C" + ) message: Dict[str, Any] = { "timestamp": timestamp, "image": self.create_base64_image(image_array), "cpuTemp": cpu_temp, "batteryTemp": battery_info["temperature"], - "batteryCharge": battery_info["percentage"] + "batteryCharge": battery_info["percentage"], } # Log hardware info to a file for further analysis @@ -194,9 +197,9 @@ def get_message(self) -> str: A JSON string containing the image data, timestamp, CPU temperature, battery temperature, and battery charge percentage as a string. """ - image_raw: np.ndarray = self.camera.capture() - timestamp: str = RTC.get_time() - message: str = self.create_message(image_raw, timestamp) + image_raw = self.camera.capture() + timestamp = RTC.get_time() + message = self.create_message(image_raw, timestamp) return message @log_execution_time("Taking a picture and sending it") @@ -239,7 +242,7 @@ def transmit_message(self) -> None: logging.error(f"Error in run method: {e}") raise - def transmit_message_with_time_measure(self) -> Tuple[float, datetime]: + def transmit_message_with_time_measure(self) -> float: """ Run the `transmit_message` method while timing how long it takes to complete. The total elapsed time is then used to calculate how long the system should wait @@ -247,18 +250,19 @@ def transmit_message_with_time_measure(self) -> Tuple[float, datetime]: Returns ------- - Tuple[float, datetime] - - float: The waiting time (in seconds) until the next execution, which + float + The waiting time (in seconds) until the next execution, which is the `period` minus the elapsed time. - - datetime: The ending time of the transmiting process, represented as a datetime object. """ try: start_time: str = RTC.get_time() self.transmit_message() end_time: str = RTC.get_time() - elapsed_time: float = (datetime.fromisoformat(end_time) - - datetime.fromisoformat(start_time)).total_seconds() - waiting_time: float = self.schedule.period - elapsed_time + elapsed_time: float = ( + datetime.fromisoformat(end_time) - datetime.fromisoformat(start_time) + ).total_seconds() + waiting_time = self.schedule.period - elapsed_time + return max(waiting_time, MINIMUM_WAIT_TIME) except Exception as e: logging.error(f"Error in run_with_time_measure method: {e}") - return max(waiting_time, MINIMUM_WAIT_TIME) + raise e diff --git a/sentinel_mrhat_cam/utils.py b/sentinel_mrhat_cam/utils.py index b7cf9c4..6ffbc60 100644 --- a/sentinel_mrhat_cam/utils.py +++ b/sentinel_mrhat_cam/utils.py @@ -1,12 +1,15 @@ import time import logging from functools import wraps +from typing import Optional, Any, TypeVar, Callable, cast +F = TypeVar('F', bound=Callable[..., Any]) -def log_execution_time(operation_name=None): - def decorator(func): + +def log_execution_time(operation_name: Optional[str] = None) -> Callable[[F], F]: + def decorator(func: F) -> F: @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: start_time = time.perf_counter() result = func(*args, **kwargs) end_time = time.perf_counter() @@ -19,5 +22,7 @@ def wrapper(*args, **kwargs): logging.info(log_message) return result - return wrapper + + return cast(F, wrapper) + return decorator diff --git a/service/sentinel_mrhat_cam.service b/service/sentinel_mrhat_cam.service new file mode 100644 index 0000000..c6befe2 --- /dev/null +++ b/service/sentinel_mrhat_cam.service @@ -0,0 +1,12 @@ +[Unit] +Description=Run Script Daemon + +[Service] +Type=simple +User=admin +ExecStart=/bin/bash /usr/local/bin/sentinel_mrhat_cam.sh +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/setup.cfg b/setup.cfg index d8c17b0..721642c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,7 +8,16 @@ packaging = packages = sentinel_mrhat_cam strict = True -[mypy-netifaces] +[mypy-pytz] +ignore_missing_imports = True + +[mypy-gpiozero] +ignore_missing_imports = True + +[mypy-libcamera] +ignore_missing_imports = True + +[mypy-picamera2] ignore_missing_imports = True [flake8] diff --git a/setup.py b/setup.py index b148db0..d2d4a89 100644 --- a/setup.py +++ b/setup.py @@ -7,11 +7,7 @@ author='Ferenc Nandor Janky, Attila Gombos, Nyiri Levente, Nyitrai Bence', author_email='info@effective-range.com', packages=find_packages(), - scripts=['scripts/sentinel_mrhat_cam.sh', 'scripts/daemon.sh'], - install_requires=['PyYAML>=6.0', - 'pillow', - 'pytz', - 'paho-mqtt', - 'numpy', - 'pybase64'] + scripts=['scripts/sentinel_mrhat_cam.sh', 'scripts/sentinel_mrhat_cam_main.py'], + data_files=[('config', ['config/config.json', 'config/log_config.yaml'])], + install_requires=['picamera2', 'PyYAML>=6.0', 'pillow', 'pytz', 'paho-mqtt', 'numpy', 'pybase64'], ) diff --git a/tests/utilsTest.py b/tests/utilsTest.py new file mode 100644 index 0000000..ad00482 --- /dev/null +++ b/tests/utilsTest.py @@ -0,0 +1,36 @@ +import logging +import time +import unittest +from unittest import TestCase + +from sentinel_mrhat_cam import log_execution_time + + +class UtilsTest(TestCase): + + @classmethod + def setUpClass(cls): + logging.basicConfig() + + def setUp(self): + print() + + def test_log_exec_time_with_operation(self): + + @log_execution_time("test") + def test_func(): + time.sleep(0.1) + + test_func() + + def test_log_exec_time_without_operation(self): + + @log_execution_time() + def test_func(): + time.sleep(0.1) + + test_func() + + +if __name__ == "__main__": + unittest.main()