diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 66f232e..a538bce 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.11.1 +current_version = 2.12.0 commit = True tag = True files = setup.py diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..1563200 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,26 @@ +name: Pylint + +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint + pip install -r requirements.txt + - name: Analysing the code with pylint + run: | + pylint --extension-pkg-whitelist=netifaces bin/pydaikin pydaikin/*.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..b7a704b --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 831b199..35ba3e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ default_language_version: python: python3.11 repos: - repo: https://github.com/psf/black - rev: 24.2.0 + rev: 24.4.2 hooks: - id: black args: @@ -17,19 +17,20 @@ repos: additional_dependencies: #- flake8-docstrings==1.5.0 - pydocstyle==5.0.2 - - repo: https://github.com/pre-commit/mirrors-isort - rev: v5.10.1 + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 hooks: - id: isort - repo: https://github.com/pycqa/pylint - rev: v3.0.3 + rev: v3.2.2 hooks: - id: pylint additional_dependencies: - aiohttp==3.7.3 - netifaces==0.11.0 - urllib3==1.26.3 - - retry==0.9.2 + - tenacity==8.2.3 exclude: 'tests/' args: - --ignore=setup.py + - --extension-pkg-allow-list=netifaces diff --git a/pydaikin/daikin_airbase.py b/pydaikin/daikin_airbase.py index 17dd936..227ccba 100644 --- a/pydaikin/daikin_airbase.py +++ b/pydaikin/daikin_airbase.py @@ -57,9 +57,9 @@ def parse_response(response_body): return response - def __init__( + def __init__( # pylint:disable=useless-parent-delegation self, device_id, session=None - ): # pylint:disable=useless-super-delegation + ) -> None: """Init the pydaikin appliance, representing one Daikin AirBase (BRP15B61) device.""" super().__init__(device_id, session) @@ -70,6 +70,9 @@ async def init(self): if not self.values: raise DaikinException("Empty values.") self.values.update({**self.DEFAULTS, **self.values}) + # Friendly display the model + if self.values.get("model", None) == "NOTSUPPORT": + self.values["model"] = "Airbase BRP15B61" async def _get_resource(self, path: str, params: Optional[dict] = None): """Make the http request.""" @@ -87,9 +90,15 @@ def support_swing_mode(self): return False @property - def support_outside_temperature(self): - """AirBase unit returns otemp if master controller starts before it.""" - return True + def outside_temperature(self): + """ + AirBase unit returns otemp if master controller starts before it. + + No Outside Thermometor returns a '-' (Non Number). + Return current outside temperature if available. + """ + value = self.values.get('otemp') + return self._parse_number('otemp') if value != '-' else None @property def support_zone_temperature(self): @@ -174,8 +183,13 @@ def zones(self): """Return list of zones.""" if not self.values.get("zone_name"): return None + enabled_zones = len(self.represent("zone_name")[1]) + if self.support_zone_count: + enabled_zones = int(self.zone_count) # float to int zone_onoff = self.represent("zone_onoff")[1] - zone_list = self.represent("zone_name")[1] + zone_list = self.represent("zone_name")[1][ + :enabled_zones + ] # Slicing to limit zones if self.support_zone_temperature: mode = self.values["mode"] diff --git a/pydaikin/daikin_base.py b/pydaikin/daikin_base.py index 1b64126..14f2101 100644 --- a/pydaikin/daikin_base.py +++ b/pydaikin/daikin_base.py @@ -9,8 +9,15 @@ from urllib.parse import unquote from aiohttp import ClientSession -from aiohttp.web_exceptions import HTTPForbidden -from retry import retry +from aiohttp.client_exceptions import ServerDisconnectedError +from aiohttp.web_exceptions import HTTPError, HTTPForbidden +from tenacity import ( + before_sleep_log, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_fixed, +) from .discovery import get_name from .power import ATTR_COOL, ATTR_HEAT, ATTR_TOTAL, TIME_TODAY, DaikinPowerMixin @@ -88,10 +95,10 @@ def discover_ip(device_id): device_ip = device_name['ip'] return device_id - def __init__(self, device_id, session: Optional[ClientSession] = None): + def __init__(self, device_id, session: Optional[ClientSession] = None) -> None: """Init the pydaikin appliance, representing one Daikin device.""" self.values = ApplianceValues() - self.session = session + self.session = session if session is not None else ClientSession() self._energy_consumption_history = defaultdict(list) if session: self.device_ip = device_id @@ -113,25 +120,39 @@ async def init(self): # Re-defined in all sub-classes raise NotImplementedError - @retry(tries=3, delay=1) + @retry( + reraise=True, + wait=wait_fixed(1), + stop=stop_after_attempt(3), + retry=retry_if_exception_type(ServerDisconnectedError), + before_sleep=before_sleep_log(_LOGGER, logging.DEBUG), + ) async def _get_resource(self, path: str, params: Optional[dict] = None): """Make the http request.""" if params is None: params = {} - if self.session is None: - session = ClientSession() - else: - session = self.session + _LOGGER.debug("Calling: %s/%s %s", self.base_url, path, params) - async with session as client_session, self.request_semaphore: - async with client_session.get( + # cannot manage session on outer async with or this will close the session + # passed to pydaikin (homeassistant for instance) + async with self.request_semaphore: + async with self.session.get( f'{self.base_url}/{path}', params=params - ) as resp: - if resp.status == 403: - raise HTTPForbidden - assert resp.status == 200, f"Response code is {resp.status}" - return self.parse_response(await resp.text()) + ) as response: + if response.status == 403: + raise HTTPForbidden(reason=f"HTTP 403 Forbidden for {response.url}") + # Airbase returns a 404 response on invalid urls but requires fallback + if response.status == 404: + _LOGGER.debug("HTTP 404 Not Found for %s", response.url) + return ( + {} + ) # return an empty dict to indicate successful connection but bad data + if response.status != 200: + raise HTTPError( + reason=f"Unexpected HTTP status code {response.status} for {response.url}" + ) + return self.parse_response(await response.text()) async def update_status(self, resources=None): """Update status from resources.""" @@ -143,10 +164,16 @@ async def update_status(self, resources=None): if self.values.should_resource_be_updated(resource) ] _LOGGER.debug("Updating %s", resources) - async with asyncio.TaskGroup() as tg: - tasks = [ - tg.create_task(self._get_resource(resource)) for resource in resources - ] + + try: + async with asyncio.TaskGroup() as tg: + tasks = [ + tg.create_task(self._get_resource(resource)) + for resource in resources + ] + except ExceptionGroup as eg: + for exc in eg.exceptions: + _LOGGER.error("Exception in TaskGroup: %s", exc) for resource, task in zip(resources, tasks): self.values.update_by_resource(resource, task.result()) @@ -175,6 +202,8 @@ def log_sensors(self, file): data.append(('out_temp', self.outside_temperature)) if self.support_compressor_frequency: data.append(('cmp_freq', self.compressor_frequency)) + if self.support_filter_dirty: + data.append(('en_filter_sign', self.filter_dirty)) if self.support_energy_consumption: data.append( ('total_today', self.energy_consumption(ATTR_TOTAL, TIME_TODAY)) @@ -201,6 +230,8 @@ def show_sensors(self): data.append(f'out_temp={int(self.outside_temperature)}°C') if self.support_compressor_frequency: data.append(f'cmp_freq={int(self.compressor_frequency)}Hz') + if self.support_filter_dirty: + data.append(f'en_filter_sign={int(self.filter_dirty)}') if self.support_energy_consumption: data.append( f'total_today={self.energy_consumption(ATTR_TOTAL, TIME_TODAY):.01f}kWh' @@ -281,6 +312,20 @@ def support_compressor_frequency(self) -> bool: """Return True if the device supports compressor frequency.""" return 'cmpfreq' in self.values + @property + def support_filter_dirty(self) -> bool: + """Return True if the device supports dirty filter notification and it is turned on.""" + return ( + 'en_filter_sign' in self.values + and 'filter_sign_info' in self.values + and int(self._parse_number('en_filter_sign')) == 1 + ) + + @property + def support_zone_count(self) -> bool: + """Return True if the device supports count of active zones.""" + return 'en_zone' in self.values + @property def support_energy_consumption(self) -> bool: """Return True if the device supports energy consumption monitoring.""" @@ -306,6 +351,16 @@ def compressor_frequency(self) -> Optional[float]: """Return current compressor frequency.""" return self._parse_number('cmpfreq') + @property + def filter_dirty(self) -> Optional[float]: + """Return current status of the filter.""" + return self._parse_number('filter_sign_info') + + @property + def zone_count(self) -> Optional[float]: + """Return number of enabled zones.""" + return self._parse_number('en_zone') + @property def humidity(self) -> Optional[float]: """Return current humidity.""" diff --git a/pydaikin/daikin_brp069.py b/pydaikin/daikin_brp069.py index bcdcd1a..63be396 100644 --- a/pydaikin/daikin_brp069.py +++ b/pydaikin/daikin_brp069.py @@ -114,6 +114,7 @@ class DaikinBRP069(Appliance): 'en_hol': 'away_mode', 'cur': 'internal clock', 'adv': 'advanced mode', + 'filter_sign_info': 'filter dirty', } async def init(self): diff --git a/pydaikin/daikin_brp072c.py b/pydaikin/daikin_brp072c.py index 84f3cdc..5f56ce1 100644 --- a/pydaikin/daikin_brp072c.py +++ b/pydaikin/daikin_brp072c.py @@ -12,7 +12,7 @@ class DaikinBRP072C(DaikinBRP069): """Daikin class for BRP072Cxx units.""" - def __init__(self, device_id, session=None, key=None, uuid=None): + def __init__(self, device_id, session=None, key=None, uuid=None) -> None: """Init the pydaikin appliance, representing one Daikin AirBase (BRP15B61) device.""" super().__init__(device_id, session) diff --git a/pydaikin/daikin_skyfi.py b/pydaikin/daikin_skyfi.py index 7855bc7..5222cb2 100644 --- a/pydaikin/daikin_skyfi.py +++ b/pydaikin/daikin_skyfi.py @@ -47,7 +47,7 @@ class DaikinSkyFi(Appliance): }, } - def __init__(self, device_id, session=None, password=None): + def __init__(self, device_id, session=None, password=None) -> None: """Init the pydaikin appliance, representing one Daikin SkyFi device.""" super().__init__(device_id, session) self.device_ip = f'{self.device_ip}:2000' diff --git a/pydaikin/discovery.py b/pydaikin/discovery.py index 48fc404..15cb93b 100644 --- a/pydaikin/discovery.py +++ b/pydaikin/discovery.py @@ -21,7 +21,7 @@ class Discovery: # pylint: disable=too-few-public-methods """Discovery class.""" - def __init__(self): + def __init__(self) -> None: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) diff --git a/pydaikin/factory.py b/pydaikin/factory.py index af57e50..694b120 100644 --- a/pydaikin/factory.py +++ b/pydaikin/factory.py @@ -1,8 +1,10 @@ "Factory to generate Pydaikin complete objects" +import logging from typing import Optional from aiohttp import ClientSession +from aiohttp.web_exceptions import HTTPNotFound from .daikin_airbase import DaikinAirBase from .daikin_base import Appliance @@ -11,6 +13,8 @@ from .daikin_skyfi import DaikinSkyFi from .exceptions import DaikinException +_LOGGER = logging.getLogger(__name__) + class DaikinFactory: # pylint: disable=too-few-public-methods "Factory object generating instantiated instances of Appliance" @@ -42,11 +46,16 @@ async def __init__( uuid=kwargs.get('uuid'), ) else: # special case for BRP069 and AirBase - self._generated_object = DaikinBRP069(device_id, session) - await self._generated_object.update_status( - self._generated_object.HTTP_RESOURCES[:1] - ) - if not self._generated_object.values: + try: + _LOGGER.debug("Trying connection to BRP069") + self._generated_object = DaikinBRP069(device_id, session) + await self._generated_object.update_status( + self._generated_object.HTTP_RESOURCES[:1] + ) + if not self._generated_object.values: + raise DaikinException("Empty Values.") + except (HTTPNotFound, DaikinException) as err: + _LOGGER.debug("Falling back to AirBase: %s", err) self._generated_object = DaikinAirBase(device_id, session) await self._generated_object.init() @@ -55,3 +64,5 @@ async def __init__( raise DaikinException( f"Error creating device, {device_id} is not supported." ) + + _LOGGER.debug("Daikin generated object: %s", self._generated_object) diff --git a/requirements.txt b/requirements.txt index ff26563..c63ace9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ netifaces aiohttp urllib3 -retry \ No newline at end of file +tenacity \ No newline at end of file diff --git a/setup.py b/setup.py index acad304..47e418e 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='pydaikin', - version='2.11.1', + version='2.12.0', description='Python Daikin HVAC appliances interface', long_description=long_description, long_description_content_type="text/markdown",