pax_global_header00006660000000000000000000000064147305730040014515gustar00rootroot0000000000000052 comment=7e0677a37fa0cb67ab2298cdecab362049af32f7 fjaraskupan-2.3.2/000077500000000000000000000000001473057300400140265ustar00rootroot00000000000000fjaraskupan-2.3.2/.github/000077500000000000000000000000001473057300400153665ustar00rootroot00000000000000fjaraskupan-2.3.2/.github/workflows/000077500000000000000000000000001473057300400174235ustar00rootroot00000000000000fjaraskupan-2.3.2/.github/workflows/python-package.yml000066400000000000000000000016001473057300400230550ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Python package on: push: branches: [ master ] pull_request: branches: [ master ] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: [3.8, 3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install flake8 pytest python -m pip install -e .[tests] - name: Test with pytest run: | pytest --cov fjaraskupan-2.3.2/.github/workflows/python-publish.yml000066400000000000000000000015401473057300400231330ustar00rootroot00000000000000# This workflow 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@v2 - name: Set up Python uses: actions/setup-python@v2 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: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/* fjaraskupan-2.3.2/.gitignore000066400000000000000000000001411473057300400160120ustar00rootroot00000000000000*.egg-info __pycache__ .pytest_cache build dist .vscode/settings.json .coverage .mypy_cache venv fjaraskupan-2.3.2/LICENSE.txt000066400000000000000000000020541473057300400156520ustar00rootroot00000000000000MIT License Copyright (c) 2021 Joakim Plate 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.fjaraskupan-2.3.2/README.rst000066400000000000000000000017711473057300400155230ustar00rootroot00000000000000******************************** Fjäråskupan Bluetooth Control ******************************** This module support controlling Fjäråskupan kitchen fans over bluetooth Status ______ .. image:: https://github.com/elupus/fjaraskupan/actions/workflows/python-package.yml/badge.svg :target: https://github.com/elupus/fjaraskupan Module ====== Code to set fan speed using library. .. code-block:: python async def run(): async with Device("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE") as device: await device.set_fan_speed(5) loop = asyncio.get_event_loop() loop.run_until_complete (run()) Commandline =========== Scan for possible devices. .. code-block:: bash python -m fjaraskupan scan Code to set fan speed using commandline. .. code-block:: bash python -m fjaraskupan fan AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE 5 To get help on possible commands .. code-block:: bash python -m fjaraskupan -h python -m fjaraskupan light -h python -m fjaraskupan fan -h fjaraskupan-2.3.2/pylintrc000066400000000000000000000030451473057300400156170ustar00rootroot00000000000000[MESSAGES CONTROL] # Reasons disabled: # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # global-statement - used for the on-demand requirement installation # redefined-variable-type - this is Python, we're duck typing! # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 # unnecessary-pass - readability for functions which only contain pass disable= abstract-class-little-used, abstract-method, cyclic-import, duplicate-code, global-statement, inconsistent-return-statements, locally-disabled, not-an-iterable, not-context-manager, redefined-variable-type, too-few-public-methods, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-public-methods, too-many-return-statements, too-many-statements, unnecessary-pass, unused-argument, no-else-return, missing-docstring [REPORTS] reports=no [TYPECHECK] # For attrs ignored-classes=_CountingAttr generated-members=botocore.errorfactory [FORMAT] expected-line-ending-format=LF [EXCEPTIONS] overgeneral-exceptions=Exception [BASIC] good-names=ac,zn,cc,e,s,cfjaraskupan-2.3.2/setup.cfg000066400000000000000000000001551473057300400156500ustar00rootroot00000000000000[bdist_wheel] [metadata] description-file = README.rst [isort] not_skip = __init__.py multi_line_output = 3fjaraskupan-2.3.2/setup.py000066400000000000000000000017711473057300400155460ustar00rootroot00000000000000from setuptools import find_packages, setup # read the contents of your README file from os import path this_directory = path.abspath(path.dirname(__file__)) with open(path.join(this_directory, "README.rst"), encoding="utf-8") as f: long_description = f.read() setup( name="fjaraskupan", version="2.3.2", description="A python library for speaking to fjäråskupan", long_description=long_description, long_description_content_type="text/x-rst", license="MIT", packages=["fjaraskupan"], package_dir={"": "src"}, python_requires=">=3.8", author="Joakim Plate", install_requires=["bleak>=0.19"], extras_require={ "tests": [ "pytest>3.6.4", "pytest-mock", "pytest-cov", ] }, url="https://github.com/elupus/fjaraskupan", classifiers=[ "License :: OSI Approved :: MIT License", "Development Status :: 4 - Beta", "Environment :: Console", "Framework :: AsyncIO", ], ) fjaraskupan-2.3.2/src/000077500000000000000000000000001473057300400146155ustar00rootroot00000000000000fjaraskupan-2.3.2/src/fjaraskupan/000077500000000000000000000000001473057300400171225ustar00rootroot00000000000000fjaraskupan-2.3.2/src/fjaraskupan/__init__.py000066400000000000000000000273441473057300400212450ustar00rootroot00000000000000"""Device communication library.""" from __future__ import annotations import asyncio from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass, replace import logging from typing import Any, AsyncIterator from uuid import UUID from bleak import BleakClient from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak.exc import BleakError COMMAND_FORMAT_FAN_SPEED_FORMAT = "-Luft-{:01d}-" COMMAND_FORMAT_DIM = "-Dim{:03d}-" COMMAND_FORMAT_PERIODIC_VENTING = "Period{:02d}" COMMAND_FORMAT_AFTERCOOKINGSTRENGTHMANUAL = "Nachla-{:01d}" COMMAND_STOP_FAN = "Luft-Aus" COMMAND_LIGHT_ON_OFF = "Kochfeld" COMMAND_RESETGREASEFILTER = "ResFett-" COMMAND_RESETCHARCOALFILTER = "ResKohle" COMMAND_AFTERCOOKINGTIMERMANUAL = "Nachlauf" COMMAND_AFTERCOOKINGTIMERAUTO = "NachlAut" COMMAND_AFTERCOOKINGTIMEROFF = "NachlAus" COMMAND_ACTIVATECARBONFILTER = "coal-ava" _LOGGER = logging.getLogger(__name__) UUID_SERVICE = UUID("{77a2bd49-1e5a-4961-bba1-21f34fa4bc7b}") UUID_RX = UUID("{23123e0a-1ad6-43a6-96ac-06f57995330d}") UUID_TX = UUID("{68ecc82c-928d-4af0-aa60-0d578ffb35f7}") UUID_CONFIG = UUID("{3e06fdc2-f432-404f-b321-dfa909f5c12c}") DEVICE_NAME = "COOKERHOOD_FJAR" ANNOUNCE_PREFIX = b"HOODFJAR" ANNOUNCE_MANUFACTURER = int.from_bytes(ANNOUNCE_PREFIX[0:2], "little") class FjaraskupanError(Exception): pass class FjaraskupanBleakError(FjaraskupanError): pass class FjaraskupanConnectionError(FjaraskupanBleakError): pass class FjaraskupanWriteError(FjaraskupanBleakError): pass class FjaraskupanReadError(FjaraskupanBleakError): pass class FjaraskupanTimeout(FjaraskupanError, TimeoutError): pass @dataclass(frozen=True) class State: """Data received from characteristics.""" light_on: bool = False after_cooking_fan_speed: int = 0 after_cooking_on: bool = False carbon_filter_available: bool = False fan_speed: int = 0 grease_filter_full: bool = False carbon_filter_full: bool = False dim_level: int = 0 periodic_venting: int = 0 periodic_venting_on: bool = False rssi: int = 0 def replace_from_tx_char(self, databytes: bytes, **changes: Any): """Update state based on tx characteristics.""" data = databytes.decode("ASCII") return replace( self, fan_speed=int(data[4]), light_on=data[5] == "L", after_cooking_on=data[6] == "N", carbon_filter_available=data[7] == "C", grease_filter_full=data[8] == "F", carbon_filter_full=data[9] == "K", dim_level=_range_check_dim(int(data[10:13]), self.dim_level), periodic_venting=_range_check_period( int(data[13:15]), self.periodic_venting ), **changes ) def replace_from_manufacture_data(self, data: bytes, **changes: Any): """Update state based on broadcasted data.""" light_on = _bittest(data[10], 0) dim_level = _range_check_dim(data[13], self.dim_level) if light_on and not self.light_on and dim_level < self.dim_level: light_on = False return replace( self, fan_speed=int(data[8]), after_cooking_fan_speed=int(data[9]), light_on=light_on, after_cooking_on=_bittest(data[10], 1), periodic_venting_on=_bittest(data[10], 2), grease_filter_full=_bittest(data[11], 0), carbon_filter_full=_bittest(data[11], 1), carbon_filter_available=_bittest(data[11], 2), dim_level=dim_level, periodic_venting=_range_check_period(data[14], self.periodic_venting), **changes ) def _range_check_dim(value: int, fallback: int): if value >= 0 and value <= 100: return value else: return fallback def _range_check_period(value: int, fallback: int): if value >= 0 and value < 60: return value else: return fallback def _bittest(data: int, bit: int): return (data & (1 << bit)) != 0 def device_filter(device: BLEDevice, advertisement_data: AdvertisementData) -> bool: uuids = advertisement_data.service_uuids if str(UUID_SERVICE) in uuids: return True if device.name == DEVICE_NAME: return True manufacturer_data = advertisement_data.manufacturer_data.get(ANNOUNCE_MANUFACTURER, b'') if manufacturer_data.startswith(ANNOUNCE_PREFIX[2:]): return True return False class Device: """Communication handler.""" def __init__(self, address: str, keycode=b"1234", disconnect_delay: float = 5.0) -> None: """Initialize handler.""" self.address = address self._keycode = keycode self.state = State() self._lock = asyncio.Lock() self._client: BleakClient | None = None self._client_count = 0 self._client_stack = AsyncExitStack() self._disconnect_delay = disconnect_delay self._disconnect_task: asyncio.Task | None = None async def _disconnect_callback(self): await asyncio.sleep(self._disconnect_delay) async with self._lock: await self._disconnect() async def _disconnect_later(self): self._disconnect_task = asyncio.create_task(self._disconnect_callback(), name="Fjaraskupen Disconnector") async def _disconnect(self): assert self._client self._client = None _LOGGER.debug("Disconnecting") try: await self._client_stack.pop_all().aclose() except TimeoutError as exc: _LOGGER.debug("Timeout on disconnect", exc_info=True) raise FjaraskupanTimeout("Timeout on disconnect") from exc except BleakError as exc: _LOGGER.debug("Error on disconnect", exc_info=True) raise FjaraskupanConnectionError("Error on disconnect") from exc _LOGGER.debug("Disconnected") async def _connect(self, address_or_ble_device: BLEDevice | str): if address_or_ble_device is None: address_or_ble_device = self.address assert self._client is None _LOGGER.debug("Connecting") try: self._client = await self._client_stack.enter_async_context(BleakClient(address_or_ble_device)) except asyncio.TimeoutError as exc: _LOGGER.debug("Timeout on connect", exc_info=True) raise FjaraskupanTimeout("Timeout on connect") from exc except BleakError as exc: _LOGGER.debug("Error on connect", exc_info=True) raise FjaraskupanConnectionError("Error on connect") from exc _LOGGER.debug("Connected") @asynccontextmanager async def connect(self, address_or_ble_device: BLEDevice | str | None = None) -> AsyncIterator[Device]: async with self._lock: if self._disconnect_task: self._disconnect_task.cancel() self._disconnect_task = None if self._client is None: await self._connect(address_or_ble_device) else: _LOGGER.debug("Connection reused") self._client_count += 1 try: yield self finally: async with self._lock: self._client_count -= 1 if self._client_count == 0: if self._disconnect_delay: await self._disconnect_later() else: await self._disconnect() def characteristic_callback(self, data: bytearray): """Handle callback on characteristic change.""" _LOGGER.debug("Characteristic callback: %s", data) if data[0:4] != self._keycode: _LOGGER.warning("Wrong keycode in data %s", data) return self.state = self.state.replace_from_tx_char(data) _LOGGER.debug("Characteristic callback result: %s", self.state) def detection_callback(self, device: BLEDevice, advertisement_data: AdvertisementData): """Handle scanner data.""" data = advertisement_data.manufacturer_data.get(ANNOUNCE_MANUFACTURER) if data is None: return # Recover full manufacturer data. It's breaking standard by # not providing a manufacturer prefix here. data = ANNOUNCE_PREFIX[0:2] + data self.detection_callback_raw(data, advertisement_data.rssi) def detection_callback_raw(self, data: bytes, rssi: int): if data[0:8] != ANNOUNCE_PREFIX: _LOGGER.debug("Missing key in manufacturer data %s", data) return self.state = self.state.replace_from_manufacture_data(data, rssi=rssi) _LOGGER.debug("Detection callback result: %s", self.state) async def update(self): async with self._lock: await self._update() async def _update(self): """Update internal state.""" assert self._client, "Device must be connected" try: databytes = await self._client.read_gatt_char(UUID_RX) except asyncio.TimeoutError as exc: _LOGGER.debug("Timeout on update", exc_info=True) raise FjaraskupanTimeout from exc except BleakError as exc: _LOGGER.debug("Failed to update", exc_info=True) raise FjaraskupanReadError("Failed to update device") from exc self.characteristic_callback(databytes) async def send_command(self, cmd: str): """Send given command.""" async with self._lock: await self._send_command(cmd) async def _send_command(self, cmd: str): """Send given command.""" assert len(cmd) == 8 assert self._client, "Device must be connected" data = self._keycode + cmd.encode("ASCII") try: await self._client.write_gatt_char(UUID_RX, data, True) except asyncio.TimeoutError as exc: _LOGGER.debug("Timeout on write", exc_info=True) raise FjaraskupanTimeout from exc except BleakError as exc: _LOGGER.debug("Failed to write", exc_info=True) raise FjaraskupanWriteError("Failed to write") from exc if cmd == COMMAND_STOP_FAN: self.state = replace(self.state, fan_speed=0) elif cmd == COMMAND_LIGHT_ON_OFF: self.state = replace(self.state, light_on=not self.state.light_on) elif cmd == COMMAND_AFTERCOOKINGTIMERMANUAL: self.state = replace(self.state, after_cooking_on=True) elif cmd == COMMAND_AFTERCOOKINGTIMERAUTO: self.state = replace( self.state, after_cooking_on=True, after_cooking_fan_speed=0 ) async def send_fan_speed(self, speed: int): """Set numbered fan speed.""" async with self._lock: await self._send_command(COMMAND_FORMAT_FAN_SPEED_FORMAT.format(speed)) self.state = replace(self.state, fan_speed=speed) async def send_after_cooking(self, speed: int): """Set numbered fan speed.""" async with self._lock: await self._send_command(COMMAND_FORMAT_AFTERCOOKINGSTRENGTHMANUAL.format(speed)) self.state = replace(self.state, after_cooking_fan_speed=speed) async def send_periodic_venting(self, minutes: int): """Set periodic venting.""" async with self._lock: await self._send_command(COMMAND_FORMAT_PERIODIC_VENTING.format(minutes)) self.state = replace(self.state, periodic_venting=minutes) async def send_dim(self, level: int): """Ask to dim to a certain level.""" async with self._lock: if self.state.light_on ^ (level > 0): await self._send_command(COMMAND_LIGHT_ON_OFF) await self._send_command(COMMAND_FORMAT_DIM.format(level)) self.state = replace(self.state, dim_level=level, light_on=level > 0) fjaraskupan-2.3.2/src/fjaraskupan/__main__.py000066400000000000000000000050011473057300400212100ustar00rootroot00000000000000import asyncio import argparse from bleak import BleakScanner, BleakClient from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from . import COMMAND_LIGHT_ON_OFF, Device, device_filter parser = argparse.ArgumentParser(description="Control kitchen fans") subparsers = parser.add_subparsers(dest="subcommand") parse_scan = subparsers.add_parser("scan") parse_scan.add_argument("--timeout", default=5.0, type=float) parse_state = subparsers.add_parser("state") parse_state.add_argument("device", type=str) parse_light = subparsers.add_parser("light") parse_light.add_argument("device", type=str) parse_light.add_argument("level", type=int) parse_fan = subparsers.add_parser("fan") parse_fan.add_argument("device", type=str) parse_fan.add_argument("speed", type=int) parse_command = subparsers.add_parser("command") parse_command.add_argument("command", type=str) async def async_scan(args): async def detection(device: BLEDevice, advertisement_data: AdvertisementData): if device_filter(device, advertisement_data): print(f"Detection: {device} - {advertisement_data}") async with BleakScanner(detection_callback=detection): await asyncio.sleep(args.timeout) async def async_light(args): async with Device(args.device).connect() as device: await device.update() if args.level == 0: if device.state.light_on: await device.send_command(COMMAND_LIGHT_ON_OFF) else: if device.state.light_on is False: await device.send_command(COMMAND_LIGHT_ON_OFF) await asyncio.sleep(3) await device.send_dim(args.level) async def async_fan(args): async with Device(args.device).connect() as device: await device.send_fan_speed(args.speed) async def async_state(args): async with Device(args.device).connect() as device: await device.update() print(device.state) async def async_command(args): async with Device(args.device).connect() as device: await device.send_command(args.command) async def main(): args = parser.parse_args() if args.subcommand == "scan": await async_scan(args) elif args.subcommand == "light": await async_light(args) elif args.subcommand == "fan": await async_fan(args) elif args.subcommand == "state": await async_state(args) elif args.subcommand == "command": await async_command(args) if __name__ == "__main__": asyncio.run(main()) fjaraskupan-2.3.2/tests/000077500000000000000000000000001473057300400151705ustar00rootroot00000000000000fjaraskupan-2.3.2/tests/test_init.py000066400000000000000000000054341473057300400175520ustar00rootroot00000000000000from dataclasses import replace from fjaraskupan import State def test_parse_announce(): state = State().replace_from_manufacture_data(b"HOODFJAR\x00\x00\x00\x00\x00\x00\x00") assert state == State( light_on=False, after_cooking_fan_speed=0, after_cooking_on=False, carbon_filter_available=False, fan_speed=0, grease_filter_full=False, carbon_filter_full=False, dim_level=0, periodic_venting=0, periodic_venting_on=False ) state = State().replace_from_manufacture_data(b"HOODFJAR\x01\x02\x00\x00\x00\x30\x04") assert state == State( light_on=False, after_cooking_fan_speed=2, after_cooking_on=False, carbon_filter_available=False, fan_speed=1, grease_filter_full=False, carbon_filter_full=False, dim_level=0x30, periodic_venting=0x04, periodic_venting_on=False ) state = State().replace_from_manufacture_data(b"HOODFJAR\x01\x02\x01\x00\x00\x30\x00") assert state == replace(state, light_on=True) state = State().replace_from_manufacture_data(b"HOODFJAR\x01\x02\x03\x00\x00\x30\x00") assert state == replace(state, after_cooking_on=True) state = State().replace_from_manufacture_data(b"HOODFJAR\x01\x02\x07\x00\x00\x30\x00") assert state == replace(state, periodic_venting_on=True) state = State().replace_from_manufacture_data(b"HOODFJAR\x01\x02\x07\x01\x00\x30\x00") assert state == replace(state, grease_filter_full=True) state = State().replace_from_manufacture_data(b"HOODFJAR\x01\x02\x07\x03\x00\x30\x00") assert state == replace(state, carbon_filter_full=True) state = State().replace_from_manufacture_data(b"HOODFJAR\x01\x02\x07\x07\x00\x30\x00") assert state == replace(state, carbon_filter_available=True) def test_parse_rx(): state = State().replace_from_tx_char(b"12340_____00000") assert state == State( light_on=False, after_cooking_fan_speed=0, after_cooking_on=False, carbon_filter_available=False, fan_speed=0, grease_filter_full=False, carbon_filter_full=False, dim_level=0, periodic_venting=0, periodic_venting_on=False ) state = State().replace_from_tx_char(b"12348_____00000") assert state == replace(state, fan_speed=8) state = State().replace_from_tx_char(b"12348_____10000") assert state == replace(state, dim_level=100) state = State().replace_from_tx_char(b"12348_____10100") assert state == replace(state, dim_level=0) state = State().replace_from_tx_char(b"12348_____10059") assert state == replace(state, periodic_venting=59) state = State().replace_from_tx_char(b"12348_____10061") assert state == replace(state, periodic_venting=0)