pax_global_header00006660000000000000000000000064150676471070014527gustar00rootroot0000000000000052 comment=c5a5bf6470c922b53418abfefe5c18a0031def23 pymill-0.14.0/000077500000000000000000000000001506764710700131175ustar00rootroot00000000000000pymill-0.14.0/.github/000077500000000000000000000000001506764710700144575ustar00rootroot00000000000000pymill-0.14.0/.github/FUNDING.yml000066400000000000000000000001461506764710700162750ustar00rootroot00000000000000# These are supported funding model platforms github: Danielhiversen custom: http://paypal.me/dahoiv pymill-0.14.0/.github/workflows/000077500000000000000000000000001506764710700165145ustar00rootroot00000000000000pymill-0.14.0/.github/workflows/code_checker.yml000066400000000000000000000031561506764710700216420ustar00rootroot00000000000000name: Code checker on: push: pull_request: schedule: - cron: "0 4 * * *" jobs: validate: runs-on: "ubuntu-latest" strategy: matrix: python-version: - "3.10" - "3.11" - "3.12" steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependency run: | pip install aiohttp PyJWT pip install ruff>=0.1.0 - name: Run ruff check run: | ruff check mill/ - name: Run ruff formatter (auto-fix) if: github.event.pull_request.head.repo.full_name == github.repository run: ruff format . - name: Check for modified files if: github.event.pull_request.head.repo.full_name == github.repository id: git-check run: echo "modified=$(if git diff-index --quiet HEAD --; then echo "false"; else echo "true"; fi)" >> $GITHUB_OUTPUT - name: Push changes if: github.event.pull_request.head.repo.full_name == github.repository && steps.git-check.outputs.modified == 'true' run: | git config --global user.name 'Daniel Hoyer' git config --global user.email 'mail@dahoiv.net' git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY git fetch origin git checkout ${{ github.head_ref || github.ref_name }} git commit -am "fixup! Format Python code with ruff" git push pymill-0.14.0/.github/workflows/python-publish.yml000066400000000000000000000015161506764710700222270ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* pymill-0.14.0/.github/workflows/ruff-format.yml000066400000000000000000000025401506764710700214700ustar00rootroot00000000000000name: Ruff Code Formatter on: push: pull_request: schedule: - cron: "0 0 * * *" jobs: format: runs-on: "ubuntu-latest" steps: - uses: actions/checkout@v4 - name: Set up Python if: github.event.pull_request.head.repo.full_name == github.repository uses: actions/setup-python@v5 with: python-version: '3.10' - name: Install ruff run: pip install ruff - name: Run ruff formatter run: find mill/ -name "*.py" -exec ruff format {} \; - name: Check for modified files if: github.event.pull_request.head.repo.full_name == github.repository id: git-check run: echo "modified=$(if git diff-index --quiet HEAD --; then echo "false"; else echo "true"; fi)" >> $GITHUB_OUTPUT - name: Push changes if: github.event.pull_request.head.repo.full_name == github.repository && steps.git-check.outputs.modified == 'true' run: | git config --global user.name 'Daniel Hoyer' git config --global user.email 'mail@dahoiv.net' git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY git fetch origin git checkout ${{ github.head_ref || github.ref_name }} git commit -am "fixup! Format Python code with ruff" git push pymill-0.14.0/.gitignore000066400000000000000000000022721506764710700151120ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ /testspymill-0.14.0/LICENSE000066400000000000000000000020661506764710700141300ustar00rootroot00000000000000MIT License Copyright (c) 2018 Daniel Høyer Iversen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pymill-0.14.0/README.md000066400000000000000000000005111506764710700143730ustar00rootroot00000000000000# pymill Python3 library for Mill heater. Control Mill heaters and get measured temperatures. [Buy me a coffee :)](http://paypal.me/dahoiv) ## Install ``` pip3 install millheater ``` The library is used as part of Home Assistant: https://github.com/home-assistant/core/blob/dev/homeassistant/components/mill/climate.py pymill-0.14.0/mill/000077500000000000000000000000001506764710700140545ustar00rootroot00000000000000pymill-0.14.0/mill/__init__.py000066400000000000000000000650321506764710700161730ustar00rootroot00000000000000"""Library to handle connection with mill.""" from __future__ import annotations import asyncio import datetime as dt import json import logging from dataclasses import dataclass, field from typing import Any import aiohttp import jwt API_ENDPOINT = "https://api.millnorwaycloud.com/" DEFAULT_TIMEOUT = 10 WINDOW_STATES = {0: "disabled", 3: "enabled_not_active", 2: "enabled_active"} # HTTP status codes HTTP_UNAUTHORIZED = 401 HTTP_TOO_MANY_REQUESTS = 429 # Time constants EARLY_MORNING_HOUR = 2 _LOGGER = logging.getLogger(__name__) LOCK = asyncio.Lock() class TooManyRequestsError(Exception): """Too many requests.""" class Mill: """Class to communicate with the Mill api.""" # pylint: disable=too-many-instance-attributes def __init__( self, username: str, password: str, timeout: int = DEFAULT_TIMEOUT, websession: aiohttp.ClientSession | None = None, user_agent: str | None = None, ) -> None: """Initialize the Mill connection.""" self.devices: dict = {} if websession is None: async def _create_session() -> aiohttp.ClientSession: return aiohttp.ClientSession() loop = asyncio.get_event_loop() self.websession = loop.run_until_complete(_create_session()) else: self.websession = websession self._ua: str | None = user_agent self._timeout = timeout self._username = username self._password = password self._user_id = None self._token = None self._token_expires = None self._refresh_token = None self._cached_data = {} self._cached_stats_data = {} async def connect(self, retry: int = 2) -> bool: """Connect to Mill.""" # pylint: disable=too-many-return-statements payload = {"login": self._username, "password": self._password} try: async with asyncio.timeout(self._timeout): resp = await self.websession.post( API_ENDPOINT + "customer/auth/sign-in", json=payload, headers=({"User-Agent": self._ua} if self._ua else None), ) except (asyncio.TimeoutError, aiohttp.ClientError): if retry < 1: _LOGGER.exception("Error connecting to Mill") return False return await self.connect(retry - 1) result = await resp.text() if "Incorrect login or password" in result: _LOGGER.error("Incorrect login or password, %s", result) return False data = json.loads(result) if not self._update_tokens(data): return False if self._user_id is not None: return True async with asyncio.timeout(self._timeout): resp = await self.websession.get( API_ENDPOINT + "customer/details", headers=self._headers, ) result = await resp.text() data = json.loads(result) if (user_id := data.get("id")) is None: _LOGGER.error("No user id") return False self._user_id = user_id return True @property def _headers(self) -> dict[str, str]: headers: dict[str, str] = {"Authorization": "Bearer " + self._token} if self._ua: headers["User-Agent"] = self._ua return headers @property def user_agent(self) -> str: return self._ua async def close_connection(self) -> None: """Close the Mill connection.""" await self.websession.close() async def refresh_token(self) -> bool: """Refresh the token.""" _LOGGER.info("Refreshing token") async with LOCK: if dt.datetime.now(dt.timezone.utc) < self._token_expires: return True headers = {"Authorization": f"Bearer {self._refresh_token}"} if self._ua: headers["User-Agent"] = self._ua try: async with asyncio.timeout(self._timeout): response = await self.websession.post( API_ENDPOINT + "/customer/auth/refresh", headers=headers, ) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.exception("Failed to refresh token") return False if response.status == HTTP_UNAUTHORIZED: return await self.connect() data = await response.json() if not self._update_tokens(data) and not await self.connect(): _LOGGER.error("Failed to refresh token") return False return True async def request( self, command: str, payload: dict[str, Any] | None = None, retry: int = 3, patch: bool = False, ) -> dict[str, Any] | None: """Request data.""" # pylint: disable=too-many-return-statements, too-many-branches if self._token is None or self._token_expires is None: _LOGGER.error("No token") return None _LOGGER.debug("Request %s %s", command, payload or "") if dt.datetime.now(dt.timezone.utc) >= self._token_expires: _LOGGER.debug("Token expired, refreshing") if not await self.refresh_token(): _LOGGER.error("Failed to refresh token") return None _LOGGER.debug("Token refreshed") url = API_ENDPOINT + command try: async with asyncio.timeout(self._timeout): if payload: if patch: resp = await self.websession.patch( url, json=payload, headers=self._headers, ) else: resp = await self.websession.post( url, json=payload, headers=self._headers, ) else: resp = await self.websession.get(url, headers=self._headers) if resp.status == HTTP_UNAUTHORIZED: _LOGGER.debug("Invalid auth token") if await self.refresh_token(): return await self.request(command, payload, retry - 1, patch=patch) _LOGGER.error("Invalid auth token") return None if resp.status == HTTP_TOO_MANY_REQUESTS: raise TooManyRequestsError(await resp.text()) _LOGGER.debug("Status %s", resp.status) resp.raise_for_status() except asyncio.TimeoutError: if retry < 1: _LOGGER.error("Timed out sending command to Mill: %s", url) return None await asyncio.sleep(max(0.5, 2 ** (3 - retry) - 0.5)) return await self.request(command, payload, retry - 1, patch=patch) except aiohttp.ClientError: _LOGGER.exception("Error sending command to Mill: %s", url) return None result = await resp.text() _LOGGER.debug("Result %s", result) return json.loads(result) async def cached_request( self, url: str, payload: dict[str, Any] | None = None, ttl: int = 20 * 60, ) -> dict[str, Any] | None: """Request data and cache.""" res, timestamp, _payload = self._cached_data.get(url + str(payload), (None, None, None)) if ( url is not None and _payload == payload and timestamp is not None and dt.datetime.now(dt.timezone.utc) - timestamp < dt.timedelta(seconds=ttl) ): return res try: res = await self.request(url, payload) if res is not None and ttl > 0: self._cached_data[url + str(payload)] = ( res, dt.datetime.now(dt.timezone.utc), payload, ) except TooManyRequestsError: if res is None: raise _LOGGER.warning("Too many requests, using cache %s", url) return res async def update_devices(self) -> list[dict[str, Any]] | None: """Request data.""" resp = await self.cached_request("houses") if resp is None: return [] homes = resp.get("ownHouses", []) tasks = [self._update_home(home) for home in homes] await asyncio.gather(*tasks) return None async def _update_home(self, home: dict[str, Any]) -> None: independent_devices_data = await self.cached_request( f"houses/{home.get('id')}/devices/independent", ttl=60, ) tasks = [] if independent_devices_data is not None: tasks.extend(self._update_device(device) for device in independent_devices_data.get("items", [])) rooms_data = await self.cached_request(f"houses/{home.get('id')}/devices") if rooms_data is not None: for room in rooms_data: if not isinstance(room, dict): _LOGGER.debug("Unexpected room data %s", room) continue tasks.append(self._update_room(room)) await asyncio.gather(*tasks) async def _update_room(self, room: dict[str, Any]) -> None: if (room_id := room.get("roomId")) is None: return room_data = await self.cached_request(f"rooms/{room_id}/devices", ttl=90) tasks = [self._update_device(device, room_data) for device in room.get("devices", [])] await asyncio.gather(*tasks) async def _update_device(self, device_data: dict[str, Any], room_data: dict[str, Any] | None = None) -> None: if device_data is None: _LOGGER.warning("No device data") return device_type = device_data.get("deviceType", {}).get("parentType", {}).get("name") _id = device_data.get("deviceId") if device_type in ("Heaters", "Sockets"): now = dt.datetime.now(dt.timezone.utc) if _id in self.devices and (now - self.devices[_id].last_fetched < dt.timedelta(seconds=15)): return device_stats = await self.fetch_yearly_stats(_id) if device_type == "Heaters": self.devices[_id] = Heater.init_from_response(device_data, room_data, device_stats) else: self.devices[_id] = Socket.init_from_response(device_data, room_data, device_stats) elif device_type in ("Sensors",): self.devices[_id] = Sensor.init_from_response(device_data) else: _LOGGER.error("Unsupported device, %s %s", device_type, device_data) return async def fetch_yearly_stats(self, device_id: str, ttl: int = 30 * 60) -> dict[str, float]: """Fetch yearly stats.""" now = dt.datetime.now(dt.timezone.utc) cache = self._cached_stats_data.get(device_id) if cache and ( (now - cache[1] > dt.timedelta(days=10)) or (now.day == 1 and now.hour < EARLY_MORNING_HOUR and now - cache[1] > dt.timedelta(hours=2)) ): self._cached_stats_data.pop(device_id) if device_id not in self._cached_stats_data: _energy_prev_month = 0 for month in range(1, now.month): _energy_prev_month += sum( item.get("value", 0) for item in (await self.fetch_stats(device_id, now.year, month, 1, "daily", ttl=0)) .get("energyUsage", {}) .get("items", []) ) self._cached_stats_data[device_id] = _energy_prev_month, now else: _energy_prev_month = cache[0] stats = await self.fetch_stats( device_id, now.year, now.month, 1, "daily", ttl=12 * 60 * 60, ) _energy_this_month = 0 for item in stats.get("energyUsage", {}).get("items", []) or []: if item["lostStatisticData"]: _date = dt.datetime.fromisoformat(item["endPeriod"]) hourly_stats = await self.fetch_stats(device_id, _date.year, _date.month, _date.day, "hourly", ttl=ttl) for _item in hourly_stats.get("energyUsage", {}).get("items", []): _energy_this_month += _item.get("value", 0) continue _energy_this_month += item.get("value", 0) return {"yearly_consumption": (_energy_this_month + _energy_prev_month)} async def fetch_historic_energy_usage(self, device_id: str, n_days: int = 4) -> dict[dt.datetime, float]: """Fetch historic energy usage.""" now = dt.datetime.now(dt.timezone.utc) res = {} n_days = max(n_days, 1) for day in range(n_days + 1): date = now - dt.timedelta(days=n_days - day) hourly_stats = await self._fetch_stats_safe(device_id, date.year, date.month, date.day, "hourly") if hourly_stats is None: break for item in hourly_stats.get("energyUsage", {}).get("items", []): res[dt.datetime.fromisoformat(item["startPeriod"]).astimezone(dt.timezone.utc)] = ( item.get("value", 0) / 1000.0 ) return res async def _fetch_stats_safe( self, device_id: str, year: int, month: int, day: int, period: str, ) -> dict[str, Any] | None: """Safely fetch stats with error handling.""" try: return await self.fetch_stats(device_id, year, month, day, period) except aiohttp.ClientResponseError: _LOGGER.warning( "Error when fetching stats for device_id=%s, year=%s, month=%s, day=%s, period=%s", device_id, year, month, day, period, ) return None # pylint: disable=too-many-arguments async def fetch_stats( self, device_id: str, year: int, month: int, day: int, period: str, ttl: int = 60 * 60, ) -> dict[str, Any]: """Fetch stats.""" try: device_stats = await self.cached_request( f"devices/{device_id}/statistics", { "period": period, "year": year, "month": month, "day": day, }, ttl=ttl, ) except TooManyRequestsError: _LOGGER.warning( "Too many requests when fetching stats for device_id=%s, year=%s, month=%s, day=%s, period=%s", device_id, year, month, day, period, ) return {} if device_stats is None: return {} return device_stats async def set_room_temperatures_by_name( self, room_name: str, sleep_temp: float | None = None, comfort_temp: float | None = None, away_temp: float | None = None, ) -> None: """Set room temps by name.""" if sleep_temp is None and comfort_temp is None and away_temp is None: _LOGGER.error("Missing input data %s", room_name) return for heater in self.devices.values(): if not isinstance(heater, Heater) or heater.room_name is None: continue if heater.room_name.lower().strip() == room_name.lower().strip(): await self.set_room_temperatures( heater.room_id, sleep_temp, comfort_temp, away_temp, ) return _LOGGER.error("Could not find a room with name %s", room_name) async def set_room_temperatures( self, room_id: str, sleep_temp: float | None = None, comfort_temp: float | None = None, away_temp: float | None = None, ) -> None: """Set room temps.""" if sleep_temp is None and comfort_temp is None and away_temp is None: return payload = {} if sleep_temp: payload["roomSleepTemperature"] = sleep_temp if away_temp: payload["roomAwayTemperature"] = away_temp if comfort_temp: payload["roomComfortTemperature"] = comfort_temp self._cached_data = {} await self.request(f"rooms/{room_id}/temperature", payload) async def fetch_heater_data(self) -> dict[str, Heater | Socket]: """Request data.""" await self.update_devices() return {key: val for key, val in self.devices.items() if isinstance(val, Heater | Socket)} async def fetch_heater_and_sensor_data(self) -> dict[str, MillDevice]: """Request data.""" await self.update_devices() return self.devices async def heater_control(self, device_id: str, power_status: bool) -> None: """Set heater temps.""" if device_id not in self.devices: _LOGGER.error("Device id %s not found", device_id) return payload = { "deviceType": self.devices[device_id].device_type, "enabled": power_status, "settings": {"operation_mode": "control_individually" if power_status > 0 else "off"}, } if await self.request(f"devices/{device_id}/settings", payload, patch=True): self._cached_data = {} self.devices[device_id].power_status = power_status if not power_status: self.devices[device_id].is_heating = False else: self.devices[device_id].is_heating = ( self.devices[device_id].set_temp > self.devices[device_id].current_temp ) self.devices[device_id].last_fetched = dt.datetime.now(dt.timezone.utc) async def max_heating_power(self, device_id: str, heating_power: float) -> None: """Max heating power.""" payload = { "deviceType": self.devices[device_id].device_type, "enabled": True, "settings": { "operation_mode": "control_individually", "max_heater_power": heating_power, }, } await self.request(f"devices/{device_id}/settings", payload, patch=True) async def set_heater_temp(self, device_id: str, set_temp: float) -> None: """Set heater temp.""" payload = { "deviceType": self.devices[device_id].device_type, "enabled": True, "settings": { "operation_mode": "control_individually", "temperature_normal": set_temp, }, } if await self.request(f"devices/{device_id}/settings", payload, patch=True): self._cached_data = {} self.devices[device_id].set_temp = set_temp self.devices[device_id].is_heating = set_temp > self.devices[device_id].current_temp self.devices[device_id].last_fetched = dt.datetime.now(dt.timezone.utc) def _update_tokens(self, data: dict[str, Any]) -> bool: """Update access and refresh tokens from API response data.""" if token := data.get("idToken"): self._token = token self._token_expires = self._get_token_expiration(token) _LOGGER.debug("Token expires at %s", self._token_expires) else: _LOGGER.error("No token") return False if refresh_token := data.get("refreshToken"): self._refresh_token = refresh_token else: _LOGGER.error("No refresh token") return False return True def _get_token_expiration(self, token: str) -> dt.datetime: """Extract expiration time from JWT token.""" try: payload = jwt.decode(token, options={"verify_signature": False}) exp_timestamp = payload.get("exp") if exp_timestamp: return dt.datetime.fromtimestamp(exp_timestamp, tz=dt.timezone.utc) except jwt.InvalidTokenError as e: _LOGGER.warning("Could not decode token expiration, using default: %s", e) return dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=10) @dataclass class MillDevice: """Mill Device.""" # pylint: disable=too-many-instance-attributes name: str | None = None device_id: str | None = None available: bool | None = None model: str | None = None report_time: int | None = None data: dict | None = None room_data: dict | None = None stats: dict | None = None @classmethod def init_from_response( cls, device_data: dict, room_data: dict | None = None, device_stats: dict | None = None, ) -> MillDevice: """Class method.""" device_type = device_data.get("deviceType") if device_type is None: model = None else: child_type = device_type.get("childType") model = None if child_type is None else child_type.get("name") last_metrics = device_data.get("lastMetrics") report_time = None if last_metrics is None else last_metrics.get("time") return cls( name=device_data.get("customName"), device_id=device_data.get("deviceId"), available=device_data.get("isConnected"), model=model, report_time=report_time, data=device_data, room_data=room_data, stats=device_stats, ) @property def device_type(self) -> str: """Return device type.""" return "unknown" @property def last_updated(self) -> dt.datetime: """Last updated.""" if self.report_time is None: return dt.datetime.fromtimestamp(0).astimezone(dt.timezone.utc) return dt.datetime.fromtimestamp(self.report_time / 1000).astimezone(dt.timezone.utc) @dataclass() class Heater(MillDevice): """Representation of heater.""" # pylint: disable=too-many-instance-attributes control_signal: float | None = None current_temp: float | None = None current_power: float | None = None day_consumption: float | None = None home_id: str | None = None independent_device: bool | None = None is_heating: bool | None = None last_fetched: dt.datetime = field(default_factory=lambda: dt.datetime.fromtimestamp(0, tz=dt.timezone.utc)) open_window: str | None = None power_status: bool | None = None room_avg_temp: float | None = None room_id: str | None = None room_name: str | None = None set_temp: float | None = None tibber_control: bool | None = None total_consumption: float | None = None year_consumption: float | None = None floor_temperature: float | None = None def __post_init__(self) -> None: """Post init.""" if self.data: last_metrics = self.data.get("lastMetrics", {}) device_settings_desired = self.data.get("deviceSettings", {}).get("desired", {}) if last_metrics is not None: self.current_temp = last_metrics.get("temperatureAmbient") self.is_heating = last_metrics.get("heaterFlag", 0) > 0 self.power_status = last_metrics.get("powerStatus", 0) > 0 self.set_temp = device_settings_desired.get("temperature_normal", last_metrics.get("temperature")) self.open_window = WINDOW_STATES.get(last_metrics.get("openWindowsStatus")) self.control_signal = last_metrics.get("controlSignal") self.current_power = last_metrics.get("currentPower") self.total_consumption = last_metrics.get("energyUsage") self.floor_temperature = last_metrics.get("floorTemperature") else: _LOGGER.warning("No last metrics for device %s", self.device_id) self.day_consumption = self.data.get("energyUsageForCurrentDay", 0) / 1000.0 if self.stats: self.year_consumption = self.stats.get("yearly_consumption", 0) / 1000.0 if self.room_data: self.tibber_control = self.room_data.get("controlSource", {}).get("tibber") == 1 self.home_id = self.room_data.get("houseId") self.room_id = self.room_data.get("id") self.room_name = self.room_data.get("name") self.room_avg_temp = self.room_data.get("averageTemperature") self.independent_device = False else: self.independent_device = True @property def device_type(self) -> str: """Return device type.""" return "Heaters" @dataclass() class Socket(Heater): """Representation of socket.""" humidity: float | None = None def __post_init__(self) -> None: """Post init.""" if self.data: last_metrics = self.data.get("lastMetrics", {}) self.humidity = last_metrics.get("humidity") super().__post_init__() @property def device_type(self) -> str: """Return device type.""" return "Sockets" @dataclass() class Sensor(MillDevice): """Representation of sensor.""" # pylint: disable=too-many-instance-attributes current_temp: float | None = None humidity: float | None = None tvoc: float | None = None eco2: float | None = None battery: float | None = None pm1: float | None = None pm25: float | None = None pm10: float | None = None particles: float | None = None filter_state: str | None = None def __post_init__(self) -> None: """Post init.""" if self.data: last_metrics = self.data.get("lastMetrics", {}) self.current_temp = last_metrics.get("temperature") self.humidity = last_metrics.get("humidity") self.tvoc = last_metrics.get("tvoc") self.eco2 = last_metrics.get("eco2") self.battery = last_metrics.get("batteryPercentage") self.pm1 = last_metrics.get("massPm_10") self.pm25 = last_metrics.get("massPm_25") self.pm10 = last_metrics.get("massPm_100") if self.pm1 is not None and self.pm25 is not None and self.pm10 is not None: self.particles = round((float(self.pm1) + float(self.pm25) + float(self.pm10)) / 3, 2) self.filter_state = self.data.get("deviceSettings", {}).get("reported", {}).get("filter_state") @property def device_type(self) -> str: """Return device type.""" return "Sensors" pymill-0.14.0/mill/consts.py000066400000000000000000000000671506764710700157420ustar00rootroot00000000000000"""Constants used by pymill""" __version__ = "0.14.0" pymill-0.14.0/pyproject.toml000066400000000000000000000003021506764710700160260ustar00rootroot00000000000000[tool.ruff] line-length = 120 ignore = ["D", "EM", "FBT", "PLR0913", "S101", "S105", "TRY", "ANN101", "ANN102", "C901", "PLR0911", "F541", "COM812", "ARG002", "F841", "PLR0912"] select = ["ALL"]pymill-0.14.0/setup.py000066400000000000000000000016341506764710700146350ustar00rootroot00000000000000from setuptools import setup version_ns = {} with open("mill/consts.py", "r", encoding="utf-8") as f: exec(f.read(), version_ns) __version__ = version_ns["__version__"] setup( name="millheater", packages=["mill"], install_requires=["aiohttp>=3.7.4,<4", "PyJWT>=2"], version=__version__, description="A python3 library to communicate with Mill", long_description="A python3 library to communicate with Mill", python_requires=">=3.10", author="Daniel Hjelseth Hoyer", author_email="mail@dahoiv.net", url="https://github.com/Danielhiversen/pymill", license="MIT", classifiers=[ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Home Automation", "Topic :: Software Development :: Libraries :: Python Modules", ], )