pax_global_header00006660000000000000000000000064145110550650014514gustar00rootroot0000000000000052 comment=4802df27dfe9f0ff94ecce949d5b889d654f02a5 MatsNl-pyatag-0a7e2c9/000077500000000000000000000000001451105506500146115ustar00rootroot00000000000000MatsNl-pyatag-0a7e2c9/.env000066400000000000000000000000171451105506500154000ustar00rootroot00000000000000PYTHONPATH="./"MatsNl-pyatag-0a7e2c9/.github/000077500000000000000000000000001451105506500161515ustar00rootroot00000000000000MatsNl-pyatag-0a7e2c9/.github/release.yml000066400000000000000000000000561451105506500203150ustar00rootroot00000000000000template: | ## What's Changed $CHANGES MatsNl-pyatag-0a7e2c9/.github/workflows/000077500000000000000000000000001451105506500202065ustar00rootroot00000000000000MatsNl-pyatag-0a7e2c9/.github/workflows/publish.yml000066400000000000000000000016061451105506500224020ustar00rootroot00000000000000# 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: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v1 with: python-version: 3.x - 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_TOKEN }} run: | python setup.py sdist bdist_wheel twine upload dist/* MatsNl-pyatag-0a7e2c9/.github/workflows/test.yml000066400000000000000000000015251451105506500217130ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Run Tests on: push: branches: [master] pull_request: branches: [master] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python 3.8 uses: actions/setup-python@v1 with: python-version: 3.8 - name: Install dependencies run: | pip install -r requirements.txt pip install -r requirements-test.txt - name: Lint with flake8 run: | flake8 pyatag # - name: Check formatting with black # run: | # black pyatag --check MatsNl-pyatag-0a7e2c9/.gitignore000066400000000000000000000001141451105506500165750ustar00rootroot00000000000000input.py __pycache__ update .vscode .DS_Store pyatag.egg-info/* dist/*MatsNl-pyatag-0a7e2c9/.pre-commit-config.yaml000066400000000000000000000033111451105506500210700ustar00rootroot00000000000000repos: - repo: https://github.com/asottile/pyupgrade rev: v2.7.2 hooks: - id: pyupgrade args: [--py38-plus] # - repo: https://github.com/psf/black # rev: 20.8b1 # hooks: # - id: black # args: # - --safe # - --quiet # files: ^((pyatag)/.+)?[^/]+\.py$ - repo: https://github.com/codespell-project/codespell rev: v2.0.0 hooks: - id: codespell args: - --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/ # - repo: https://gitlab.com/pycqa/flake8 # rev: 3.8.4 # hooks: # - id: flake8 # additional_dependencies: # - flake8-docstrings==1.5.0 # # Temporarily every now and then for noqa cleanup; not done by # # default yet due to https://github.com/plinss/flake8-noqa/issues/1 # # - flake8-noqa==1.1.0 # - pydocstyle==5.1.1 # files: ^(pyatag)/.+\.py$ - repo: https://github.com/PyCQA/isort rev: 5.7.0 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.2.0 hooks: - id: check-executables-have-shebangs stages: [manual] - id: check-json exclude: (.vscode|.devcontainer) - repo: https://github.com/adrienverge/yamllint.git rev: v1.24.2 hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.2.1 hooks: - id: prettier stages: [manual] MatsNl-pyatag-0a7e2c9/.yamllint000066400000000000000000000023261451105506500164460ustar00rootroot00000000000000ignore: | azure-*.yml rules: braces: level: error min-spaces-inside: 0 max-spaces-inside: 1 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 brackets: level: error min-spaces-inside: 0 max-spaces-inside: 0 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 colons: level: error max-spaces-before: 0 max-spaces-after: 1 commas: level: error max-spaces-before: 0 min-spaces-after: 1 max-spaces-after: 1 comments: level: error require-starting-space: true min-spaces-from-content: 2 comments-indentation: level: error document-end: level: error present: false document-start: level: error present: false empty-lines: level: error max: 1 max-start: 0 max-end: 1 hyphens: level: error max-spaces-after: 1 indentation: level: error spaces: 2 indent-sequences: true check-multi-line-strings: false key-duplicates: level: error line-length: disable new-line-at-end-of-file: level: error new-lines: level: error type: unix trailing-spaces: level: error truthy: level: error MatsNl-pyatag-0a7e2c9/CHANGELOG.md000066400000000000000000000016611451105506500164260ustar00rootroot00000000000000# Changelog ## [0.3.5.3] - set request retries to 10 and reduce sleep (reduces failures) - stop using email, not needed for local connections - remove tests for now ## [0.3.5] - merge bug fixes from PR - merge #6 - remove faulty tests ## [0.3.4] - Add automatic retry on server disconnect error ## [0.3.3] - Remove sleep from updater (request only) - Raise errors on auth and update failure ## [0.3.2] - Add water heater set temperature service - Small bugfixes ## [0.3.1] - Fix controls not working in 0.3.0 - Fix for inconsistently capitalized preset modes - Fix binary division for status reporting ## [0.3.0] - Rewrite to align objects to HomeAssistant entity types ## [0.2.19] - Fixed a timezone related bug introduced in 0.2.18 - Start keeping changelog ## [0.2.18] - Added functionality for preset modes ## [0.2.0] - Prepare for HomeAssistant integration ## [0.1.0] - Initial cli version MatsNl-pyatag-0a7e2c9/LICENSE.txt000066400000000000000000000020671451105506500164410ustar00rootroot00000000000000MIT License Copyright (c) 2018 YOUR NAME 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. MatsNl-pyatag-0a7e2c9/MANIFEST.in000066400000000000000000000000741451105506500163500ustar00rootroot00000000000000include README.md include requirements.txt include LICENSEMatsNl-pyatag-0a7e2c9/README.md000066400000000000000000000025061451105506500160730ustar00rootroot00000000000000# PyAtag ## Asynchronous library to control Atag One Requires Python 3.x and uses asyncio and aiohttp. ```python import asyncio import logging import aiohttp from pyatag import AtagException, AtagOne from pyatag.discovery import async_discover_atag logging.basicConfig() _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) async def main(): """Initialize session for main program.""" async with aiohttp.ClientSession() as session: await run(session) async def run(session): """Run example main program.""" atag_ip, atag_id = await async_discover_atag() # for auto discovery, requires access to UDP broadcast (hostnet) atag = AtagOne(atag_ip, session) try: await atag.authorize() await atag.update() except AtagException as err: _LOGGER.error(err) return False for sensor in atag.report: _LOGGER.debug("%s = %s", sensor.name, sensor.state) for attribute in dir(atag.climate): _LOGGER.debug( "atag.climate.%s = %s", attribute, getattr(atag.climate, attribute) ) await atag.climate.set_preset_mode("manual") await atag.climate.set_temp(11) _LOGGER.debug(atag.report.report_time) _LOGGER.debug(atag.dhw.temperature) asyncio.run(main()) ``` MatsNl-pyatag-0a7e2c9/example.py000066400000000000000000000023711451105506500166210ustar00rootroot00000000000000"""Example program to test pyatag.""" import asyncio import logging import aiohttp from pyatag import AtagException, AtagOne from pyatag.discovery import async_discover_atag logging.basicConfig() _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) async def main(): """Initialize session for main program.""" async with aiohttp.ClientSession() as session: await run(session) async def run(session): """Run example main program.""" atag_ip, device_id = await async_discover_atag() _LOGGER.info(f"Found Atag device {device_id }at address {atag_ip}") atag = AtagOne(atag_ip, session) try: await atag.authorize() await atag.update() except AtagException as err: _LOGGER.error(err) return False for sensor in atag.report: _LOGGER.debug("%s = %s", sensor.name, sensor.state) for attribute in dir(atag.climate): _LOGGER.debug( "atag.climate.%s = %s", attribute, getattr(atag.climate, attribute) ) await atag.climate.set_preset_mode("manual") await atag.climate.set_temp(21) _LOGGER.debug(atag.report.report_time) _LOGGER.debug(atag.dhw.temperature) asyncio.run(main()) MatsNl-pyatag-0a7e2c9/pyatag/000077500000000000000000000000001451105506500160765ustar00rootroot00000000000000MatsNl-pyatag-0a7e2c9/pyatag/__init__.py000066400000000000000000000002501451105506500202040ustar00rootroot00000000000000"""Provides connection to ATAG One Thermostat REST API.""" __version__ = "0.3.7.1" from .errors import * # noqa from .gateway import AtagOne assert AtagOne MatsNl-pyatag-0a7e2c9/pyatag/const.py000066400000000000000000000042421451105506500176000ustar00rootroot00000000000000"""Constants for ATAG API.""" import logging _LOGGER = logging.getLogger(__package__) CLASSES = { "temp": ["temperature", None, "mdi:thermometer"], "pres": ["pressure", "bar", "mdi:gauge"], "time": ["time", None, "mdi:clock"], "duration": ["duration", None, "mdi:timer"], "hours": [None, "h", "mdi:clock"], "rate": [None, "%", "mdi:fire"], } SENSORS = { "burning_hours": "Burning Hours", # "download_url": "API Version", "outside_temp": "Outside Temperature", "rel_mod_level": "Flame", "tout_avg": "Average Outside Temperature", "weather_status": "Weather Status", # "ch_control_mode": "CH HVAC Mode", # "ch_mode": "CH Preset Mode", # "ch_mode_duration": "CH Preset Duration", # "ch_mode_temp": "CH Target Temperature", "ch_return_temp": "CH Return Temperature", "ch_water_pres": "CH Water Pressure", "ch_water_temp": "CH Water Temperature", } STATES = { "weather_status": { 0: {"state": "Sunny", "icon": "mdi:weather-sunny"}, 1: {"state": "Clear", "icon": "mdi:weather-night"}, 2: {"state": "Rainy", "icon": "mdi:weather-rainy"}, 3: {"state": "Snowy", "icon": "mdi:weather-snowy"}, 4: {"state": "Haily", "icon": "mdi:weather-hail"}, 5: {"state": "Windy", "icon": "mdi:weather-windy"}, 6: {"state": "Misty", "icon": "mdi:weather-fog"}, 7: {"state": "Cloudy", "icon": "mdi:weather-cloudy"}, 8: {"state": "Partly Sunny", "icon": "mdi:weather-partly-cloudy"}, 9: {"state": "Partly Cloudy", "icon": "mdi:cloud"}, 10: {"state": "Shower", "icon": "mdi:weather-pouring"}, 11: {"state": "Lightning", "icon": "mdi:weather-lightning"}, 12: {"state": "Hurricane", "icon": "mdi:weather-hurricane"}, 13: {"state": "Unknown", "icon": "mdi:cloud-question"}, }, "temp_unit": {0: "°C", 1: "°F"}, "ch_mode": { 1: "manual", 2: "automatic", 3: "vacation", 4: "extend", 5: "fireplace", }, "ch_control_mode": {0: "auto", 1: "heat"}, "dhw_mode": {0: "performance", 1: "eco"}, } DEFAULT_PORT = 10000 MatsNl-pyatag-0a7e2c9/pyatag/discovery.py000066400000000000000000000040571451105506500204650ustar00rootroot00000000000000"""Automatic discovery of ATAG Thermostat on LAN.""" import asyncio import socket from .const import _LOGGER from .errors import RequestError ATAG_UDP_PORT = 11000 LOCALHOST = "0.0.0.0" class Discovery(asyncio.DatagramProtocol): """Discovery class.""" def __init__(self): """Start listener.""" self.data = asyncio.Future() def connection_made(self, transport): """Log connection made.""" _LOGGER.debug("Listening on UDP %s", ATAG_UDP_PORT) def datagram_received(self, data, addr): """Record broadcasted data.""" self.data.set_result([data, addr]) async def async_discover_atag(): """Discover Atag on local network.""" # return format: [b'ONE xxxx-xxxx-xxxx_xx-xx-xxx-xxx (ST)', # ('xxx.xxx.x.x', xxxx)] trans, proto = await asyncio.get_event_loop().create_datagram_endpoint( Discovery, local_addr=(LOCALHOST, ATAG_UDP_PORT) ) try: result = await asyncio.wait_for(proto.data, timeout=30) host_ip = result[1][0] device_id = result[0].decode().split()[1] trans.close() except asyncio.TimeoutError: trans.close() raise RequestError("Host discovery failed") return host_ip, device_id def discover_atag(): """Discover Atag on local network.""" # return format: [b'ONE xxxx-xxxx-xxxx_xx-xx-xxx-xxx (ST)', # ('xxx.xxx.x.x', xxxx)] # sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) # UDP sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.settimeout(30) sock.bind(("", 11000)) try: while True: result = sock.recvfrom(37) host_ip = result[1][0] device_id = result[0].decode().split()[1] return host_ip, device_id except socket.timeout: return False except Exception as err: raise RequestError(err) MatsNl-pyatag-0a7e2c9/pyatag/entities.py000066400000000000000000000237261451105506500203060ustar00rootroot00000000000000"""Classes within AtagOne object.""" from datetime import datetime, timedelta from .const import CLASSES, SENSORS, STATES def convert_time(seconds, sensorclass="time"): """Convert reported time to real time.""" if sensorclass == "duration": return str(timedelta(seconds=seconds)) return str(datetime(2000, 1, 1) + timedelta(seconds=seconds)) class Report: """Main object to hold report and control data.""" def __init__(self, data, update, setter): """Initiate object for Sensor and Control data.""" self._update = update self._setter = setter self._items = {} self._data = data self._classes = CLASSES self._classes["temp"][1] = STATES["temp_unit"][ self._data["configuration"]["temp_unit"] ] self._process_raw(self._data) def update(self, data): """Process latest data.""" self._data = data self._process_raw(self._data) def _process_raw(self, raw): """Push data to the sensor and control objects.""" for grp in ["configuration", "status", "report", "control"]: for _id, raw_i in raw[grp].items(): name = SENSORS.get(_id) obj = self._items.get(name) or self._items.get(_id) if obj is not None: obj.raw = raw_i elif grp == "control": self._items[name or _id] = Control( _id, raw_i, self._classes, self._setter ) else: self._items[name or _id] = Sensor(_id, raw_i, self._classes) def items(self): """Return the report objects.""" return self._items.values() @property def report_time(self): """Return latest report time.""" return self._items["report_time"].state def __getitem__(self, obj_id): """Return selected sensor object by name or ID.""" if obj_id in SENSORS: return self._items[SENSORS[obj_id]] return self._items[obj_id] def __iter__(self): """Iterate over sensor and control objects.""" return iter(self._items.values()) class Sensor: """Represents an Atag sensor.""" def __init__(self, _id, raw, classes): """Initiate sensor object.""" self.id = _id self.raw = raw self._info = classes.get(self.id.split("_")[-1]) if _id == "tout_avg": self._info = classes["temp"] if _id == "rel_mod_level": self._info = classes["rate"] self._states = STATES.get(self.id) @property def name(self): """Return the readable name of the Sensor.""" return SENSORS.get(self.id) or self.id @property def sensorclass(self): """Return the sensorclass if known.""" if self._info: return self._info[0] @property def state(self): """Return state, if known in readable format.""" if self.sensorclass in ["time", "duration"]: return convert_time(self.raw, self.sensorclass) if self.id == "boiler_status": return { "burner": self.raw & 8 == 8, "dhw": self.raw & 4 == 4, "ch": self.raw & 2 == 2, } if self.id == "download_url": return self.raw.split("/")[-1] if self._states is not None: if isinstance(self._states[self.raw], dict): return self._states[self.raw]["state"] return self._states[self.raw] return self.raw @property def icon(self): """Return the icon corresponding to the state.""" if self._info: return self._info[2] elif self._states and isinstance(self._states[self.raw], dict): return self._states[self.raw]["icon"] return None @property def measure(self): """Return the unit of measurement if known.""" if self._info: return self._info[1] return None def __repr__(self): """Return the name of the Sensor.""" return str(self.state) class Control(Sensor): """Represents an Atag control.""" def __init__(self, _id, raw, classes, setter): """Initiate Control object.""" super().__init__(_id, raw, classes) self._setter = setter self._target = None self._last_call = None @property def state(self): """Return reported state, or target state if recently set.""" if self._target: if (datetime.utcnow() - self._last_call).total_seconds() < 15: return self._target self._target = None if self.sensorclass in ["time", "duration"]: return convert_time(self.raw, self.sensorclass) if self._states: if isinstance(self._states[self.raw], dict): return self._states[self.raw]["state"] return self._states[self.raw] if self.id == "dhw_mode_temp": return self.raw % 150 return self.raw async def set_state(self, target): """Set the Control to a new target state.""" target_int = None if self._states is not None: target = {v.lower(): v for v in self._states.values()}.get(target.lower()) target_int = {v: k for k, v in self._states.items()}.get(target.lower()) if target == self.state: return True self._target = target self._last_call = datetime.utcnow() return await self._setter(**{self.id: target_int}) async def set_temp(self, target): """Set the Control to a new target state.""" if target == self.state: return True self._target = target self._last_call = datetime.utcnow() return await self._setter(**{self.id: target}) class Climate: """Main climate entity.""" def __init__(self, report): """Initiate the main climate object.""" self._report = report def __repr__(self): """Return Climate properties.""" return ", ".join( [ f"temperature: {self.temperature} {self.temp_unit}", f"target: {self.target_temperature} {self.temp_unit}", f"status: {self.status}", f"burner status: {self.burnerstatus}", f"hvac mode: {self.hvac_mode}", f"preset mode: {self.preset_mode}", f"flame: {self.flame}", ] ) @property def temp_unit(self): """Return temperature unit.""" return self._report["temp_unit"].state @property def burnerstatus(self): """Return boolean for burner active.""" return self._report["boiler_status"].state["burner"] @property def status(self): """Return HVAC action.""" return self._report["boiler_status"].state["ch"] @property def flame(self): """Return flame level.""" if self.status: return self._report["rel_mod_level"].state return 0 @property def hvac_mode(self): """Return the operating mode (Weather or Regular/Heat).""" return self._report["ch_control_mode"].state async def set_hvac_mode(self, target: str) -> int: """Set the operating mode (Weather or Regular/Heat).""" await self._report["ch_control_mode"].set_state(target) @property def preset_mode(self): """Return the preset mode (manual/automatic/extend/vacation/fireplace).""" return self._report["ch_mode"].state @property def preset_mode_duration(self): """Return remaining time on preset mode.""" return self._report["ch_mode_duration"].state async def set_preset_mode(self, target: str, **kwargs) -> int: """Set the hold mode (manual/automatic/extend/vacation/fireplace).""" await self._report["ch_mode"].set_state(target) @property def temperature(self): """Return current indoor temperature.""" return self._report["room_temp"].state @property def target_temperature(self): """Return target temperature.""" return self._report["ch_mode_temp"].state async def set_temp(self, target: float): """Set target temperature.""" await self._report["ch_mode_temp"].set_temp(target) class DHW: """Main Domestic Hot Water object.""" def __init__(self, report): """Initiate main DHW object.""" self._report = report @property def temp_unit(self): """Return temperature unit.""" return self._report["temp_unit"].state @property def burnerstatus(self): """Return boolean for burner status.""" return self._report["boiler_status"].state["burner"] @property def status(self): """Return boolean indicator for heating for DHW.""" return self._report["boiler_status"].state["dhw"] @property def flame(self): """Return flame level if active for DHW.""" if self.status: return self._report["rel_mod_level"].state return 0 @property def temperature(self): """Return water temperature.""" return self._report["dhw_water_temp"].state @property def min_temp(self): """Return dhw min temperature.""" return self._report["dhw_min_set"].state @property def max_temp(self): """Return dhw max temperature.""" return self._report["dhw_max_set"].state @property def target_temperature(self): """Return dhw target temperature.""" if self.status: return self._report["dhw_temp_setp"].state return self._report["dhw_mode_temp"].state @property def current_operation(self): """Return the current operating mode (Eco or Performance (Comfort).""" return self._report["dhw_mode"].state if self.status else "off" async def set_temp(self, target: float): """Set dhw target temperature.""" await self._report["dhw_temp_setp"].set_temp(target) MatsNl-pyatag-0a7e2c9/pyatag/errors.py000066400000000000000000000007641451105506500177730ustar00rootroot00000000000000"""Error handling for atag_Thermostat.""" class AtagException(Exception): """Base error for AtagOne devices.""" class Unauthorized(AtagException): """Failed to authenticate.""" class RequestError(AtagException): """Unable to fulfill request.""" class ConnectionError(AtagException): """Unable to fulfill request.""" class ResponseError(AtagException): """Invalid response.""" class UnknownAtagError(AtagException): """Invalid response.""" MatsNl-pyatag-0a7e2c9/pyatag/gateway.py000066400000000000000000000135401451105506500201140ustar00rootroot00000000000000"""Gateway connecting to ATAG thermostat.""" import asyncio import re import socket # together with your other imports import uuid from datetime import datetime, timedelta import aiohttp from . import __version__, errors from .const import _LOGGER from .entities import DHW, Climate, Report USER_AGENT = "Mozilla/5.0 (compatible; AtagOneAPI/x; http://atag.one/)" REQUEST_HEADER_USER_AGENT = "User-Agent" REQUEST_HEADER_X_ONEAPP_VERSION = "X-OneApp-Version" HEADERS = { REQUEST_HEADER_USER_AGENT: USER_AGENT, REQUEST_HEADER_X_ONEAPP_VERSION: f"{__package__}-{__version__}", } class AtagOne: """Central data store entity.""" def __init__(self, host, session=None, device=None, email=None, port=10000): """Initialize main AtagOne object.""" del email # email is not needed for local connections self.host = host self.port = port self._device = device self._authorized = device is not None # assume authorized if device id is known self._mac = "-".join(re.findall("..", "%012x" % uuid.getnode())).upper() self._last_call = datetime(1970, 1, 1) self._lock = asyncio.Lock() self._session = session or aiohttp.ClientSession() self.climate = None self.dhw = None self.report = None @property def id(self): """Return the ID of the bridge.""" if self.report: self._device = self.report["device_id"].state return self._device @property def apiversion(self): """Return the ID of the bridge.""" if self.report: return self.report["download_url"].state @property def authorized(self): """Return authorization status.""" return self._authorized @authorized.setter def authorized(self, data): """Check response for error message.""" self._authorized = data[list(data.keys())[0]]["acc_status"] == 2 if not self._authorized: raise errors.Unauthorized("Received unauthorized message from device!") async def authorize(self): """Check auth status.""" if not self.authorized: json = { "pair_message": { "seqnr": 0, "account_auth": {"user_account": "", "mac_address": self._mac}, "accounts": { "entries": [ { "user_account": "", # self.email, "mac_address": self._mac, "device_name": socket.gethostname(), "account_type": 0, } ] }, } } await self.request("pair", json) _LOGGER.debug("Authorized successfully.") return self.authorized async def request(self, path, json=None): """Make a request to the API.""" url = f"http://{self.host}:{self.port}/{path}" async with self._lock: for tries in range(10): await asyncio.sleep( 1 - (datetime.utcnow() - self._last_call).total_seconds() ) self._last_call = datetime.utcnow() _LOGGER.debug(f"Call {tries+1} to {self.host} for {path}") try: async with self._session.post( url, headers=HEADERS, json=json ) as res: self.authorized = data = await res.json() return data except ( aiohttp.ServerDisconnectedError, aiohttp.ClientError, asyncio.CancelledError, ) as err: if tries < 9 and isinstance(err, aiohttp.ServerDisconnectedError): continue raise errors.ConnectionError( f"Giving up after {type(err).__name__} (attempts: {tries+1})" ) from err async def update(self, info=71): """Get latest data from API.""" if not self.authorized: await self.authorize() json = { "retrieve_message": { "seqnr": 0, "account_auth": {"user_account": "", "mac_address": self._mac}, "info": info, } } res = await self.request("retrieve", json) res = res["retrieve_reply"] res["report"].update(res["report"].pop("details")) if self.report is None: self.report = Report(res, self.update, self.setter) self.climate = Climate(self.report) self.dhw = DHW(self.report) else: self.report.update(res) return True async def setter(self, **kwargs): """Set control items.""" if not self.authorized: await self.authorize() json = { "update_message": { "seqnr": 0, "account_auth": {"user_account": "", "mac_address": self._mac}, "control": {}, "configuration": {}, } } for key, val in kwargs.items(): json["update_message"]["control"][key] = val if key == "ch_mode" and val == 3: json["update_message"]["control"]["vacation_duration"] = int( timedelta(days=1).total_seconds() ) json["update_message"]["configuration"]["start_vacation"] = int( (datetime.utcnow() - datetime(2000, 1, 1)).total_seconds() ) res = await self.request("update", json) return res["update_reply"] MatsNl-pyatag-0a7e2c9/requirements-test.txt000066400000000000000000000000371451105506500210520ustar00rootroot00000000000000flake8==3.7.9 black==19.10b0 MatsNl-pyatag-0a7e2c9/requirements.txt000066400000000000000000000000071451105506500200720ustar00rootroot00000000000000aiohttpMatsNl-pyatag-0a7e2c9/script/000077500000000000000000000000001451105506500161155ustar00rootroot00000000000000MatsNl-pyatag-0a7e2c9/script/release000066400000000000000000000002711451105506500174600ustar00rootroot00000000000000#!/bin/sh # Pushes a new version to PyPi. # Stop on errors set -e #cd "$(dirname "$0")/.." rm -rf dist python3 setup.py sdist python3 -m twine upload dist/* --skip-existing MatsNl-pyatag-0a7e2c9/setup.cfg000066400000000000000000000005351451105506500164350ustar00rootroot00000000000000[metadata] description-file = README.md version = attr: pyatag.__version__ [flake8] # To work with Black max-line-length = 88 # E501: line too long # W503: Line break occurred before a binary operator # E203: Whitespace before ':' # D202 No blank lines allowed after function docstring ignore = E501, W503, E203, D202 MatsNl-pyatag-0a7e2c9/setup.py000066400000000000000000000012751451105506500163300ustar00rootroot00000000000000"""Setup script.""" from setuptools import setup long_description = open("README.md").read() setup( name="pyatag", license="MIT", url="https://github.com/MatsNl/pyatag", author="@MatsNL", description="Python module to talk to Atag One.", packages=["pyatag"], zip_safe=True, platforms="any", install_requires=list(val.strip() for val in open("requirements.txt")), classifiers=[ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries :: Python Modules", ], )