Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.11", "3.x"]
python-version: ["3.11", "3.13", "3.x"]

steps:
- name: 🛒 Checkout repository
Expand All @@ -26,7 +26,7 @@ jobs:
- name: 👷🏻 Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt pytest
pip install . build

- name: 🏗️ Build application wheel
run: python -m build
Expand All @@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.11", "3.x"]
python-version: ["3.11", "3.13", "3.x"]

steps:
- name: 🛒 Checkout repository
Expand All @@ -50,7 +50,7 @@ jobs:
- name: 👷🏻 Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt pytest
pip install . pytest

- name: 🕵🏻 Test with pytest
run: pytest --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml
Expand All @@ -63,7 +63,7 @@ jobs:
# Use always() to always run this step to publish test results when there are test failures
if: ${{ always() }}

build-pylint:
build-ruff:
runs-on: ubuntu-latest
steps:
- name: 🛒 Checkout repository
Expand All @@ -74,14 +74,15 @@ jobs:
with:
python-version: "3.x"

- name: 👷🏻 Install dependencies
- name: 👷🏻 Install ruff
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt pylint pylint-exit
pip install ruff

- name: 🕵🏻 Analyse code with pylint
- name: 🕵🏻 Check code with ruff
run: |
pylint --init-hook="import sys; import os; sys.path.append(os.path.abspath('.'));" $(git ls-files '*.py') || pylint-exit $?
ruff check .
ruff format --check .

build-docker:
runs-on: ubuntu-latest
Expand Down
28 changes: 18 additions & 10 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
FROM python:3.11-slim AS builder
FROM python:3.13-slim AS builder

RUN apt-get update && apt-get install -y --no-install-recommends binutils
RUN apt-get update && apt-get install -y --no-install-recommends binutils \
&& rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user -r requirements.txt pyinstaller --no-warn-script-location

COPY src/aps2mqtt/ /app/aps2mqtt
WORKDIR /app
RUN /root/.local/bin/pyinstaller --collect-all tzdata --onefile /app/aps2mqtt/__main__.py -n aps2mqtt
COPY . /build
WORKDIR /build
RUN pip install --no-cache-dir --user . pyinstaller --no-warn-script-location
RUN SITE_PACKAGES=$(python -c "import sysconfig; print(sysconfig.get_path('purelib', 'posix_user'))") \
&& /root/.local/bin/pyinstaller --collect-all tzdata --onefile "$SITE_PACKAGES/aps2mqtt/__main__.py" -n aps2mqtt

FROM ubuntu:noble AS runner
RUN apt update && apt install tzdata -y && apt clean && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/dist/aps2mqtt /app/
LABEL org.opencontainers.image.source="https://github.com/fligneul/aps2mqtt" \
org.opencontainers.image.description="Access APSystems ECU data over MQTT" \
org.opencontainers.image.licenses="GPL-3.0-or-later"

RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r aps2mqtt && useradd -r -g aps2mqtt aps2mqtt

COPY --from=builder /build/dist/aps2mqtt /app/

USER aps2mqtt
WORKDIR /app
CMD ["./aps2mqtt"]
9 changes: 0 additions & 9 deletions MANIFEST.in

This file was deleted.

14 changes: 8 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "aps2mqtt"
dynamic = ["version"]
description = "Access APSystems ECU data over MQTT"
readme = "README.md"
requires-python = ">=3.9, <3.14"
requires-python = ">=3.11, <3.14"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
authors = [
Expand All @@ -18,8 +18,6 @@ urls = { Homepage = "https://github.com/fligneul/aps2mqtt" }
classifiers = [
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand All @@ -29,7 +27,6 @@ dependencies = [
"requests~=2.32",
"paho.mqtt~=2.1",
"pyyaml~=6.0",
"numpy~=2.3",
"suntime~=1.2.5",
"str2bool~=1.1",
"tzdata>=2025.2",
Expand All @@ -38,16 +35,21 @@ dependencies = [
[tool.setuptools.dynamic]
version = { attr = "aps2mqtt.__version__" }

[tool.black]
[tool.ruff]
line-length = 100
target-version = "py311"
exclude = ["src/aps2mqtt/apsystems/", "test/aps2mqtt/apsystems/"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "N"]

[tool.semantic_release]
version_variables = ["src/aps2mqtt/__init__.py:__version__"]
commit_message = "[release] {version}\n\nAutomatically generated by python-semantic-release"
commit_parser = "conventional"
major_on_zero = true
tag_format = "v{version}"
build_command = "pip install -r requirements.txt && python -m build"
build_command = "pip install . && python -m build"

[tool.semantic_release.branches.main]
match = "(main|master)"
Expand Down
10 changes: 0 additions & 10 deletions requirements.txt

This file was deleted.

67 changes: 49 additions & 18 deletions src/aps2mqtt/config.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,49 @@
"""Application config classes, can be set by file or env variable"""

import os
from dataclasses import dataclass, field
from zoneinfo import ZoneInfo
from dateutil import tz

import yaml
from dateutil import tz
from str2bool import str2bool_exc


@dataclass
class MQTTDiscoveryConfig:
"""MQTT Discovery config"""

def __init__(self, cfg):
prefix: str

def __init__(self, cfg: dict) -> None:
self.prefix = cfg.get("MQTT_DISCOVERY_PREFIX", "homeassistant")


@dataclass
class WifiConfig:
"""Wifi config of the ECU"""

ssid: str
passwd: str


@dataclass
class MQTTConfig:
"""MQTT config"""

def __init__(self, cfg):
broker_addr: str = "127.0.0.1"
broker_port: int = 1883
broker_user: str = ""
broker_passwd: str = ""
client_id: str = "APS2MQTT"
topic_prefix: str = ""
retain: bool = False
discovery_enabled: bool = False
discovery: MQTTDiscoveryConfig | None = None
secured_connection: bool = False
cacerts_path: str | None = None

def __init__(self, cfg: dict) -> None:
self.broker_addr = cfg.get("MQTT_BROKER_HOST", "127.0.0.1")
self.broker_port = int(cfg.get("MQTT_BROKER_PORT", 1883))
self.broker_user = cfg.get("MQTT_BROKER_USER", "")
Expand All @@ -32,13 +58,23 @@ def __init__(self, cfg):
str(cfg.get("MQTT_BROKER_SECURED_CONNECTION", False))
)
if self.secured_connection:
self.cacerts_path = cfg.get("MQTT_BROKER_CACERTS_PATH", None)
self.cacerts_path = cfg.get("MQTT_BROKER_CACERTS_PATH")


@dataclass
class ECUConfig:
"""ECU config"""

def __init__(self, cfg):
ipaddr: str = ""
port: int = 8899
timezone: ZoneInfo | tz.tzlocal = field(default_factory=tz.tzlocal)
auto_restart: bool = False
wifi_config: WifiConfig | None = None
stop_at_night: bool = False
ecu_position_latitude: float = 48.864716
ecu_position_longitude: float = 2.349014

def __init__(self, cfg: dict) -> None:
self.ipaddr = cfg["APS_ECU_IP"]
self.port = int(cfg.get("APS_ECU_PORT", 8899))
ecu_timezone = cfg.get("APS_ECU_TIMEZONE", os.getenv("TZ", None))
Expand All @@ -54,29 +90,24 @@ def __init__(self, cfg):
self.ecu_position_longitude = float(cfg.get("APS_ECU_POSITION_LNG", 2.349014))


class WifiConfig:
"""Wifi config of the ECU"""

def __init__(self, ssid, passwd):
self.ssid = ssid
self.passwd = passwd


class Config:
"""Application config"""

def __init__(self, config_path=None):
mqtt_config: MQTTConfig
ecu_config: ECUConfig

def __init__(self, config_path: str | None = None) -> None:
if config_path is not None:
self.__load_yaml_config_file(config_path)
self._load_yaml_config_file(config_path)
elif os.getenv("CONFIG_FILE") is not None:
self.__load_yaml_config_file(os.getenv("CONFIG_FILE"))
self._load_yaml_config_file(os.getenv("CONFIG_FILE"))
else:
cfg = os.environ
self.mqtt_config = MQTTConfig(cfg)
self.ecu_config = ECUConfig(cfg)

def __load_yaml_config_file(self, config_path):
with open(config_path, "r", encoding="UTF-8") as yml_cfg:
def _load_yaml_config_file(self, config_path: str) -> None:
with open(config_path, encoding="UTF-8") as yml_cfg:
cfg = yaml.safe_load(yml_cfg)
self.mqtt_config = MQTTConfig(cfg["mqtt"])
self.ecu_config = ECUConfig(cfg["ecu"])
25 changes: 13 additions & 12 deletions src/aps2mqtt/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@
import logging
import os
import time
from argparse import ArgumentParser, Namespace
from datetime import UTC, datetime, timedelta

from argparse import ArgumentParser
from datetime import datetime, timedelta, timezone
from str2bool import str2bool_exc
from aps2mqtt.mqtthandler import MQTTHandler
from aps2mqtt.config import Config

from aps2mqtt.apsystems.ECU import ECU
from aps2mqtt.config import Config
from aps2mqtt.mqtthandler import MQTTHandler

_LOGGER = logging.getLogger(__name__)


def cli_args():
def cli_args() -> Namespace:
"""Create CLI arguments and parse them"""
parser = ArgumentParser(prog="aps2mqtt")
parser.add_argument(
Expand All @@ -27,7 +28,7 @@ def cli_args():
return parser.parse_args()


def main():
def main() -> None:
"""Application main"""
args = cli_args()
conf = Config(args.config_path)
Expand All @@ -37,14 +38,14 @@ def main():
else:
logging.basicConfig(level=logging.INFO)

update_time = datetime(1970, 1, 1, tzinfo=timezone.utc)
update_time = datetime(1970, 1, 1, tzinfo=UTC)

ecu = ECU(conf.ecu_config)
mqtt_handler = MQTTHandler(conf.mqtt_config)
mqtt_handler.connect_mqtt()

while 1:
if datetime.now(timezone.utc) > update_time:
while True:
if datetime.now(UTC) > update_time:
if ecu.should_sleep():
update_time = ecu.wake_up_time()
_LOGGER.info(
Expand All @@ -54,15 +55,15 @@ def main():
else:
try:
data = ecu.update()
if data is None or len(data) == 0:
if not data:
raise ValueError("Retrieved data are empty")
update_time = datetime.strptime(data["timestamp"], "%Y-%m-%d %H:%M:%S").replace(
tzinfo=conf.ecu_config.timezone
) + timedelta(seconds=360)
mqtt_handler.publish_values(data)
except Exception as e:
update_time = datetime.now(timezone.utc) + timedelta(seconds=60)
_LOGGER.error("An exception occured: %s -> %s", e.__class__.__name__, str(e))
update_time = datetime.now(UTC) + timedelta(seconds=60)
_LOGGER.error("An exception occurred: %s -> %s", e.__class__.__name__, str(e))
_LOGGER.debug("Exception trace:", exc_info=True)
_LOGGER.info(
"Update finished, next update at: %s",
Expand Down
Loading
Loading