From 72c3cfdde96944a931d7c6fa966da5bed2da965a Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Fri, 28 Jun 2024 09:43:02 +0200 Subject: [PATCH] sync --- chargeamps/local.py | 134 ++++++++++++++++++++++++++++++++++++++++++- chargeamps/models.py | 33 +++++++---- poetry.lock | 74 +++++++++++------------- pyproject.toml | 6 +- 4 files changed, 191 insertions(+), 56 deletions(-) diff --git a/chargeamps/local.py b/chargeamps/local.py index fd80181..38899f4 100644 --- a/chargeamps/local.py +++ b/chargeamps/local.py @@ -1,7 +1,135 @@ """Charge-Amps Local API Client""" -from .base import ChargeAmpsClient +import logging +import uuid +from typing import Self +from urllib.parse import urlparse +from pydantic import BaseModel, ConfigDict -class ChargeAmpsLocalClient(ChargeAmpsClient): - pass +from .models import ( + ChargePointConnectorMode, + ChargePointConnectorSettings, + ChargePointType, +) + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 8080 + + +class LocalChargePoint(BaseModel): + name: str + host: str + port: int + pin: str + + model_config = ConfigDict(frozen=True) + + @classmethod + def from_url(cls, url: str, pin: str, name: str | None = None) -> Self: + res = urlparse(url) + if res.scheme != "ws": + raise ValueError("Only ws URLs supported") + return cls( + name=name or str(uuid.uuid4()), + host=res.hostname or DEFAULT_HOST, + port=res.port or DEFAULT_PORT, + pin=pin, + ) + + +class LocalChargePointStatus(BaseModel): + chargepoint: LocalChargePoint + type: ChargePointType | None = None + charge_point_id: str | None = None + connector_settings: list[ChargePointConnectorSettings] | None = None + dimmer: int = 0 + light: bool = False + + @staticmethod + def get_default_halo_connectors() -> list[ChargePointConnectorSettings]: + return [ + ChargePointConnectorSettings( + charge_point_id="", + connector_id=0, + mode=str(ChargePointConnectorMode.OFF), + rfid_lock=False, + cable_lock=False, + max_current=None, + ), + ChargePointConnectorSettings( + charge_point_id="", + connector_id=1, + mode=str(ChargePointConnectorMode.OFF), + rfid_lock=False, + cable_lock=False, + max_current=None, + ), + ] + + @staticmethod + def get_default_aura_connectors() -> list[ChargePointConnectorSettings]: + return [ + ChargePointConnectorSettings( + charge_point_id="", + connector_id=0, + mode=str(ChargePointConnectorMode.OFF), + rfid_lock=False, + cable_lock=False, + max_current=None, + ), + ChargePointConnectorSettings( + charge_point_id="", + connector_id=1, + mode=str(ChargePointConnectorMode.OFF), + rfid_lock=False, + cable_lock=False, + max_current=None, + ), + ] + + def update_settings(self, message: str) -> None: + parameters = [int(x) for x in message.split(",")] + + match parameters[0]: + case 1: + self.type = ChargePointType.HALO + if self.connector_settings is None: + self.connector_settings = self.get_default_halo_connectors() + assert len(parameters) == 8 + self.connector_settings[0].mode = "On" if parameters[1] else "Off" + self.connector_settings[1].mode = "On" if parameters[2] else "Off" + self.connector_settings[0].rfid_lock = bool(parameters[3]) + self.light = bool(parameters[4]) + self.connector_settings[0].max_current = parameters[5] // 10 + self.dimmer = bool(parameters[6]) + assert parameters[7] == 4 + + case 101: + self.type = ChargePointType.AURA + if self.connector_settings is None: + self.connector_settings = self.get_default_aura_connectors() + assert len(parameters) == 12 + self.connector_settings[0].mode = "On" if parameters[1] else "Off" + self.connector_settings[0].cable_lock = bool(parameters[2]) + self.connector_settings[0].rfid_lock = bool(parameters[3]) + self.connector_settings[0].max_current = parameters[4] // 10 + assert parameters[5] == 4 + self.connector_settings[1].mode = "On" if parameters[6] else "Off" + self.connector_settings[1].cable_lock = bool(parameters[7]) + self.connector_settings[1].rfid_lock = bool(parameters[8]) + self.connector_settings[1].max_current = parameters[9] // 10 + assert parameters[10] == 4 + self.dimmer = parameters[11] + case _: + logging.warning("Unknown settings preamble: %d", parameters[0]) + + def update_status(self, message: str) -> None: + pass + + +class ChargeAmpsLocalClient: # (ChargeAmpsClient): + def __init__(self, chargepoints: list[LocalChargePoint]) -> None: + self.chargepoints: list[LocalChargePointStatus] = [ + LocalChargePointStatus(chargepoint=cp) for cp in chargepoints + ] diff --git a/chargeamps/models.py b/chargeamps/models.py index 1382cc7..f1efeef 100644 --- a/chargeamps/models.py +++ b/chargeamps/models.py @@ -1,6 +1,7 @@ """Data models for ChargeAmps API""" from datetime import datetime +from enum import Enum from typing import Optional from pydantic import BaseModel, ConfigDict, PlainSerializer @@ -15,22 +16,32 @@ ] -class FrozenBaseSchema(BaseModel): +class ChargePointType(Enum): + HALO = 1 + AURA = 2 + + +class ChargePointConnectorMode(Enum): + OFF = "Off" + ON = "On" + SCHEDULE = "Schedule" + + +class BaseSchema(BaseModel): model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, from_attributes=True, - frozen=True, ) -class ChargePointConnector(FrozenBaseSchema): +class ChargePointConnector(BaseSchema): charge_point_id: str connector_id: int type: str -class ChargePoint(FrozenBaseSchema): +class ChargePoint(BaseSchema): id: str name: str password: str @@ -41,13 +52,13 @@ class ChargePoint(FrozenBaseSchema): connectors: list[ChargePointConnector] -class ChargePointMeasurement(FrozenBaseSchema): +class ChargePointMeasurement(BaseSchema): phase: str current: float voltage: float -class ChargePointConnectorStatus(FrozenBaseSchema): +class ChargePointConnectorStatus(BaseSchema): charge_point_id: str connector_id: int total_consumption_kwh: float @@ -58,19 +69,19 @@ class ChargePointConnectorStatus(FrozenBaseSchema): session_id: Optional[int] = None -class ChargePointStatus(FrozenBaseSchema): +class ChargePointStatus(BaseSchema): id: str status: str connector_statuses: list[ChargePointConnectorStatus] -class ChargePointSettings(FrozenBaseSchema): +class ChargePointSettings(BaseSchema): id: str dimmer: str down_light: bool -class ChargePointConnectorSettings(FrozenBaseSchema): +class ChargePointConnectorSettings(BaseSchema): charge_point_id: str connector_id: int mode: str @@ -79,7 +90,7 @@ class ChargePointConnectorSettings(FrozenBaseSchema): max_current: Optional[float] = None -class ChargingSession(FrozenBaseSchema): +class ChargingSession(BaseSchema): id: int charge_point_id: str connector_id: int @@ -89,7 +100,7 @@ class ChargingSession(FrozenBaseSchema): end_time: Optional[CustomDateTime] = None -class StartAuth(FrozenBaseSchema): +class StartAuth(BaseSchema): rfid_length: int rfid_format: str rfid: str diff --git a/poetry.lock b/poetry.lock index 8733efa..5b7ab19 100644 --- a/poetry.lock +++ b/poetry.lock @@ -87,7 +87,6 @@ files = [ [package.dependencies] aiosignal = ">=1.1.2" -async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" @@ -96,6 +95,20 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["Brotli", "aiodns", "brotlicffi"] +[[package]] +name = "aiomqtt" +version = "2.1.0" +description = "The idiomatic asyncio MQTT client, wrapped around paho-mqtt" +optional = true +python-versions = "<4.0,>=3.8" +files = [ + {file = "aiomqtt-2.1.0-py3-none-any.whl", hash = "sha256:ba62afe18e57b01ef28240f192b73cd54d7e42d827a47697997d3e6252a130d1"}, + {file = "aiomqtt-2.1.0.tar.gz", hash = "sha256:8501c373c00fa7074a990a951285f31340c29b6541aea15e040da9ea33d79719"}, +] + +[package.dependencies] +paho-mqtt = ">=2.0.0,<3.0.0" + [[package]] name = "aiosignal" version = "1.3.1" @@ -140,17 +153,6 @@ types-python-dateutil = ">=2.8.10" doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] -[[package]] -name = "async-timeout" -version = "4.0.3" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.7" -files = [ - {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, - {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, -] - [[package]] name = "attrs" version = "23.2.0" @@ -244,20 +246,6 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "exceptiongroup" -version = "1.2.1" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, - {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, -] - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "frozenlist" version = "1.4.1" @@ -490,6 +478,20 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "paho-mqtt" +version = "2.1.0" +description = "MQTT version 5.0/3.1.1 client class" +optional = true +python-versions = ">=3.7" +files = [ + {file = "paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee"}, + {file = "paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834"}, +] + +[package.extras] +proxy = ["pysocks"] + [[package]] name = "pluggy" version = "1.5.0" @@ -645,11 +647,9 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -736,17 +736,6 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - [[package]] name = "types-python-dateutil" version = "2.9.0.20240316" @@ -872,7 +861,10 @@ files = [ idna = ">=2.0" multidict = ">=4.0" +[extras] +mqtt = ["aiomqtt"] + [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "7d1d7d27e4f94ec46b625cc1858cf3b7ca07a57ec6b83fc2b122f396898f381d" +python-versions = "^3.11" +content-hash = "5b4b2f804d83bc1e5a3317633bd29a5f5b1cd182d026e42a61e06092dd05b67d" diff --git a/pyproject.toml b/pyproject.toml index ad2b506..5f93937 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,12 +27,16 @@ classifiers = [ chargeamps = "chargeamps.cli:main" [tool.poetry.dependencies] -python = "^3.9" +python = "^3.11" aiohttp = "^3.9.5" PyJWT = "^2.1.0" isoduration = "^20.11.0" ciso8601 = "^2.2.0" pydantic = "^2.7.4" +aiomqtt = {version = "^2.1.0", optional = true} + +[tool.poetry.extras] +mqtt = ["aiomqtt"] [tool.poetry.group.dev.dependencies] pytest = "^7.4.3"