pax_global_header00006660000000000000000000000064151165114360014515gustar00rootroot0000000000000052 comment=56e48967ac1a7f96ced9a366f0fca5c8894b8070 Danielhiversen-pymill-56e4896/000077500000000000000000000000001511651143600162555ustar00rootroot00000000000000Danielhiversen-pymill-56e4896/.github/000077500000000000000000000000001511651143600176155ustar00rootroot00000000000000Danielhiversen-pymill-56e4896/.github/FUNDING.yml000066400000000000000000000001461511651143600214330ustar00rootroot00000000000000# These are supported funding model platforms github: Danielhiversen custom: http://paypal.me/dahoiv Danielhiversen-pymill-56e4896/.github/workflows/000077500000000000000000000000001511651143600216525ustar00rootroot00000000000000Danielhiversen-pymill-56e4896/.github/workflows/code_checker.yml000066400000000000000000000031561511651143600250000ustar00rootroot00000000000000name: 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 Danielhiversen-pymill-56e4896/.github/workflows/python-publish.yml000066400000000000000000000015161511651143600253650ustar00rootroot00000000000000# 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/* Danielhiversen-pymill-56e4896/.github/workflows/ruff-format.yml000066400000000000000000000025401511651143600246260ustar00rootroot00000000000000name: 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 Danielhiversen-pymill-56e4896/.gitignore000066400000000000000000000022721511651143600202500ustar00rootroot00000000000000# 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/ /testsDanielhiversen-pymill-56e4896/LICENSE000066400000000000000000000020661511651143600172660ustar00rootroot00000000000000MIT 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. Danielhiversen-pymill-56e4896/README.md000066400000000000000000000005111511651143600175310ustar00rootroot00000000000000# 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 Danielhiversen-pymill-56e4896/mill/000077500000000000000000000000001511651143600172125ustar00rootroot00000000000000Danielhiversen-pymill-56e4896/mill/__init__.py000066400000000000000000001075371511651143600213400ustar00rootroot00000000000000"""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 Configuration API_ENDPOINT = "https://api.millnorwaycloud.com/" DEFAULT_TIMEOUT = 10 # Device States 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 DEFAULT_CACHE_TTL = 20 * 60 # 20 minutes STATS_CACHE_TTL = 30 * 60 # 30 minutes DEVICE_UPDATE_INTERVAL = 15 # seconds TOKEN_DEFAULT_LIFETIME = 10 # minutes # Device types DEVICE_TYPE_HEATERS = "Heaters" DEVICE_TYPE_SOCKETS = "Sockets" DEVICE_TYPE_SENSORS = "Sensors" # Operation modes MODE_INDIVIDUAL = "control_individually" MODE_WEEKLY = "weekly_program" MODE_OFF = "off" # Lock states LOCK_CHILD = "child_lock" LOCK_NONE = "no_lock" LOCK_COMMERCIAL = "commercial" _LOGGER = logging.getLogger(__name__) LOCK = asyncio.Lock() class TooManyRequestsError(Exception): """Too many requests.""" @dataclass class TokenManager: """Manages authentication tokens and their expiration.""" access_token: str | None = None refresh_token: str | None = None expires_at: dt.datetime | None = None def is_expired(self) -> bool: """Check if the access token is expired.""" if not self.expires_at: return True return dt.datetime.now(dt.timezone.utc) >= self.expires_at def update(self, data: dict[str, Any]) -> bool: """Update tokens from API response.""" access_token = data.get("idToken") if not access_token: _LOGGER.error("No token in response") return False refresh_token = data.get("refreshToken") if not refresh_token: _LOGGER.error("No refresh token in response") return False self.access_token = access_token self.refresh_token = refresh_token expires_at = None try: payload = jwt.decode(access_token, options={"verify_signature": False}) exp_timestamp = payload.get("exp") if exp_timestamp: expires_at = dt.datetime.fromtimestamp(exp_timestamp, tz=dt.timezone.utc) except jwt.InvalidTokenError as e: _LOGGER.warning("Could not decode token expiration: %s", e) if not expires_at: expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=TOKEN_DEFAULT_LIFETIME) self.expires_at = expires_at _LOGGER.debug("Token expires at %s", self.expires_at) return True @dataclass class CacheEntry: """Represents a cached value with timestamp.""" value: dict[str, Any] timestamp: dt.datetime payload: dict[str, Any] | None = None def is_valid(self, ttl: int, payload: dict[str, Any] | None = None) -> bool: """Check if cache entry is still valid.""" if payload != self.payload: return False if ttl <= 0: return True age = dt.datetime.now(dt.timezone.utc) - self.timestamp return age < dt.timedelta(seconds=ttl) class CacheManager: """Manages request caching with TTL.""" def __init__(self) -> None: """Initialize cache manager.""" self._cache: dict[str, CacheEntry] = {} def get(self, key: str, ttl: int, payload: dict[str, Any] | None = None) -> dict[str, Any] | None: """Get cached value if valid.""" entry = self._cache.get(key) if entry and entry.is_valid(ttl, payload): return entry.value return None def set(self, key: str, value: dict[str, Any], payload: dict[str, Any] | None = None) -> None: """Store value in cache.""" self._cache[key] = CacheEntry( value=value, timestamp=dt.datetime.now(dt.timezone.utc), payload=payload, ) def clear(self) -> None: """Clear all cached data.""" self._cache.clear() def remove(self, key: str) -> None: """Remove specific cache entry.""" self._cache.pop(key, None) 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 = {} self.websession = websession or aiohttp.ClientSession() self._ua = user_agent self._timeout = timeout self._username = username self._password = password self._user_id: str | None = None self._token_manager = TokenManager() self._cache = CacheManager() self._stats_cache: dict[str, tuple[float, dt.datetime]] = {} async def connect(self, retry: int = 2) -> bool: """Connect to Mill.""" if not await self._authenticate(retry): return False if self._user_id is not None: return True return await self._fetch_user_id() async def _authenticate(self, retry: int) -> bool: """Authenticate with Mill API.""" payload = {"login": self._username, "password": self._password} try: async with asyncio.timeout(self._timeout): resp = await self.websession.post( f"{API_ENDPOINT}customer/auth/sign-in", json=payload, headers=self._build_headers(), ) except (asyncio.TimeoutError, aiohttp.ClientError): if retry < 1: _LOGGER.exception("Error connecting to Mill") return False return await self._authenticate(retry - 1) result = await resp.text() if "Incorrect login or password" in result: _LOGGER.error("Incorrect login or password") return False try: data = json.loads(result) except json.JSONDecodeError: _LOGGER.error("Invalid JSON response during authentication") return False return self._token_manager.update(data) async def _fetch_user_id(self) -> bool: """Fetch and store user ID.""" try: async with asyncio.timeout(self._timeout): resp = await self.websession.get( f"{API_ENDPOINT}customer/details", headers=self._build_headers(include_auth=True), ) data = await resp.json() if user_id := data.get("id"): self._user_id = user_id return True _LOGGER.error("No user id in response") return False except (asyncio.TimeoutError, aiohttp.ClientError, json.JSONDecodeError): _LOGGER.exception("Error fetching user ID") return False def _build_headers(self, include_auth: bool = False) -> dict[str, str]: """Build request headers.""" headers = {} if include_auth and self._token_manager.access_token: headers["Authorization"] = f"Bearer {self._token_manager.access_token}" if self._ua: headers["User-Agent"] = self._ua return headers @property def user_agent(self) -> str | None: """Get user agent string.""" 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 access token.""" _LOGGER.info("Refreshing token") async with LOCK: if not self._token_manager.is_expired(): return True if not self._token_manager.refresh_token: _LOGGER.error("No refresh token available") return await self.connect() headers = { "Authorization": f"Bearer {self._token_manager.refresh_token}", } if self._ua: headers["User-Agent"] = self._ua try: async with asyncio.timeout(self._timeout): response = await self.websession.post( f"{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() try: data = await response.json() except json.JSONDecodeError: _LOGGER.error("Invalid JSON response during token refresh") return await self.connect() if not self._token_manager.update(data): return await self.connect() return True async def request( self, command: str, payload: dict[str, Any] | None = None, retry: int = 3, patch: bool = False, ) -> dict[str, Any] | None: """Execute API request with automatic token refresh and retry logic.""" if not self._token_manager.access_token: _LOGGER.error("No access token available") return None _LOGGER.debug("Request %s %s", command, payload or "") if self._token_manager.is_expired(): _LOGGER.debug("Token expired, refreshing") if not await self.refresh_token(): _LOGGER.error("Failed to refresh token") return None url = f"{API_ENDPOINT}{command}" try: return await self._execute_request(url, payload, patch, retry, command) except asyncio.TimeoutError: if retry < 1: _LOGGER.error("Timed out sending command to Mill: %s", url) return None backoff_time = max(0.5, 2 ** (3 - retry) - 0.5) await asyncio.sleep(backoff_time) return await self.request(command, payload, retry - 1, patch=patch) except aiohttp.ClientError: _LOGGER.exception("Error sending command to Mill: %s", url) return None async def _execute_request( self, url: str, payload: dict[str, Any] | None, patch: bool, retry: int, command: str, ) -> dict[str, Any] | None: """Execute the actual HTTP request.""" async with asyncio.timeout(self._timeout): headers = self._build_headers(include_auth=True) if not payload: resp = await self.websession.get(url, headers=headers) elif patch: resp = await self.websession.patch(url, json=payload, headers=headers) else: resp = await self.websession.post(url, json=payload, headers=headers) if resp.status == HTTP_UNAUTHORIZED: _LOGGER.debug("Invalid auth token, attempting refresh") if await self.refresh_token(): return await self.request(command, payload, retry - 1, patch=patch) _LOGGER.error("Invalid auth token, refresh failed") return None if resp.status == HTTP_TOO_MANY_REQUESTS: raise TooManyRequestsError(await resp.text()) _LOGGER.debug("Status %s", resp.status) resp.raise_for_status() 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 = DEFAULT_CACHE_TTL, ) -> dict[str, Any] | None: """Request data with caching support.""" cache_key = f"{url}:{json.dumps(payload, sort_keys=True) if payload else ''}" if cached := self._cache.get(cache_key, ttl, payload): return cached try: result = await self.request(url, payload) if result is not None and ttl > 0: self._cache.set(cache_key, result, payload) return result except TooManyRequestsError: if cached := self._cache.get(cache_key, ttl=0, payload=payload): _LOGGER.warning("Too many requests, using stale cache for %s", url) return cached raise async def update_devices(self) -> None: """Update all devices from all houses.""" resp = await self.cached_request("houses") if not resp: return homes = resp.get("ownHouses", []) for home in homes: home_id = home.get("id") if not home_id: continue tasks = [] independent_devices_data = await self.cached_request( f"houses/{home_id}/devices/independent", ttl=60, ) if independent_devices_data: tasks.extend(self._update_device(device) for device in independent_devices_data.get("items", [])) rooms_data = await self.cached_request(f"houses/{home_id}/devices") if rooms_data: for room in rooms_data: if not isinstance(room, dict): _LOGGER.debug("Unexpected room data: %s", room) continue room_id = room.get("roomId") if not room_id: continue room_data = await self.cached_request(f"rooms/{room_id}/devices", ttl=90) tasks.extend(self._update_device(device, room_data) for device in room.get("devices", [])) if tasks: await asyncio.gather(*tasks) async def _update_device(self, device_data: dict[str, Any], room_data: dict[str, Any] | None = None) -> None: """Update a single device from API data.""" if not device_data: _LOGGER.warning("No device data") return device_type = device_data.get("deviceType", {}).get("parentType", {}).get("name") device_id = device_data.get("deviceId") if not device_id: _LOGGER.warning("Device has no ID") return if device_type in (DEVICE_TYPE_HEATERS, DEVICE_TYPE_SOCKETS): if device_id in self.devices: time_since_update = dt.datetime.now(dt.timezone.utc) - self.devices[device_id].last_fetched if time_since_update < dt.timedelta(seconds=DEVICE_UPDATE_INTERVAL): return device_stats = await self.fetch_yearly_stats(device_id) if device_type == DEVICE_TYPE_HEATERS: self.devices[device_id] = Heater.init_from_response(device_data, room_data, device_stats) else: self.devices[device_id] = Socket.init_from_response(device_data, room_data, device_stats) elif device_type == DEVICE_TYPE_SENSORS: self.devices[device_id] = Sensor.init_from_response(device_data) else: _LOGGER.error("Unsupported device type: %s", device_type) async def fetch_yearly_stats(self, device_id: str, ttl: int = STATS_CACHE_TTL) -> dict[str, float]: """Fetch yearly energy consumption statistics.""" now = dt.datetime.now(dt.timezone.utc) # Remove stale cache entries if device_id in self._stats_cache: _, timestamp = self._stats_cache[device_id] is_old = now - timestamp > dt.timedelta(days=10) is_new_month = now.day == 1 and now.hour < EARLY_MORNING_HOUR and now - timestamp > dt.timedelta(hours=2) if is_old or is_new_month: self._stats_cache.pop(device_id) if device_id in self._stats_cache: prev_months_energy = self._stats_cache[device_id][0] else: prev_months_energy = 0.0 for month in range(1, now.month): stats = await self.fetch_stats(device_id, now.year, month, 1, "daily", ttl=0) prev_months_energy += sum( item.get("value", 0) for item in stats.get("energyUsage", {}).get("items", []) ) self._stats_cache[device_id] = (prev_months_energy, now) stats = await self.fetch_stats(device_id, now.year, now.month, 1, "daily", ttl=12 * 60 * 60) current_month_energy = 0.0 for item in stats.get("energyUsage", {}).get("items", []) or []: if item.get("lostStatisticData"): # Recover lost daily statistics using hourly data date = dt.datetime.fromisoformat(item["endPeriod"]) hourly_stats = await self.fetch_stats(device_id, date.year, date.month, date.day, "hourly", ttl=ttl) current_month_energy += sum( _item.get("value", 0) for _item in hourly_stats.get("energyUsage", {}).get("items", []) ) else: current_month_energy += item.get("value", 0) return {"yearly_consumption": prev_months_energy + current_month_energy} async def fetch_historic_energy_usage(self, device_id: str, n_days: int = 4) -> dict[dt.datetime, float]: """Fetch historic hourly energy usage for the last n_days.""" now = dt.datetime.now(dt.timezone.utc) n_days = max(n_days, 1) result = {} for day in range(n_days + 1): date = now - dt.timedelta(days=n_days - day) try: hourly_stats = await self.fetch_stats(device_id, date.year, date.month, date.day, "hourly") except aiohttp.ClientResponseError: _LOGGER.warning( "Error when fetching stats for device_id=%s, year=%s, month=%s, day=%s, period=%s", device_id, date.year, date.month, date.day, "hourly", ) hourly_stats = None if hourly_stats is None: break for item in hourly_stats.get("energyUsage", {}).get("items", []): timestamp = dt.datetime.fromisoformat(item["startPeriod"]).astimezone(dt.timezone.utc) result[timestamp] = item.get("value", 0) / 1000.0 return result # 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 temperature settings by room name.""" if not any([sleep_temp, comfort_temp, away_temp]): _LOGGER.error("No temperature values provided for room %s", room_name) return normalized_room_name = room_name.lower().strip() for heater in self.devices.values(): if not isinstance(heater, Heater) or not heater.room_name: continue if heater.room_name.lower().strip() == normalized_room_name: 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 temperature settings.""" if not any([sleep_temp, comfort_temp, away_temp]): return payload = {} if sleep_temp: payload["roomSleepTemperature"] = sleep_temp if away_temp: payload["roomAwayTemperature"] = away_temp if comfort_temp: payload["roomComfortTemperature"] = comfort_temp self._cache.clear() await self.request(f"rooms/{room_id}/temperature", payload) async def fetch_heater_data(self) -> dict[str, Heater | Socket]: """Fetch all heater and socket devices.""" 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]: """Fetch all devices including heaters, sockets, and sensors.""" await self.update_devices() return self.devices async def heater_control(self, device_id: str, power_status: bool) -> None: """Control heater power status.""" device = self.devices.get(device_id) if not device: _LOGGER.error("Device id %s not found", device_id) return if not isinstance(device, Heater): _LOGGER.error("Device %s is not a heater", device_id) return operation_mode = MODE_INDIVIDUAL if power_status else MODE_OFF payload: dict[str, Any] = { "deviceType": device.device_type, "enabled": True, "settings": { "operation_mode": operation_mode }, } if await self.request(f"devices/{device_id}/settings", payload, patch=True): self._cache.clear() self.devices[device_id].power_status = power_status self.devices[device_id].is_heating = ( power_status and device.set_temp is not None and device.current_temp is not None and device.set_temp > device.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: """Set maximum heating power.""" payload = { "deviceType": self.devices[device_id].device_type, "enabled": True, "settings": { "operation_mode": MODE_INDIVIDUAL, "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 target temperature.""" payload = { "deviceType": self.devices[device_id].device_type, "enabled": True, "settings": { "operation_mode": MODE_INDIVIDUAL, "temperature_normal": set_temp, }, } if await self.request(f"devices/{device_id}/settings", payload, patch=True): self._cache.clear() device = self.devices[device_id] device.set_temp = set_temp device.is_heating = set_temp > device.current_temp device.last_fetched = dt.datetime.now(dt.timezone.utc) async def _patch_device_settings(self, device_id: str, settings: dict[str, Any]) -> bool: """PATCH /devices/{id}/settings for heaters and sockets.""" device = self.devices.get(device_id) if not device or not isinstance(device, (Heater, Socket)): _LOGGER.error("Device id %s not found or unsupported", device_id) return False payload: dict[str, Any] = { "deviceType": device.device_type, "enabled": bool(device.power_status), "settings": settings, } resp = await self.request(f"devices/{device_id}/settings", payload, patch=True) if resp is None: _LOGGER.error("Failed to patch settings for %s", device_id) return False self._cache.clear() device.last_fetched = dt.datetime.now(dt.timezone.utc) return True async def set_individual_control(self, device_id: str, enabled: bool) -> bool: """Switch manual/individual control on or off.""" mode = MODE_INDIVIDUAL if enabled else MODE_WEEKLY return await self._patch_device_settings(device_id, {"operation_mode": mode}) async def set_child_lock(self, device_id: str, enabled: bool) -> bool: """Toggle child lock on or off.""" status = LOCK_CHILD if enabled else LOCK_NONE return await self._patch_device_settings(device_id, {"lock_status": status}) async def set_commercial_lock(self, device_id: str, enabled: bool) -> bool: """Switch commercial lock on or off.""" _LOGGER.debug("Setting commercial lock to %s for %s", enabled, device_id) status = LOCK_COMMERCIAL if enabled else LOCK_NONE return await self._patch_device_settings(device_id, {"lock_status": status}) async def set_open_window(self, device_id: str, enabled: bool) -> bool: """Toggle open-window detection on or off.""" return await self._patch_device_settings(device_id, {"open_window": {"enabled": enabled}}) async def set_regulator_type(self, device_id: str, regulator_type: str) -> bool: """Set the regulator type (e.g., "pid", "hysteresis_or_slow_pid").""" _LOGGER.debug("Setting regulator type to %s for %s", regulator_type, device_id) return await self._patch_device_settings(device_id, {"regulator_type": regulator_type}) async def set_predictive_heating(self, device_id: str, enabled: bool) -> bool: """Enable or disable predictive heating.""" _LOGGER.debug("Setting predictive heating to %s for %s", enabled, device_id) mode = "advanced" if enabled else "off" return await self._patch_device_settings( device_id, {"predictive_heating_type": mode} ) async def set_night_saving(self, device_id: str, enabled: bool) -> bool: """Enable or disable night saving mode.""" _LOGGER.debug("Setting night saving to %s for %s", enabled, device_id) return await self._patch_device_settings( device_id, {"night_saving_mode_active": enabled} ) async def set_frost_protection(self, device_id: str, enabled: bool) -> bool: """Enable or disable frost protection mode.""" _LOGGER.debug("Setting frost protection to %s for %s", enabled, device_id) return await self._patch_device_settings( device_id, {"frost_protection_active": enabled} ) async def set_cooling_mode(self, device_id: str, enabled: bool) -> bool: """Enable or disable cooling mode for sockets.""" _LOGGER.debug("Setting cooling mode to %s for %s", enabled, device_id) mode = "cooling" if enabled else None return await self._patch_device_settings( device_id, {"additional_socket_mode": mode} ) @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: """Initialize device from API response data.""" # Extract device model from nested device type data device_type = device_data.get("deviceType") child_type = device_type.get("childType") if device_type else None model = child_type.get("name") if child_type else None # Extract report time from device metrics last_metrics = device_data.get("lastMetrics") report_time = last_metrics.get("time") if last_metrics else None 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 regulator_type: str | None = None predictive_heating: bool | None = None commercial_lock: bool | None = None night_saving: bool | None = None frost_protection: bool | None = None def __post_init__(self) -> None: """Initialize heater from device data.""" if self.data: self._populate_from_device_data() if self.stats: self.year_consumption = self.stats.get("yearly_consumption", 0) / 1000.0 if self.room_data: self._populate_from_room_data() else: self.independent_device = True def _populate_from_device_data(self) -> None: """Populate heater attributes from device data.""" device_settings = self.data.get("deviceSettings", {}) device_settings_reported = device_settings.get("reported", {}) device_settings_desired = device_settings.get("desired", {}) self.regulator_type = device_settings_reported.get("regulator_type") predictive_mode = device_settings_reported.get("predictive_heating_type") self.predictive_heating = None if predictive_mode is None else predictive_mode == "advanced" night_saving_mode = device_settings_reported.get("night_saving_mode_active") self.night_saving = None if night_saving_mode is None else bool(night_saving_mode) lock_status = device_settings_reported.get("lock_status") if lock_status is not None: self.commercial_lock = lock_status == LOCK_COMMERCIAL frost_protection = device_settings_reported.get("frost_protection_active") self.frost_protection = None if frost_protection is None else bool(frost_protection) last_metrics = self.data.get("lastMetrics", {}) if not last_metrics: _LOGGER.warning("No last metrics for device %s", self.device_id) return 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 = last_metrics.get("temperature") if self.set_temp is None: self.set_temp = device_settings_desired.get("temperature_normal") 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") self.day_consumption = self.data.get("energyUsageForCurrentDay", 0) / 1000.0 def _populate_from_room_data(self) -> None: """Populate heater attributes from 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 @property def device_type(self) -> str: """Return device type.""" return DEVICE_TYPE_HEATERS @dataclass() class Socket(Heater): """Representation of socket with humidity sensor.""" humidity: float | None = None cooling_mode: bool | None = None def __post_init__(self) -> None: """Initialize socket from device data.""" if self.data: last_metrics = self.data.get("lastMetrics", {}) self.humidity = last_metrics.get("humidity") device_settings = self.data.get("deviceSettings", {}) device_settings_reported = device_settings.get("reported", {}) if device_settings_reported: cooling_mode = device_settings_reported.get("additional_socket_mode") self.cooling_mode = None if cooling_mode is None else cooling_mode == "cooling" super().__post_init__() @property def device_type(self) -> str: """Return device type.""" return DEVICE_TYPE_SOCKETS @dataclass() class Sensor(MillDevice): """Representation of air quality and environmental 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: """Initialize sensor from device data.""" if not self.data: return self._populate_metrics() self._calculate_particles() self.filter_state = self.data.get("deviceSettings", {}).get("reported", {}).get("filter_state") def _populate_metrics(self) -> None: """Populate sensor metrics from last metrics 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") def _calculate_particles(self) -> None: """Calculate average particle count from PM measurements.""" if self.pm1 is not None and self.pm25 is not None and self.pm10 is not None: avg_particles = (float(self.pm1) + float(self.pm25) + float(self.pm10)) / 3 self.particles = round(avg_particles, 2) @property def device_type(self) -> str: """Return device type.""" return DEVICE_TYPE_SENSORS Danielhiversen-pymill-56e4896/mill/consts.py000066400000000000000000000000671511651143600211000ustar00rootroot00000000000000"""Constants used by pymill""" __version__ = "0.15.0" Danielhiversen-pymill-56e4896/pyproject.toml000066400000000000000000000003001511651143600211620ustar00rootroot00000000000000[tool.ruff] line-length = 120 [tool.ruff.lint] ignore = ["D", "EM", "FBT", "PLR0913", "S101", "S105", "TRY", "C901", "PLR0911", "F541", "COM812", "ARG002", "F841", "PLR0912"] select = ["ALL"]Danielhiversen-pymill-56e4896/setup.py000066400000000000000000000016341511651143600177730ustar00rootroot00000000000000from 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", ], )