├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug.yml ├── dependabot.yml └── workflows │ ├── lint.yml │ └── validate.yml ├── pytest.ini ├── hacs.json ├── scripts ├── lint ├── setup └── develop ├── requirements.txt ├── custom_components └── onlycat │ ├── const.py │ ├── manifest.json │ ├── data │ ├── type.py │ ├── __init__.py │ ├── pet.py │ ├── event.py │ ├── device.py │ └── policy.py │ ├── services.yaml │ ├── button.py │ ├── binary_sensor.py │ ├── translations │ ├── en.json │ └── de.json │ ├── button_unlock.py │ ├── button_reboot.py │ ├── tests │ ├── test_select.py │ └── test_init.py │ ├── binary_sensor_connectivity.py │ ├── services.py │ ├── binary_sensor_contraband.py │ ├── binary_sensor_lock.py │ ├── config_flow.py │ ├── api.py │ ├── binary_sensor_event.py │ ├── device_tracker.py │ ├── select.py │ └── __init__.py ├── conftest.py ├── .gitignore ├── config └── configuration.yaml ├── .ruff.toml ├── README.md └── LICENSE /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = custom_components/onlycat/tests 3 | python_files = test_*.py -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OnlyCat", 3 | "homeassistant": "2025.2.4", 4 | "hacs": "2.0.1" 5 | } -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff format . 8 | ruff check . --fix 9 | pytest -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog==6.9.0 2 | homeassistant==2025.2.4 3 | pip>=21.3.1 4 | ruff==0.12.8 5 | pytest==8.4.1 6 | pytest-asyncio==1.1.0 7 | python-socketio==5.13.0 -------------------------------------------------------------------------------- /custom_components/onlycat/const.py: -------------------------------------------------------------------------------- 1 | """Constants for OnlyCat.""" 2 | 3 | from logging import Logger, getLogger 4 | 5 | LOGGER: Logger = getLogger(__package__) 6 | 7 | DOMAIN = "onlycat" 8 | ATTRIBUTION = "" 9 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | """Configuration of pytest.""" 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | # Add the root directory to Python path so custom_components can be found 7 | sys.path.insert(0, str(Path(Path(__file__).parent).resolve())) 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | *.egg-info 5 | */build/* 6 | */dist/* 7 | 8 | 9 | # misc 10 | .coverage 11 | .vscode 12 | .idea 13 | *.iml 14 | coverage.xml 15 | .ruff_cache 16 | .python-version 17 | 18 | # Home Assistant configuration 19 | config/* 20 | !config/configuration.yaml 21 | 22 | # OnlyCat example code 23 | onlycat-client-code-reference** -------------------------------------------------------------------------------- /custom_components/onlycat/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "onlycat", 3 | "name": "OnlyCat", 4 | "codeowners": [ 5 | "@OnlyCatAI" 6 | ], 7 | "config_flow": true, 8 | "documentation": "https://github.com/OnlyCatAI/onlycat-home-assistant", 9 | "iot_class": "cloud_push", 10 | "issue_tracker": "https://github.com/OnlyCatAI/onlycat-home-assistant/issues", 11 | "requirements": ["python-socketio==5.12.1"], 12 | "version": "0.1.0" 13 | } -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/homeassistant/ 5 | homeassistant: 6 | debug: true 7 | 8 | # https://www.home-assistant.io/integrations/logger/ 9 | logger: 10 | default: info 11 | logs: 12 | custom_components.onlycat: debug 13 | socketio: debug 14 | aiohttp: debug 15 | 16 | debugpy: 17 | start: true 18 | wait: false -------------------------------------------------------------------------------- /custom_components/onlycat/data/type.py: -------------------------------------------------------------------------------- 1 | """Custom types for onlycat representing a type.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from enum import StrEnum 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class Type(StrEnum): 12 | """Enum representing the type of event received via SocketIO.""" 13 | 14 | UNKNOWN = "unknown" 15 | CREATE = "create" 16 | UPDATE = "update" 17 | 18 | @classmethod 19 | def _missing_(cls, value: str) -> Type: 20 | """Handle missing enum values in case of API extensions.""" 21 | _LOGGER.warning("Unknown type: %s", value) 22 | return cls.UNKNOWN 23 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "devcontainers" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | 14 | - package-ecosystem: "pip" 15 | directory: "/" 16 | schedule: 17 | interval: "daily" 18 | ignore: 19 | # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json 20 | - dependency-name: "homeassistant" -------------------------------------------------------------------------------- /custom_components/onlycat/data/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom types for onlycat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from dataclasses import dataclass 7 | from typing import TYPE_CHECKING 8 | 9 | if TYPE_CHECKING: 10 | from homeassistant.config_entries import ConfigEntry 11 | 12 | from custom_components.onlycat.api import OnlyCatApiClient 13 | 14 | from .device import Device 15 | from .pet import Pet 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | type OnlyCatConfigEntry = ConfigEntry[OnlyCatData] 21 | 22 | 23 | @dataclass 24 | class OnlyCatData: 25 | """Data for the OnlyCat integration.""" 26 | 27 | client: OnlyCatApiClient 28 | devices: list[Device] 29 | pets: list[Pet] 30 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py313" 4 | 5 | [lint] 6 | select = [ 7 | "ALL", 8 | ] 9 | 10 | ignore = [ 11 | "ANN101", # Missing type annotation for `self` in method 12 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 13 | "D203", # no-blank-line-before-class (incompatible with formatter) 14 | "D212", # multi-line-summary-first-line (incompatible with formatter) 15 | "COM812", # incompatible with formatter 16 | "ISC001", # incompatible with formatter 17 | "S101", # Allow assert, especially for tests 18 | ] 19 | 20 | [lint.per-file-ignores] 21 | "**/tests/*" = [ 22 | "INP001" 23 | ] 24 | 25 | [lint.flake8-pytest-style] 26 | fixture-parentheses = false 27 | 28 | [lint.pyupgrade] 29 | keep-runtime-typing = true 30 | 31 | [lint.mccabe] 32 | max-complexity = 25 -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | branches: 9 | - "master" 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | ruff: 15 | name: "Ruff" 16 | runs-on: "ubuntu-latest" 17 | steps: 18 | - name: Checkout the repository 19 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 23 | with: 24 | python-version: "3.13" 25 | cache: "pip" 26 | 27 | - name: Install requirements 28 | run: python3 -m pip install -r requirements.txt 29 | 30 | - name: Lint 31 | run: python3 -m ruff check . 32 | 33 | - name: Format 34 | run: python3 -m ruff format . --check 35 | 36 | - name: Test 37 | run: pytest 38 | -------------------------------------------------------------------------------- /custom_components/onlycat/services.yaml: -------------------------------------------------------------------------------- 1 | set_pet_location: 2 | name: Set pet location 3 | description: Overrides the current location of a pet 4 | fields: 5 | device_tracker: 6 | name: Pet Device Tracker 7 | description: The OnlyCat integrations device tracker 8 | required: true 9 | selector: 10 | entity: 11 | domain: device_tracker 12 | location: 13 | name: Location 14 | description: The new location ("home" or "not_home") 15 | required: true 16 | selector: 17 | text: 18 | 19 | toggle_pet_location: 20 | name: Toggle pet location 21 | description: Toggles the current location of a pet between home and not_home 22 | fields: 23 | device_tracker: 24 | name: Pet Device Tracker 25 | description: The OnlyCat integrations device tracker 26 | required: true 27 | selector: 28 | entity: 29 | domain: device_tracker 30 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - master 13 | 14 | permissions: {} 15 | 16 | jobs: 17 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 18 | name: Hassfest validation 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout the repository 22 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 23 | 24 | - name: Run hassfest validation 25 | uses: home-assistant/actions/hassfest@a19f5f4e08ef2786e4604a948f62addd937a6bc9 # master 26 | 27 | hacs: # https://github.com/hacs/action 28 | name: HACS validation 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Run HACS validation 32 | uses: hacs/action@d556e736723344f83838d08488c983a15381059a # 22.5.0 33 | with: 34 | category: integration 35 | -------------------------------------------------------------------------------- /custom_components/onlycat/button.py: -------------------------------------------------------------------------------- 1 | """Button platform for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from .button_reboot import OnlyCatRebootButton 8 | from .button_unlock import OnlyCatUnlockButton 9 | 10 | if TYPE_CHECKING: 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | 14 | from .data.__init__ import OnlyCatConfigEntry 15 | 16 | 17 | async def async_setup_entry( 18 | hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` 19 | entry: OnlyCatConfigEntry, 20 | async_add_entities: AddEntitiesCallback, 21 | ) -> None: 22 | """Set up the button platform entities.""" 23 | async_add_entities( 24 | button 25 | for device in entry.runtime_data.devices 26 | for button in ( 27 | OnlyCatUnlockButton( 28 | device=device, 29 | api_client=entry.runtime_data.client, 30 | ), 31 | OnlyCatRebootButton(device=device, api_client=entry.runtime_data.client), 32 | ) 33 | ) 34 | -------------------------------------------------------------------------------- /custom_components/onlycat/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from .binary_sensor_connectivity import OnlyCatConnectionSensor 8 | from .binary_sensor_contraband import OnlyCatContrabandSensor 9 | from .binary_sensor_event import OnlyCatEventSensor 10 | from .binary_sensor_lock import OnlyCatLockSensor 11 | 12 | if TYPE_CHECKING: 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | 16 | from .data.__init__ import OnlyCatConfigEntry 17 | 18 | 19 | async def async_setup_entry( 20 | hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` 21 | entry: OnlyCatConfigEntry, 22 | async_add_entities: AddEntitiesCallback, 23 | ) -> None: 24 | """Set up the sensor platform.""" 25 | async_add_entities( 26 | sensor 27 | for device in entry.runtime_data.devices 28 | for sensor in ( 29 | OnlyCatEventSensor( 30 | device=device, 31 | api_client=entry.runtime_data.client, 32 | ), 33 | OnlyCatContrabandSensor( 34 | device=device, 35 | api_client=entry.runtime_data.client, 36 | ), 37 | OnlyCatLockSensor( 38 | device=device, 39 | api_client=entry.runtime_data.client, 40 | ), 41 | OnlyCatConnectionSensor( 42 | device=device, 43 | api_client=entry.runtime_data.client, 44 | ), 45 | ) 46 | ) 47 | -------------------------------------------------------------------------------- /custom_components/onlycat/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "description": "API Credentials for OnlyCat. Acquire them from the OnlyCat app.", 6 | "data": { 7 | "token": "OnlyCat Device Token" 8 | } 9 | } 10 | }, 11 | "error": { 12 | "auth": "Token wrong.", 13 | "connection": "Unable to connect to the server.", 14 | "unknown": "Unknown error occurred." 15 | }, 16 | "abort": { 17 | "already_configured": "This entry is already configured." 18 | } 19 | }, 20 | "entity": { 21 | "binary_sensor": { 22 | "onlycat_connection_sensor": { 23 | "name": "Connectivity" 24 | }, 25 | "onlycat_event_sensor": { 26 | "name": "Flap event" 27 | }, 28 | "onlycat_contraband_sensor": { 29 | "name": "Contraband detection" 30 | }, 31 | "onlycat_lock_sensor": { 32 | "name": "Lock state" 33 | } 34 | }, 35 | "device_tracker": { 36 | "onlycat_pet_tracker": { 37 | "name": "{pet_name}'s presence" 38 | } 39 | }, 40 | "select": { 41 | "onlycat_policy_select": { 42 | "name": "Door Policy" 43 | } 44 | }, 45 | "button": { 46 | "onlycat_reboot_button": { 47 | "name": "Reboot" 48 | }, 49 | "onlycat_unlock_button": { 50 | "name": "Unlock" 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /custom_components/onlycat/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "description": "Token für die OnlyCat API. In der OnlyCat App zu finden.", 6 | "data": { 7 | "token": "OnlyCat Device Token" 8 | } 9 | } 10 | }, 11 | "error": { 12 | "auth": "Token falsch.", 13 | "connection": "Konnte nicht mit der API verbinden.", 14 | "unknown": "Unbekannter Fehler." 15 | }, 16 | "abort": { 17 | "already_configured": "Diese Entität ist bereits konfiguriert." 18 | } 19 | }, 20 | "entity": { 21 | "binary_sensor": { 22 | "onlycat_connection_sensor": { 23 | "name": "Erreichbarkeit" 24 | }, 25 | "onlycat_event_sensor": { 26 | "name": "Klappenbewegung" 27 | }, 28 | "onlycat_contraband_sensor": { 29 | "name": "Beuteerkennung" 30 | }, 31 | "onlycat_lock_sensor": { 32 | "name": "Verriegelung" 33 | } 34 | }, 35 | "device_tracker": { 36 | "onlycat_pet_tracker": { 37 | "name": "{pet_name}s Anwesenheit" 38 | } 39 | }, 40 | "select": { 41 | "onlycat_policy_select": { 42 | "name": "Zugangsrichtlinie" 43 | } 44 | }, 45 | "button": { 46 | "onlycat_reboot_button": { 47 | "name": "Neustarten" 48 | }, 49 | "onlycat_unlock_button": { 50 | "name": "Aufschließen" 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request" 3 | description: "Suggest an idea for this project" 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. 8 | - type: checkboxes 9 | attributes: 10 | label: Checklist 11 | options: 12 | - label: I have filled out the template to the best of my ability. 13 | required: true 14 | - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). 15 | required: true 16 | - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/Alex-Ala/onlycat-home-assistant/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). 17 | required: true 18 | 19 | - type: textarea 20 | attributes: 21 | label: "Is your feature request related to a problem? Please describe." 22 | description: "A clear and concise description of what the problem is." 23 | placeholder: "I'm always frustrated when [...]" 24 | validations: 25 | required: true 26 | 27 | - type: textarea 28 | attributes: 29 | label: "Describe the solution you'd like" 30 | description: "A clear and concise description of what you want to happen." 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | attributes: 36 | label: "Describe alternatives you've considered" 37 | description: "A clear and concise description of any alternative solutions or features you've considered." 38 | validations: 39 | required: true 40 | 41 | - type: textarea 42 | attributes: 43 | label: "Additional context" 44 | description: "Add any other context or screenshots about the feature request here." 45 | validations: 46 | required: true 47 | -------------------------------------------------------------------------------- /custom_components/onlycat/button_unlock.py: -------------------------------------------------------------------------------- 1 | """Unlcok Button for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.button import ( 9 | ButtonEntity, 10 | ButtonEntityDescription, 11 | ) 12 | from homeassistant.helpers.device_registry import DeviceInfo 13 | 14 | from .const import DOMAIN 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | if TYPE_CHECKING: 19 | from .api import OnlyCatApiClient 20 | from .data.device import Device 21 | 22 | ENTITY_DESCRIPTION = ButtonEntityDescription( 23 | key="OnlyCat", 24 | name="Unlock", 25 | icon="mdi:lock-open", 26 | translation_key="onlycat_unlock_button", 27 | ) 28 | 29 | 30 | class OnlyCatUnlockButton(ButtonEntity): 31 | """OnlyCat unlock button class.""" 32 | 33 | _attr_has_entity_name = True 34 | _attr_should_poll = False 35 | 36 | @property 37 | def device_info(self) -> DeviceInfo: 38 | """Return device info to map to a device.""" 39 | return DeviceInfo( 40 | identifiers={(DOMAIN, self.device.device_id)}, 41 | name=self.device.description, 42 | serial_number=self.device.device_id, 43 | ) 44 | 45 | def __init__( 46 | self, 47 | device: Device, 48 | api_client: OnlyCatApiClient, 49 | ) -> None: 50 | """Initialize the button class.""" 51 | self.entity_description = ENTITY_DESCRIPTION 52 | self.device: Device = device 53 | self._attr_unique_id = device.device_id.replace("-", "_").lower() + "_unlock" 54 | self._api_client = api_client 55 | self.entity_id = "button." + self._attr_unique_id 56 | 57 | async def async_press(self) -> None: 58 | """Handle button press.""" 59 | await self._api_client.send_message( 60 | "runDeviceCommand", {"deviceId": self.device.device_id, "command": "unlock"} 61 | ) 62 | -------------------------------------------------------------------------------- /custom_components/onlycat/button_reboot.py: -------------------------------------------------------------------------------- 1 | """Unlcok Button for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.button import ( 9 | ButtonDeviceClass, 10 | ButtonEntity, 11 | ButtonEntityDescription, 12 | ) 13 | from homeassistant.helpers.device_registry import DeviceInfo 14 | 15 | from .const import DOMAIN 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | if TYPE_CHECKING: 20 | from .api import OnlyCatApiClient 21 | from .data.device import Device 22 | 23 | ENTITY_DESCRIPTION = ButtonEntityDescription( 24 | key="OnlyCat", 25 | name="Reboot", 26 | device_class=ButtonDeviceClass.RESTART, 27 | translation_key="onlycat_reboot_button", 28 | ) 29 | 30 | 31 | class OnlyCatRebootButton(ButtonEntity): 32 | """OnlyCat reboot button class.""" 33 | 34 | _attr_has_entity_name = True 35 | _attr_should_poll = False 36 | 37 | @property 38 | def device_info(self) -> DeviceInfo: 39 | """Return device info to map to a device.""" 40 | return DeviceInfo( 41 | identifiers={(DOMAIN, self.device.device_id)}, 42 | name=self.device.description, 43 | serial_number=self.device.device_id, 44 | ) 45 | 46 | def __init__( 47 | self, 48 | device: Device, 49 | api_client: OnlyCatApiClient, 50 | ) -> None: 51 | """Initialize the button class.""" 52 | self.entity_description = ENTITY_DESCRIPTION 53 | self.device: Device = device 54 | self._attr_unique_id = device.device_id.replace("-", "_").lower() + "_reboot" 55 | self._api_client = api_client 56 | self.entity_id = "button." + self._attr_unique_id 57 | 58 | async def async_press(self) -> None: 59 | """Handle button press.""" 60 | await self._api_client.send_message( 61 | "runDeviceCommand", {"deviceId": self.device.device_id, "command": "reboot"} 62 | ) 63 | -------------------------------------------------------------------------------- /custom_components/onlycat/tests/test_select.py: -------------------------------------------------------------------------------- 1 | """Test of OnlyCat Policy Select entity.""" 2 | 3 | from unittest.mock import AsyncMock 4 | 5 | import pytest 6 | from homeassistant.components.select import SelectEntityDescription 7 | 8 | from custom_components.onlycat import Device 9 | from custom_components.onlycat.select import OnlyCatPolicySelect, load_policies 10 | 11 | get_device_transit_policies = [ 12 | [], 13 | [ 14 | {"deviceTransitPolicyId": 0, "deviceId": "OC-00000000001", "name": "Policy1"}, 15 | {"deviceTransitPolicyId": 1, "deviceId": "OC-00000000001", "name": "Policy2"}, 16 | {"deviceTransitPolicyId": 2, "deviceId": "OC-00000000001", "name": "Policy3"}, 17 | ], 18 | ] 19 | 20 | 21 | @pytest.mark.asyncio 22 | @pytest.mark.parametrize("data", get_device_transit_policies) 23 | async def test_load_policies(data: list) -> None: 24 | """Test loading policies for a device.""" 25 | mock_client = AsyncMock() 26 | mock_client.send_message.side_effect = [data] 27 | device_id = "OC-00000000001" 28 | 29 | policies = await load_policies(mock_client, device_id) 30 | 31 | # Verify API call 32 | mock_client.send_message.assert_called_once_with( 33 | "getDeviceTransitPolicies", {"deviceId": device_id} 34 | ) 35 | 36 | # Verify results 37 | assert len(policies) == len(data) 38 | 39 | 40 | def test_empty_onlycat_policy_slect() -> None: 41 | """Tests initialization of OnlyCatPolicySelect with no active or known policies.""" 42 | mock_device = Device( 43 | device_id="OC-00000000001", 44 | description="Test Cat Flap", 45 | device_transit_policy_id=None, 46 | ) 47 | entity_description = SelectEntityDescription( 48 | key="onlycat_policy_select", 49 | ) 50 | mock_api_client = AsyncMock() 51 | select = OnlyCatPolicySelect( 52 | device=mock_device, 53 | policies=[], 54 | entity_description=entity_description, 55 | api_client=mock_api_client, 56 | ) 57 | 58 | assert select.device.device_id == "OC-00000000001" 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report" 3 | description: "Report a bug with the integration" 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: Before you open a new issue, search through the existing issues to see if others have had the same problem. 8 | - type: textarea 9 | attributes: 10 | label: "System Health details" 11 | description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" 12 | validations: 13 | required: true 14 | - type: checkboxes 15 | attributes: 16 | label: Checklist 17 | options: 18 | - label: I have enabled debug logging for my installation. 19 | required: true 20 | - label: I have filled out the issue template to the best of my ability. 21 | required: true 22 | - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). 23 | required: true 24 | - label: This issue is not a duplicate issue of any [previous issues](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Bug%22+).. 25 | required: true 26 | - type: textarea 27 | attributes: 28 | label: "Describe the issue" 29 | description: "A clear and concise description of what the issue is." 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Reproduction steps 35 | description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." 36 | value: | 37 | 1. 38 | 2. 39 | 3. 40 | ... 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: "Debug logs" 46 | description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." 47 | render: text 48 | validations: 49 | required: true 50 | 51 | - type: textarea 52 | attributes: 53 | label: "Diagnostics dump" 54 | description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" 55 | -------------------------------------------------------------------------------- /custom_components/onlycat/data/pet.py: -------------------------------------------------------------------------------- 1 | """Custom types for onlycat representing a pet.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from dataclasses import dataclass 7 | from typing import TYPE_CHECKING 8 | 9 | from .event import EventTriggerSource 10 | from .policy import PolicyResult 11 | 12 | if TYPE_CHECKING: 13 | from datetime import datetime 14 | 15 | from .device import Device 16 | from .event import Event 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | @dataclass 22 | class Pet: 23 | """Data representing a pet.""" 24 | 25 | device: Device 26 | rfid_code: str 27 | last_seen: datetime 28 | last_seen_event: Event | None = None 29 | label: str | None = None 30 | 31 | def is_present(self, event: Event) -> bool | None: 32 | """Determine whether a pet is present based on an event.""" 33 | pet_name = self.label if self.label else self.rfid_code 34 | 35 | if event.rfid_codes is None or self.rfid_code not in event.rfid_codes: 36 | return None 37 | 38 | _LOGGER.debug( 39 | "New %s event for %s, determining new state", 40 | event.event_trigger_source.name 41 | if event.event_trigger_source 42 | else "UNKNOWN", 43 | pet_name, 44 | ) 45 | 46 | if not self.device.device_transit_policy: 47 | _LOGGER.debug( 48 | "No transit policy set, unable to determine policy result for event %s", 49 | event.event_id, 50 | ) 51 | return None 52 | if event.event_trigger_source not in ( 53 | EventTriggerSource.OUTDOOR_MOTION, 54 | EventTriggerSource.INDOOR_MOTION, 55 | ): 56 | return None 57 | 58 | policy_result = self.device.device_transit_policy.determine_policy_result(event) 59 | if policy_result == PolicyResult.LOCKED: 60 | _LOGGER.debug("Transit was not allowed, ignoring event for %s.", pet_name) 61 | return None 62 | if policy_result == PolicyResult.UNKNOWN: 63 | _LOGGER.debug( 64 | "Unable to determine policy result, ignoring event for %s.", 65 | pet_name, 66 | ) 67 | return None 68 | 69 | result = event.event_trigger_source == EventTriggerSource.OUTDOOR_MOTION 70 | _LOGGER.debug( 71 | "Transit was allowed, assuming %s is %s", 72 | pet_name, 73 | "present" if result else "not present", 74 | ) 75 | 76 | return result 77 | -------------------------------------------------------------------------------- /custom_components/onlycat/binary_sensor_connectivity.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.binary_sensor import ( 9 | BinarySensorDeviceClass, 10 | BinarySensorEntity, 11 | BinarySensorEntityDescription, 12 | ) 13 | from homeassistant.const import EntityCategory 14 | from homeassistant.helpers.device_registry import DeviceInfo 15 | 16 | from .const import DOMAIN 17 | from .data.device import DeviceUpdate 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | if TYPE_CHECKING: 22 | from . import Device 23 | from .api import OnlyCatApiClient 24 | 25 | ENTITY_DESCRIPTION = BinarySensorEntityDescription( 26 | key="OnlyCat", 27 | name="Connectivity", 28 | device_class=BinarySensorDeviceClass.CONNECTIVITY, 29 | entity_category=EntityCategory.DIAGNOSTIC, 30 | translation_key="onlycat_connection_sensor", 31 | ) 32 | 33 | 34 | class OnlyCatConnectionSensor(BinarySensorEntity): 35 | """OnlyCat Sensor class.""" 36 | 37 | _attr_has_entity_name = True 38 | _attr_should_poll = False 39 | 40 | @property 41 | def device_info(self) -> DeviceInfo: 42 | """Return device info to map to a device.""" 43 | return DeviceInfo( 44 | identifiers={(DOMAIN, self.device.device_id)}, 45 | name=self.device.description, 46 | serial_number=self.device.device_id, 47 | ) 48 | 49 | def __init__( 50 | self, 51 | device: Device, 52 | api_client: OnlyCatApiClient, 53 | ) -> None: 54 | """Initialize the sensor class.""" 55 | self.entity_description = ENTITY_DESCRIPTION 56 | self._attr_is_on = device.connectivity.connected 57 | self._attr_raw_data = None 58 | self.device = device 59 | self._attr_unique_id = ( 60 | device.device_id.replace("-", "_").lower() + "_connectivity" 61 | ) 62 | self.entity_id = "binary_sensor." + self._attr_unique_id 63 | 64 | api_client.add_event_listener("deviceUpdate", self.on_device_update) 65 | 66 | async def on_device_update(self, data: dict) -> None: 67 | """Handle device update event.""" 68 | if data["deviceId"] != self.device.device_id: 69 | return 70 | 71 | device_update = DeviceUpdate.from_api_response(data) 72 | 73 | self._attr_raw_data = str(data) 74 | if device_update.body.connectivity: 75 | self._attr_is_on = device_update.body.connectivity.connected 76 | self.async_write_ha_state() 77 | -------------------------------------------------------------------------------- /custom_components/onlycat/services.py: -------------------------------------------------------------------------------- 1 | """Provides services for OnlyCat.""" 2 | 3 | import logging 4 | 5 | import voluptuous as vol 6 | from homeassistant.const import STATE_HOME, STATE_NOT_HOME 7 | from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse 8 | from homeassistant.exceptions import ServiceValidationError 9 | from homeassistant.helpers import config_validation as cv 10 | 11 | from .const import DOMAIN 12 | from .device_tracker import OnlyCatPetTracker 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | def _get_pet_tracker_entity(call: ServiceCall) -> OnlyCatPetTracker: 18 | """Get the pet tracker entity from the service call.""" 19 | device_tracker_id: str = call.data["device_tracker"] 20 | entity_component = call.hass.data.get("entity_components", {}).get("device_tracker") 21 | if not entity_component: 22 | error = "Device tracker component not found" 23 | raise ServiceValidationError(error) 24 | entity_obj = entity_component.get_entity(device_tracker_id) 25 | if not entity_obj: 26 | error = f"Entity {device_tracker_id} not found" 27 | raise ServiceValidationError(error) 28 | if not isinstance(entity_obj, OnlyCatPetTracker): 29 | error = f"Entity {device_tracker_id} is not an OnlyCatPetTracker entity" 30 | raise ServiceValidationError(error) 31 | return entity_obj 32 | 33 | 34 | async def async_setup_services(hass: HomeAssistant) -> None: 35 | """Create services for OnlyCat.""" 36 | hass.services.async_register( 37 | DOMAIN, 38 | "set_pet_location", 39 | async_handle_set_pet_presence, 40 | schema=vol.Schema( 41 | { 42 | vol.Required("device_tracker"): cv.entity_id, 43 | vol.Required("location"): cv.string, 44 | } 45 | ), 46 | ) 47 | hass.services.async_register( 48 | DOMAIN, 49 | "toggle_pet_location", 50 | async_handle_toggle_pet_presence, 51 | schema=vol.Schema( 52 | { 53 | vol.Required("device_tracker"): cv.entity_id, 54 | } 55 | ), 56 | ) 57 | 58 | 59 | async def async_handle_set_pet_presence(call: ServiceCall) -> ServiceResponse: 60 | """Handle the set presence service call.""" 61 | location: str = call.data["location"] 62 | entity_obj = _get_pet_tracker_entity(call) 63 | new_state = STATE_HOME if location.lower() == "home" else STATE_NOT_HOME 64 | await entity_obj.manual_update_location(new_state) 65 | _LOGGER.info("Set %s presence to: %s", entity_obj.entity_id, location) 66 | 67 | 68 | async def async_handle_toggle_pet_presence(call: ServiceCall) -> ServiceResponse: 69 | """Handle the toggle presence service call.""" 70 | entity_obj = _get_pet_tracker_entity(call) 71 | current_state = entity_obj.state 72 | new_state = STATE_NOT_HOME if current_state == STATE_HOME else STATE_HOME 73 | await entity_obj.manual_update_location(new_state) 74 | _LOGGER.info("Toggled %s presence to: %s", entity_obj.entity_id, new_state) 75 | -------------------------------------------------------------------------------- /custom_components/onlycat/binary_sensor_contraband.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.binary_sensor import ( 9 | BinarySensorDeviceClass, 10 | BinarySensorEntity, 11 | BinarySensorEntityDescription, 12 | ) 13 | from homeassistant.helpers.device_registry import DeviceInfo 14 | 15 | from .const import DOMAIN 16 | from .data.event import Event, EventClassification, EventUpdate 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | if TYPE_CHECKING: 21 | from .api import OnlyCatApiClient 22 | from .data.device import Device 23 | 24 | ENTITY_DESCRIPTION = BinarySensorEntityDescription( 25 | key="OnlyCat", 26 | name="Contraband", 27 | device_class=BinarySensorDeviceClass.PROBLEM, 28 | icon="mdi:rodent", 29 | translation_key="onlycat_contraband_sensor", 30 | ) 31 | 32 | 33 | class OnlyCatContrabandSensor(BinarySensorEntity): 34 | """OnlyCat Sensor class.""" 35 | 36 | _attr_has_entity_name = True 37 | _attr_should_poll = False 38 | 39 | @property 40 | def device_info(self) -> DeviceInfo: 41 | """Return device info to map to a device.""" 42 | return DeviceInfo( 43 | identifiers={(DOMAIN, self.device.device_id)}, 44 | name=self.device.description, 45 | serial_number=self.device.device_id, 46 | ) 47 | 48 | def __init__( 49 | self, 50 | device: Device, 51 | api_client: OnlyCatApiClient, 52 | ) -> None: 53 | """Initialize the sensor class.""" 54 | self.entity_description = ENTITY_DESCRIPTION 55 | self._attr_is_on = False 56 | self._attr_raw_data = None 57 | self.device: Device = device 58 | self._current_event: Event = Event() 59 | self._attr_unique_id = ( 60 | device.device_id.replace("-", "_").lower() + "_contraband" 61 | ) 62 | self._api_client = api_client 63 | self.entity_id = "sensor." + self._attr_unique_id 64 | 65 | api_client.add_event_listener("deviceEventUpdate", self.on_event_update) 66 | api_client.add_event_listener("eventUpdate", self.on_event_update) 67 | 68 | async def on_event_update(self, data: dict) -> None: 69 | """Handle event update event.""" 70 | if data["deviceId"] != self.device.device_id: 71 | return 72 | 73 | self._current_event.update_from(EventUpdate.from_api_response(data).event) 74 | self.determine_new_state(self._current_event) 75 | self.async_write_ha_state() 76 | 77 | def determine_new_state(self, event: Event) -> None: 78 | """Determine the new state of the sensor based on the event.""" 79 | if not event: 80 | return 81 | 82 | if event.frame_count: 83 | self._attr_is_on = False 84 | self._current_event = Event() 85 | elif event.event_classification == EventClassification.CONTRABAND: 86 | _LOGGER.debug("Contraband detected for event %s", event) 87 | self._attr_is_on = True 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OnlyCat Integration for Home Assistant 2 | 3 | HomeAssistant integration for [OnlyCat](https://www.onlycat.com/) flaps. 4 | 5 | ## Features 6 | 7 | * 🏠 Know whether your pet is home or on the hunt using the Device Tracker 8 | * 🐾 In case your pet chooses another exit, you can override the presence using the set_pet_location service 9 | * 🚪 Manage the active door policy manually or using automations 10 | * 🔎 Keep track of your device and build automations with it using sensors for: 11 | * 📶 Flap connection status 12 | * 🕒 Flap events (timestamp, RFID codes, trigger source, event classification) 13 | * 🐭 Contraband detection 14 | * 🔐 Lock state 15 | * 🔄 Control your flap remotely using reboot and remote unlock options 16 | 17 | Common automation ideas enabled by this integration include: 18 | 19 | * 🚨 Switch the door policy to "Locked" for a longer time period than usual when contraband is detected 20 | * 💦 Deter intruders by triggering a sprinkler when an unknown RFID code is detected 21 | * 🧹 Start your robot vacuum when your pet leaves the house 22 | * 😻 Roll out the red carpet for your pet by activating welcome lights or triggering a feeder upon arrival 23 | 24 | ## Installation 25 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=OnlyCatAI&repository=onlycat-home-assistant&category=integration) 26 | 27 | 1. Install [Home Assistant Community Store (HACS)](https://hacs.xyz/) if you haven't done so already. 28 | 2. Open HACS in Home Assistant 29 | 3. Click the three dots in the top right corner 30 | 4. Select "Custom repositories" 31 | 5. Add this repository URL: https://github.com/OnlyCatAI/onlycat-home-assistant 32 | 6. Set category to "Integration" 33 | 7. Click "Add" 34 | 8. Search for "OnlyCat" and install 35 | 9. Restart Home Assistant 36 | 37 | ## Configuration 38 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=onlycat) 39 | 40 | 1. Go to `Settings` > `Devices & Services` > `Add Integration` 41 | 2. Search for "OnlyCat" 42 | 3. Enter your configuration: 43 | * **Device Token**: The device token of your OnlyCat flap. You can find it in the OnlyCat app under "Account". 44 | 45 | ## Limitations 46 | 47 | Currently, the following features of the OnlyCat app are not yet included in the Home Assistant integration: 48 | 49 | * Creating or modifying door policies 50 | * Creating or modifying pet profiles (i.e., labels for RFID codes) 51 | * Accessing the video or poster frame of flap events 52 | 53 | ## Contributing 54 | 55 | Contributions are welcome! If you have ideas for new features & improvements or want to report a bug, 56 | please open an issue or submit a pull request. 57 | 58 | To get a local development environment up and running, follow these steps: 59 | 60 | 1. Install pip requirements via `pip install -r requirements.txt` 61 | 2. Run a HA instance: 62 | 1. Directly by running `./scripts/develop`, or 63 | 2. In Docker by running `docker run --volume ./config:/config --volume ./custom_components:/config/custom_components -p 8123:8123 "ghcr.io/home-assistant/home-assistant:stable"` 64 | 3. Add the integration from the HA "Devices & services" ui. 65 | 66 | -------------------------------------------------------------------------------- /custom_components/onlycat/binary_sensor_lock.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.binary_sensor import ( 9 | BinarySensorDeviceClass, 10 | BinarySensorEntity, 11 | BinarySensorEntityDescription, 12 | ) 13 | from homeassistant.helpers.device_registry import DeviceInfo 14 | 15 | from .const import DOMAIN 16 | from .data.event import Event, EventUpdate 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | if TYPE_CHECKING: 21 | from .api import OnlyCatApiClient 22 | from .data.device import Device 23 | 24 | ENTITY_DESCRIPTION = BinarySensorEntityDescription( 25 | key="OnlyCat", 26 | name="Lock", 27 | device_class=BinarySensorDeviceClass.LOCK, 28 | translation_key="onlycat_lock_sensor", 29 | ) 30 | 31 | 32 | class OnlyCatLockSensor(BinarySensorEntity): 33 | """OnlyCat Sensor class.""" 34 | 35 | _attr_has_entity_name = True 36 | _attr_should_poll = False 37 | 38 | @property 39 | def device_info(self) -> DeviceInfo: 40 | """Return device info to map to a device.""" 41 | return DeviceInfo( 42 | identifiers={(DOMAIN, self.device.device_id)}, 43 | name=self.device.description, 44 | serial_number=self.device.device_id, 45 | ) 46 | 47 | def __init__( 48 | self, 49 | device: Device, 50 | api_client: OnlyCatApiClient, 51 | ) -> None: 52 | """Initialize the sensor class.""" 53 | self.entity_description = ENTITY_DESCRIPTION 54 | self.device: Device = device 55 | self._current_event: Event = Event() 56 | self._attr_is_on = self.device.is_unlocked_in_idle_state() 57 | self._attr_unique_id = device.device_id.replace("-", "_").lower() + "_lock" 58 | self._api_client = api_client 59 | self.entity_id = "sensor." + self._attr_unique_id 60 | 61 | api_client.add_event_listener("deviceEventUpdate", self.on_event_update) 62 | api_client.add_event_listener("eventUpdate", self.on_event_update) 63 | api_client.add_event_listener("deviceUpdate", self.on_device_update) 64 | 65 | async def on_event_update(self, data: dict) -> None: 66 | """Handle event update event.""" 67 | if data["deviceId"] != self.device.device_id: 68 | return 69 | 70 | self._current_event.update_from(EventUpdate.from_api_response(data).event) 71 | self.determine_new_state(self._current_event) 72 | self.async_write_ha_state() 73 | 74 | async def on_device_update(self, data: dict) -> None: 75 | """Handle device update event.""" 76 | if data["deviceId"] != self.device.device_id: 77 | return 78 | 79 | self._attr_is_on = self.device.is_unlocked_in_idle_state() 80 | self.async_write_ha_state() 81 | 82 | def determine_new_state(self, event: Event) -> None: 83 | """Determine the new state of the sensor based on the event.""" 84 | if event.frame_count: 85 | self._attr_is_on = self.device.is_unlocked_in_idle_state() 86 | self._current_event = Event() 87 | else: 88 | unlocked = self.device.is_unlocked_by_event(event) 89 | if unlocked is not None: 90 | _LOGGER.debug("Lock state changed to %s for event %s", unlocked, event) 91 | self._attr_is_on = unlocked 92 | -------------------------------------------------------------------------------- /custom_components/onlycat/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | import voluptuous as vol 8 | from homeassistant import config_entries 9 | from homeassistant.const import CONF_ACCESS_TOKEN 10 | from homeassistant.helpers import selector 11 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 12 | 13 | from .api import ( 14 | OnlyCatApiClient, 15 | OnlyCatApiClientAuthenticationError, 16 | OnlyCatApiClientCommunicationError, 17 | OnlyCatApiClientError, 18 | ) 19 | from .const import DOMAIN, LOGGER 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class OnlyCatFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 25 | """Config flow for OnlyCat.""" 26 | 27 | VERSION = 1 28 | 29 | async def async_step_user( 30 | self, 31 | user_input: dict | None = None, 32 | ) -> config_entries.ConfigFlowResult: 33 | """Handle a flow initialized by the user.""" 34 | _errors = {} 35 | if user_input is not None: 36 | try: 37 | _LOGGER.debug("Initializing API client") 38 | client = OnlyCatApiClient( 39 | user_input[CONF_ACCESS_TOKEN], 40 | session=async_create_clientsession(self.hass), 41 | ) 42 | user_id = None 43 | 44 | async def on_user_update(data: any) -> None: 45 | nonlocal user_id 46 | if data is not None and "id" in data: 47 | user_id = str(data["id"]) 48 | 49 | client.add_event_listener("userUpdate", on_user_update) 50 | await self._validate_connection(client) 51 | except OnlyCatApiClientAuthenticationError as exception: 52 | LOGGER.warning(exception) 53 | _errors["base"] = "auth" 54 | except OnlyCatApiClientCommunicationError as exception: 55 | LOGGER.error(exception) 56 | _errors["base"] = "connection" 57 | except OnlyCatApiClientError as exception: 58 | LOGGER.exception(exception) 59 | _errors["base"] = "unknown" 60 | else: 61 | _LOGGER.debug("Creating entry with id %s", user_id) 62 | await self.async_set_unique_id(unique_id=user_id) 63 | self._abort_if_unique_id_configured() 64 | return_data = { 65 | "user_id": user_id, 66 | "token": user_input[CONF_ACCESS_TOKEN], 67 | } 68 | return self.async_create_entry( 69 | title=user_id, 70 | data=return_data, 71 | ) 72 | 73 | return self.async_show_form( 74 | step_id="user", 75 | data_schema=vol.Schema( 76 | { 77 | vol.Required( 78 | CONF_ACCESS_TOKEN, 79 | default=(user_input or {}).get( 80 | CONF_ACCESS_TOKEN, vol.UNDEFINED 81 | ), 82 | ): selector.TextSelector( 83 | selector.TextSelectorConfig( 84 | type=selector.TextSelectorType.PASSWORD, 85 | ), 86 | ) 87 | }, 88 | ), 89 | errors=_errors, 90 | ) 91 | 92 | async def _validate_connection(self, client: OnlyCatApiClient) -> None: 93 | """Validate connection.""" 94 | await client.connect() 95 | await client.send_message("getDevices", {"subscribe": False}) 96 | await client.disconnect() 97 | -------------------------------------------------------------------------------- /custom_components/onlycat/api.py: -------------------------------------------------------------------------------- 1 | """OnlyCat API Client.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections import defaultdict 7 | from typing import TYPE_CHECKING, Any 8 | 9 | if TYPE_CHECKING: 10 | import aiohttp 11 | 12 | from .data import OnlyCatData 13 | 14 | import socketio 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | ONLYCAT_URL = "https://gateway.onlycat.com" 19 | 20 | 21 | class OnlyCatApiClientError(Exception): 22 | """Exception to indicate a general API error.""" 23 | 24 | 25 | class OnlyCatApiClientCommunicationError( 26 | OnlyCatApiClientError, 27 | ): 28 | """Exception to indicate a communication error.""" 29 | 30 | 31 | class OnlyCatApiClientAuthenticationError( 32 | OnlyCatApiClientError, 33 | ): 34 | """Exception to indicate an authentication error.""" 35 | 36 | 37 | class OnlyCatApiClient: 38 | """Only Cat API Client.""" 39 | 40 | def __init__( 41 | self, 42 | token: str, 43 | session: aiohttp.ClientSession, 44 | data: OnlyCatData | None = None, 45 | socket: socketio.AsyncClient | None = None, 46 | ) -> None: 47 | """Sample API Client.""" 48 | self._token = token 49 | self._data = data 50 | self._session = session 51 | self._listeners = defaultdict(list) 52 | self._socket = socket or socketio.AsyncClient( 53 | http_session=self._session, 54 | reconnection=True, 55 | reconnection_attempts=0, 56 | reconnection_delay=10, 57 | reconnection_delay_max=10, 58 | ssl_verify=True, 59 | ) 60 | self._socket.on("*", self.handle_event) 61 | self.add_event_listener("connect", self.on_connected) 62 | 63 | async def connect(self) -> None: 64 | """Connect to wesocket client.""" 65 | if self._socket.connected: 66 | return 67 | _LOGGER.debug("Connecting to API") 68 | 69 | await self._socket.connect( 70 | ONLYCAT_URL, 71 | transports=["websocket"], 72 | namespaces="/", 73 | headers={"platform": "home-assistant", "device": "onlycat-hass"}, 74 | auth={"token": self._token}, 75 | ) 76 | 77 | async def disconnect(self) -> None: 78 | """Disconnect websocket client.""" 79 | _LOGGER.debug("Disconnecting from API") 80 | await self._socket.disconnect() 81 | await self._socket.shutdown() 82 | 83 | def add_event_listener(self, event: str, callback: Any) -> None: 84 | """Add an event listener.""" 85 | self._listeners[event].append(callback) 86 | _LOGGER.debug("Added event listener for event: %s", event) 87 | 88 | async def handle_event(self, event: str, *args: Any) -> None: 89 | """Handle an event.""" 90 | _LOGGER.debug("Received event: %s with args: %s", event, args) 91 | for callback in self._listeners[event]: 92 | try: 93 | await callback(*args) 94 | except Exception: 95 | _LOGGER.exception( 96 | "Error while handling event %s with args %s", event, args 97 | ) 98 | 99 | async def send_message(self, event: str, data: any) -> Any | None: 100 | """Send a message to the API.""" 101 | _LOGGER.debug("Sending %s message to API: %s", event, data) 102 | return await self._socket.call(event, data) 103 | 104 | async def wait(self) -> None: 105 | """Wait until client is disconnected.""" 106 | await self._socket.wait() 107 | 108 | async def on_connected(self) -> None: 109 | """Handle connected event.""" 110 | _LOGGER.debug("(Re)connected to API") 111 | -------------------------------------------------------------------------------- /custom_components/onlycat/binary_sensor_event.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.binary_sensor import ( 9 | BinarySensorDeviceClass, 10 | BinarySensorEntity, 11 | BinarySensorEntityDescription, 12 | ) 13 | from homeassistant.helpers.device_registry import DeviceInfo 14 | 15 | from .const import DOMAIN 16 | from .data.event import Event, EventUpdate 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | if TYPE_CHECKING: 21 | from .api import OnlyCatApiClient 22 | from .data.device import Device 23 | 24 | ENTITY_DESCRIPTION = BinarySensorEntityDescription( 25 | key="OnlyCat", 26 | name="Flap event", 27 | device_class=BinarySensorDeviceClass.MOTION, 28 | translation_key="onlycat_event_sensor", 29 | ) 30 | 31 | 32 | class OnlyCatEventSensor(BinarySensorEntity): 33 | """OnlyCat Sensor class.""" 34 | 35 | _attr_has_entity_name = True 36 | _attr_should_poll = False 37 | 38 | @property 39 | def device_info(self) -> DeviceInfo: 40 | """Return device info to map to a device.""" 41 | return DeviceInfo( 42 | identifiers={(DOMAIN, self.device.device_id)}, 43 | name=self.device.description, 44 | serial_number=self.device.device_id, 45 | ) 46 | 47 | def __init__( 48 | self, 49 | device: Device, 50 | api_client: OnlyCatApiClient, 51 | ) -> None: 52 | """Initialize the sensor class.""" 53 | self.entity_description = ENTITY_DESCRIPTION 54 | self._attr_is_on = False 55 | self._attr_extra_state_attributes = {} 56 | self._attr_raw_data = None 57 | self.device: Device = device 58 | self._attr_unique_id = device.device_id.replace("-", "_").lower() + "_event" 59 | self._api_client = api_client 60 | self.entity_id = "sensor." + self._attr_unique_id 61 | 62 | api_client.add_event_listener("deviceEventUpdate", self.on_event_update) 63 | api_client.add_event_listener("eventUpdate", self.on_event_update) 64 | 65 | async def on_event_update(self, data: dict) -> None: 66 | """Handle event update event.""" 67 | if data["deviceId"] != self.device.device_id: 68 | return 69 | 70 | self.determine_new_state(EventUpdate.from_api_response(data).event) 71 | self.async_write_ha_state() 72 | 73 | def determine_new_state(self, event: Event) -> None: 74 | """Determine the new state of the sensor based on the event.""" 75 | if (self._attr_extra_state_attributes.get("eventId")) != event.event_id: 76 | _LOGGER.debug( 77 | "Event ID has changed (%s -> %s), updating state.", 78 | self._attr_extra_state_attributes.get("eventId"), 79 | event.event_id, 80 | ) 81 | self._attr_is_on = True 82 | 83 | self._attr_extra_state_attributes = { 84 | "eventId": event.event_id, 85 | "timestamp": event.timestamp, 86 | "eventTriggerSource": event.event_trigger_source.name, 87 | } 88 | if event.rfid_codes: 89 | self._attr_extra_state_attributes["rfidCodes"] = event.rfid_codes 90 | elif event.frame_count: 91 | # Frame count is present, event is concluded 92 | self._attr_is_on = False 93 | self._attr_extra_state_attributes = {} 94 | else: 95 | if event.event_classification: 96 | self._attr_extra_state_attributes["eventClassification"] = ( 97 | event.event_classification.name 98 | ) 99 | if event.rfid_codes: 100 | self._attr_extra_state_attributes["rfidCodes"] = event.rfid_codes 101 | -------------------------------------------------------------------------------- /custom_components/onlycat/data/event.py: -------------------------------------------------------------------------------- 1 | """Custom types for onlycat representing a flap event.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from dataclasses import dataclass, fields 7 | from datetime import datetime 8 | from enum import Enum 9 | 10 | from .type import Type 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class EventTriggerSource(Enum): 16 | """Enum representing the source of an OnlyCat flap event.""" 17 | 18 | UNKNOWN = -1 19 | MANUAL = 0 20 | REMOTE = 1 21 | INDOOR_MOTION = 2 22 | OUTDOOR_MOTION = 3 23 | 24 | @classmethod 25 | def _missing_(cls, value: str) -> EventTriggerSource: 26 | """Handle missing enum values in case of API extensions.""" 27 | _LOGGER.warning("Unknown event trigger source: %s", value) 28 | return cls.UNKNOWN 29 | 30 | 31 | class EventClassification(Enum): 32 | """Enum representing the classification of an OnlyCat flap event.""" 33 | 34 | UNKNOWN = 0 35 | CLEAR = 1 36 | SUSPICIOUS = 2 37 | CONTRABAND = 3 38 | HUMAN_ACTIVITY = 4 39 | REMOTE_UNLOCK = 10 40 | 41 | @classmethod 42 | def _missing_(cls, value: str) -> EventClassification: 43 | """Handle missing enum values in case of API extensions.""" 44 | _LOGGER.warning("Unknown event classification: %s", value) 45 | return cls.UNKNOWN 46 | 47 | 48 | @dataclass 49 | class Event: 50 | """Data representing an OnlyCat flap event.""" 51 | 52 | global_id: int | None = None 53 | device_id: str | None = None 54 | event_id: int | None = None 55 | timestamp: datetime | None = None 56 | frame_count: int | None = None 57 | event_trigger_source: EventTriggerSource | None = EventTriggerSource.UNKNOWN 58 | event_classification: EventClassification | None = EventClassification.UNKNOWN 59 | poster_frame_index: int | None = None 60 | access_token: str | None = None 61 | rfid_codes: list[str] | None = None 62 | 63 | @classmethod 64 | def from_api_response(cls, api_event: dict) -> Event | None: 65 | """Create an Event instance from API response data.""" 66 | if not api_event: 67 | return None 68 | timestamp = api_event.get("timestamp") 69 | trigger_source = api_event.get("eventTriggerSource") 70 | classification = api_event.get("eventClassification") 71 | 72 | return cls( 73 | global_id=api_event.get("globalId"), 74 | device_id=api_event.get("deviceId"), 75 | event_id=api_event.get("eventId"), 76 | timestamp=datetime.fromisoformat(timestamp) if timestamp else None, 77 | frame_count=api_event.get("frameCount"), 78 | event_trigger_source=EventTriggerSource(int(trigger_source)) 79 | if trigger_source 80 | else None, 81 | event_classification=EventClassification(int(classification)) 82 | if classification 83 | else None, 84 | poster_frame_index=api_event.get("posterFrameIndex"), 85 | access_token=api_event.get("accessToken"), 86 | rfid_codes=api_event.get("rfidCodes", []), 87 | ) 88 | 89 | def update_from(self, updated_event: Event) -> None: 90 | """Update the event with data from another event instance.""" 91 | if updated_event is None: 92 | return 93 | 94 | for field in fields(self): 95 | new_value = getattr(updated_event, field.name, None) 96 | if new_value is not None: 97 | setattr(self, field.name, new_value) 98 | 99 | 100 | @dataclass 101 | class EventUpdate: 102 | """Data representing an update to an OnlyCat flap event.""" 103 | 104 | device_id: str 105 | event_id: int 106 | type: Type 107 | event: Event 108 | 109 | @classmethod 110 | def from_api_response(cls, api_event: dict) -> EventUpdate | None: 111 | """Create an EventUpdate instance from API response data.""" 112 | device_id = api_event.get("deviceId", api_event.get("body").get("deviceId")) 113 | event_id = api_event.get("eventId", api_event.get("body").get("eventId")) 114 | event_type = ( 115 | Type(api_event.get("type")) if api_event.get("type") else Type.UNKNOWN 116 | ) 117 | event = Event.from_api_response(api_event.get("body")) 118 | if event.event_id is None: 119 | event.event_id = event_id 120 | return cls( 121 | device_id=device_id, 122 | event_id=event_id, 123 | type=event_type, 124 | event=event, 125 | ) 126 | -------------------------------------------------------------------------------- /custom_components/onlycat/device_tracker.py: -------------------------------------------------------------------------------- 1 | """Tracker platform for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.device_tracker import ( 9 | SourceType, 10 | TrackerEntity, 11 | TrackerEntityDescription, 12 | ) 13 | from homeassistant.const import STATE_HOME, STATE_NOT_HOME 14 | from homeassistant.helpers.device_registry import DeviceInfo 15 | 16 | from .const import DOMAIN 17 | from .data.event import Event, EventUpdate 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | if TYPE_CHECKING: 22 | from homeassistant.core import HomeAssistant 23 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 24 | 25 | from .api import OnlyCatApiClient 26 | from .data import OnlyCatConfigEntry 27 | from .data.device import Device 28 | from .data.pet import Pet 29 | 30 | ENTITY_DESCRIPTION = TrackerEntityDescription( 31 | key="OnlyCat", 32 | name="Pet Tracker", 33 | icon="mdi:cat", 34 | translation_key="onlycat_pet_tracker", 35 | ) 36 | 37 | 38 | async def async_setup_entry( 39 | hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` 40 | entry: OnlyCatConfigEntry, 41 | async_add_entities: AddEntitiesCallback, 42 | ) -> None: 43 | """Set up the tracker platform.""" 44 | if entry.runtime_data.pets: 45 | async_add_entities( 46 | sensor 47 | for pet in entry.runtime_data.pets 48 | for sensor in ( 49 | OnlyCatPetTracker( 50 | pet=pet, 51 | api_client=entry.runtime_data.client, 52 | ), 53 | ) 54 | ) 55 | 56 | 57 | class OnlyCatPetTracker(TrackerEntity): 58 | """OnlyCat Tracker class.""" 59 | 60 | _attr_has_entity_name = True 61 | _attr_should_poll = False 62 | _attr_source_type = SourceType.ROUTER 63 | 64 | @property 65 | def device_info(self) -> DeviceInfo: 66 | """Return device info to map to a device.""" 67 | return DeviceInfo( 68 | identifiers={(DOMAIN, self.device.device_id)}, 69 | name=self.device.description, 70 | serial_number=self.device.device_id, 71 | ) 72 | 73 | def determine_new_state(self, event: Event) -> None: 74 | """Determine the new state of the sensor based on the event.""" 75 | present = self.pet.is_present(event) 76 | if present is not None: 77 | self._attr_location_name = STATE_HOME if present else STATE_NOT_HOME 78 | 79 | if event.frame_count: 80 | self._current_event = Event() 81 | 82 | def __init__( 83 | self, 84 | pet: Pet, 85 | api_client: OnlyCatApiClient, 86 | ) -> None: 87 | """Initialize the sensor class.""" 88 | self.entity_description = ENTITY_DESCRIPTION 89 | self._attr_raw_data = None 90 | self.device: Device = pet.device 91 | self.pet: Pet = pet 92 | self._current_event: Event = Event() 93 | self.pet_name = pet.label if pet.label is not None else pet.rfid_code 94 | self._attr_translation_placeholders = { 95 | "pet_name": self.pet_name, 96 | } 97 | self._attr_unique_id = ( 98 | self.device.device_id.replace("-", "_").lower() 99 | + "_" 100 | + pet.rfid_code 101 | + "_tracker" 102 | ) 103 | self._api_client = api_client 104 | self.entity_id = "sensor." + self._attr_unique_id 105 | self._attr_location_name = STATE_NOT_HOME 106 | if pet.last_seen_event: 107 | self.determine_new_state(pet.last_seen_event) 108 | 109 | api_client.add_event_listener("deviceEventUpdate", self.on_event_update) 110 | api_client.add_event_listener("eventUpdate", self.on_event_update) 111 | 112 | async def on_event_update(self, data: dict) -> None: 113 | """Handle event update event.""" 114 | if data["deviceId"] != self.device.device_id: 115 | return 116 | 117 | self._current_event.update_from(EventUpdate.from_api_response(data).event) 118 | self.determine_new_state(self._current_event) 119 | self.async_write_ha_state() 120 | 121 | async def manual_update_location(self, location: str) -> None: 122 | """Manually override current state of a pets device tracker.""" 123 | if location not in (STATE_HOME, STATE_NOT_HOME): 124 | _LOGGER.debug("Manual update of location cannot be set to %s", location) 125 | return 126 | self._attr_location_name = location 127 | self.async_write_ha_state() 128 | -------------------------------------------------------------------------------- /custom_components/onlycat/tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Tests for OnlyCat/__init.py.""" 2 | 3 | from typing import Any 4 | from unittest.mock import AsyncMock, patch 5 | 6 | import pytest 7 | 8 | from custom_components.onlycat import _initialize_devices 9 | 10 | get_devices = [ 11 | # "Normal device" 12 | { 13 | "deviceId": "OC-00000000001", 14 | "description": "Device Name", 15 | "timeZone": "Europe/Zurich", 16 | "deviceTransitPolicyId": 0000, 17 | "cursorId": 0000, 18 | }, 19 | # Fresh device with no transit policy 20 | { 21 | "deviceId": "OC-00000000002", 22 | "description": "Device Name", 23 | "timeZone": "Europe/Zurich", 24 | "cursorId": 0000, 25 | }, 26 | ] 27 | get_device = { 28 | "OC-00000000001": { 29 | "deviceId": "OC-00000000001", 30 | "description": "Device Name", 31 | "timeZone": "Europe/Zurich", 32 | "deviceTransitPolicyId": 0000, 33 | "connectivity": { 34 | "connected": True, 35 | "disconnectReason": None, 36 | "timestamp": 1743841488269, 37 | }, 38 | }, 39 | "OC-00000000002": { 40 | "deviceId": "OC-00000000002", 41 | "description": "Device Name", 42 | "timeZone": "Europe/Zurich", 43 | "connectivity": { 44 | "connected": True, 45 | "disconnectReason": None, 46 | "timestamp": 1743841488269, 47 | }, 48 | }, 49 | } 50 | get_device_transit_policy = { 51 | 0: { 52 | "deviceTransitPolicyId": 0000, 53 | "deviceId": "OC-00000000001", 54 | "name": "Nachts", 55 | "transitPolicy": { 56 | "rules": [ 57 | { 58 | "action": {"lock": True}, 59 | "criteria": { 60 | "eventTriggerSource": 3, 61 | "eventClassification": [2, 3], 62 | }, 63 | "description": "Contraband Rule", 64 | }, 65 | { 66 | "action": {"lock": False}, 67 | "enabled": True, 68 | "criteria": { 69 | "rfidCode": [ 70 | "000000000000003", 71 | "000000000000001", 72 | "000000000000002", 73 | ], 74 | "eventTriggerSource": 3, 75 | }, 76 | "description": "Entry Rule", 77 | }, 78 | ], 79 | "idleLock": True, 80 | "idleLockBattery": True, 81 | }, 82 | } 83 | } 84 | device_update = [ 85 | { 86 | "type": "update", 87 | "deviceId": "OC-00000000002", 88 | "body": { 89 | "connectivity": { 90 | "connected": False, 91 | "disconnectReason": "SERVER_INITIATED_DISCONNECT", 92 | "timestamp": 1754114553075, 93 | } 94 | }, 95 | }, 96 | { 97 | "deviceId": "OC-00000000002", 98 | "type": "update", 99 | "body": {"deviceId": "OC-00000000002", "deviceTransitPolicyId": 0000}, 100 | }, 101 | ] 102 | 103 | 104 | async def mock_send_message(topic: str, data: dict) -> Any | None: 105 | """Mock of OnlyCatApiClient.send_message to return test api responses.""" 106 | if topic == "getDevices": 107 | return get_devices 108 | if topic == "getDevice": 109 | return get_device[data["deviceId"]] 110 | if topic == "getDeviceTransitPolicy": 111 | return get_device_transit_policy[data["deviceTransitPolicyId"]] 112 | return None 113 | 114 | 115 | @pytest.mark.asyncio 116 | async def test_initialize_devices() -> None: 117 | """Test _initialize_devices.""" 118 | mock_entry = AsyncMock() 119 | mock_entry.runtime_data.devices = [] 120 | mock_entry.runtime_data.client = AsyncMock() 121 | mock_entry.runtime_data.client.send_message.side_effect = mock_send_message 122 | with patch( 123 | "custom_components.onlycat._retrieve_current_transit_policy" 124 | ) as mock_retrieve_current_transit_policy: 125 | await _initialize_devices(mock_entry) 126 | 127 | assert len(mock_entry.runtime_data.devices) == len(get_devices) 128 | mock_entry.runtime_data.client.send_message.assert_any_call( 129 | "getDevices", {"subscribe": True} 130 | ) 131 | for device_id in get_device: 132 | mock_entry.runtime_data.client.send_message.assert_any_call( 133 | "getDevice", {"deviceId": device_id, "subscribe": True} 134 | ) 135 | assert mock_entry.runtime_data.devices[1].device_transit_policy_id is None 136 | assert mock_retrieve_current_transit_policy.call_count == 1 137 | -------------------------------------------------------------------------------- /custom_components/onlycat/data/device.py: -------------------------------------------------------------------------------- 1 | """Custom types for onlycat representing a Device.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import zoneinfo 7 | from dataclasses import dataclass, fields 8 | from datetime import UTC, datetime, tzinfo 9 | from typing import TYPE_CHECKING 10 | 11 | from .event import EventTriggerSource 12 | from .pet import PolicyResult 13 | from .type import Type 14 | 15 | if TYPE_CHECKING: 16 | from .event import Event 17 | from .policy import DeviceTransitPolicy 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | @dataclass 23 | class DeviceConnectivity: 24 | """Data representing the connectivity of an OnlyCat device.""" 25 | 26 | connected: bool 27 | disconnect_reason: str 28 | timestamp: datetime 29 | 30 | @classmethod 31 | def from_api_response(cls, api_connectivity: dict) -> DeviceConnectivity | None: 32 | """Create a DeviceConnectivity instance from API response data.""" 33 | if api_connectivity is None: 34 | return None 35 | 36 | return cls( 37 | connected=api_connectivity.get("connected"), 38 | disconnect_reason=api_connectivity.get("disconnectReason"), 39 | timestamp=datetime.fromtimestamp( 40 | api_connectivity.get("timestamp") / 1000.0, tz=UTC 41 | ), 42 | ) 43 | 44 | 45 | @dataclass 46 | class Device: 47 | """Data representing an OnlyCat device.""" 48 | 49 | device_id: str 50 | connectivity: DeviceConnectivity | None = None 51 | description: str | None = None 52 | time_zone: tzinfo | None = UTC 53 | device_transit_policy_id: int | None = None 54 | device_transit_policy: DeviceTransitPolicy | None = None 55 | 56 | @classmethod 57 | def from_api_response( 58 | cls, api_device: dict, device_id: str | None = None 59 | ) -> Device | None: 60 | """Create a Device instance from API response data.""" 61 | if api_device is None: 62 | return None 63 | timezone_str = api_device.get("timeZone") 64 | if timezone_str is not None: 65 | try: 66 | timezone = zoneinfo.ZoneInfo(timezone_str) 67 | except zoneinfo.ZoneInfoNotFoundError: 68 | _LOGGER.warning("Unable to parse timezone: %s", timezone_str) 69 | timezone = UTC 70 | else: 71 | timezone = UTC 72 | device_id = api_device.get("deviceId", device_id) 73 | if device_id is None: 74 | return None 75 | return cls( 76 | device_id=device_id, 77 | connectivity=DeviceConnectivity.from_api_response( 78 | api_device.get("connectivity") 79 | ), 80 | time_zone=timezone, 81 | description=api_device.get("description"), 82 | device_transit_policy_id=api_device.get("deviceTransitPolicyId"), 83 | ) 84 | 85 | def update_from(self, updated_device: Device) -> None: 86 | """Update the device with data from another Device instance.""" 87 | if updated_device is None: 88 | return 89 | 90 | for field in fields(self): 91 | new_value = getattr(updated_device, field.name, None) 92 | if new_value is not None: 93 | setattr(self, field.name, new_value) 94 | 95 | def is_unlocked_in_idle_state(self) -> bool | None: 96 | """Check if the device is unlocked in idle state.""" 97 | if ( 98 | not self.device_transit_policy 99 | or not self.device_transit_policy.transit_policy 100 | ): 101 | _LOGGER.debug("Unable to determine lock state, no transit policy set.") 102 | return None 103 | 104 | return not self.device_transit_policy.transit_policy.idle_lock 105 | 106 | def is_unlocked_by_event(self, event: Event) -> bool | None: 107 | """Check if the device is unlocked by the given event.""" 108 | if event.event_trigger_source == EventTriggerSource.REMOTE: 109 | return True 110 | policy_result = self.device_transit_policy.determine_policy_result(event) 111 | if policy_result == PolicyResult.UNLOCKED: 112 | return True 113 | if policy_result == PolicyResult.LOCKED: 114 | return False 115 | return None 116 | 117 | 118 | @dataclass 119 | class DeviceUpdate: 120 | """Data representing an update to a device.""" 121 | 122 | device_id: str 123 | type: Type 124 | body: Device 125 | 126 | @classmethod 127 | def from_api_response(cls, api_event: dict) -> DeviceUpdate | None: 128 | """Create a DeviceUpdate instance from API response data.""" 129 | if api_event is None: 130 | return None 131 | return cls( 132 | device_id=api_event["deviceId"], 133 | type=Type(api_event["type"]) if api_event.get("type") else Type.UNKNOWN, 134 | body=Device.from_api_response( 135 | api_event.get("body"), device_id=api_event["deviceId"] 136 | ), 137 | ) 138 | -------------------------------------------------------------------------------- /custom_components/onlycat/select.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for OnlyCat.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.select import ( 9 | SelectEntity, 10 | SelectEntityDescription, 11 | ) 12 | from homeassistant.const import EntityCategory 13 | from homeassistant.helpers.device_registry import DeviceInfo 14 | 15 | from .const import DOMAIN 16 | from .data.device import DeviceUpdate 17 | from .data.policy import DeviceTransitPolicy 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | if TYPE_CHECKING: 22 | from homeassistant.core import HomeAssistant 23 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 24 | 25 | from .api import OnlyCatApiClient 26 | from .data import Device, OnlyCatConfigEntry 27 | 28 | ENTITY_DESCRIPTION = SelectEntityDescription( 29 | key="OnlyCat", 30 | name="Door Policy", 31 | entity_category=EntityCategory.CONFIG, 32 | icon="mdi:home-clock", 33 | translation_key="onlycat_policy_select", 34 | ) 35 | 36 | 37 | async def load_policies( 38 | api_client: OnlyCatApiClient, device_id: str 39 | ) -> list[DeviceTransitPolicy]: 40 | """Load policies for a device.""" 41 | return [ 42 | DeviceTransitPolicy.from_api_response(policy) 43 | for policy in await api_client.send_message( 44 | "getDeviceTransitPolicies", {"deviceId": device_id} 45 | ) 46 | ] 47 | 48 | 49 | async def async_setup_entry( 50 | hass: HomeAssistant, # noqa: ARG001 Unused function argument: `hass` 51 | entry: OnlyCatConfigEntry, 52 | async_add_entities: AddEntitiesCallback, 53 | ) -> None: 54 | """Set up the sensor platform.""" 55 | entities = [] 56 | for device in entry.runtime_data.devices: 57 | policies = await load_policies(entry.runtime_data.client, device.device_id) 58 | entities.append( 59 | OnlyCatPolicySelect( 60 | device=device, 61 | policies=policies, 62 | entity_description=ENTITY_DESCRIPTION, 63 | api_client=entry.runtime_data.client, 64 | ) 65 | ) 66 | async_add_entities(entities) 67 | 68 | 69 | class OnlyCatPolicySelect(SelectEntity): 70 | """Door policy for the flap.""" 71 | 72 | _attr_has_entity_name = True 73 | _attr_should_poll = False 74 | entity_category = EntityCategory.CONFIG 75 | _attr_translation_key = "onlycat_policy_select" 76 | 77 | @property 78 | def device_info(self) -> DeviceInfo: 79 | """Return device info to map to a device.""" 80 | return DeviceInfo( 81 | identifiers={(DOMAIN, self.device.device_id)}, 82 | name=self.device.description, 83 | serial_number=self.device.device_id, 84 | ) 85 | 86 | def __init__( 87 | self, 88 | device: Device, 89 | policies: list[DeviceTransitPolicy], 90 | entity_description: SelectEntityDescription, 91 | api_client: OnlyCatApiClient, 92 | ) -> None: 93 | """Initialize the sensor class.""" 94 | self.entity_description = entity_description 95 | self._state = None 96 | self._attr_raw_data = None 97 | self._api_client = api_client 98 | self._attr_unique_id = device.device_id.replace("-", "_").lower() + "_policy" 99 | self.entity_id = "select." + self._attr_unique_id 100 | self._attr_options = [policy.name for policy in policies] 101 | self.device: Device = device 102 | self._policies = policies 103 | if device.device_transit_policy_id is not None: 104 | self.set_current_policy(device.device_transit_policy_id) 105 | api_client.add_event_listener("deviceUpdate", self.on_device_update) 106 | 107 | def set_current_policy(self, policy_id: int) -> None: 108 | """Set the current policy.""" 109 | _LOGGER.debug( 110 | "Setting policy %s for device %s", policy_id, self.device.device_id 111 | ) 112 | 113 | self._attr_current_option = next( 114 | p.name for p in self._policies if p.device_transit_policy_id == policy_id 115 | ) 116 | 117 | async def on_device_update(self, data: dict) -> None: 118 | """Handle device update event.""" 119 | if data["deviceId"] != self.device.device_id: 120 | return 121 | 122 | _LOGGER.debug("Device update event received for select: %s", data) 123 | 124 | device_update = DeviceUpdate.from_api_response(data) 125 | if device_update.body.device_transit_policy_id: 126 | # Reload policies in case a new policy got added in the meantime 127 | self._policies = await load_policies( 128 | self._api_client, self.device.device_id 129 | ) 130 | self._attr_options = [policy.name for policy in self._policies] 131 | self.set_current_policy(device_update.body.device_transit_policy_id) 132 | self.async_write_ha_state() 133 | 134 | async def async_select_option(self, option: str) -> None: 135 | """Activate a device policy.""" 136 | _LOGGER.debug("Setting policy %s for device %s", option, self.device.device_id) 137 | policy_id = next( 138 | p.device_transit_policy_id for p in self._policies if p.name == option 139 | ) 140 | await self._api_client.send_message( 141 | "activateDeviceTransitPolicy", 142 | {"deviceId": self.device.device_id, "deviceTransitPolicyId": policy_id}, 143 | ) 144 | -------------------------------------------------------------------------------- /custom_components/onlycat/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom integration to integrate OnlyCat with Home Assistant. 3 | 4 | For more details about this integration, please refer to 5 | https://github.com/OnlyCatAI/onlycat-home-assistant 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import logging 11 | from datetime import datetime 12 | from typing import TYPE_CHECKING 13 | 14 | from homeassistant.const import Platform 15 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 16 | 17 | from .api import OnlyCatApiClient 18 | from .data.__init__ import OnlyCatConfigEntry, OnlyCatData 19 | from .data.device import Device, DeviceUpdate 20 | from .data.event import Event 21 | from .data.pet import Pet 22 | from .data.policy import DeviceTransitPolicy 23 | from .services import async_setup_services 24 | 25 | if TYPE_CHECKING: 26 | from homeassistant.core import HomeAssistant 27 | 28 | PLATFORMS: list[Platform] = [ 29 | Platform.BINARY_SENSOR, 30 | Platform.SELECT, 31 | Platform.DEVICE_TRACKER, 32 | Platform.BUTTON, 33 | ] 34 | _LOGGER = logging.getLogger(__name__) 35 | 36 | 37 | # https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry 38 | async def async_setup_entry( 39 | hass: HomeAssistant, 40 | entry: OnlyCatConfigEntry, 41 | ) -> bool: 42 | """Set up this integration using UI.""" 43 | entry.runtime_data = OnlyCatData( 44 | client=OnlyCatApiClient( 45 | token=entry.data["token"], 46 | session=async_get_clientsession(hass), 47 | ), 48 | devices=[], 49 | pets=[], 50 | ) 51 | await entry.runtime_data.client.connect() 52 | 53 | await _initialize_devices(entry) 54 | await _initialize_pets(entry) 55 | 56 | async def refresh_subscriptions(args: dict | None) -> None: 57 | _LOGGER.debug("Refreshing subscriptions, caused by event: %s", args) 58 | for device in entry.runtime_data.devices: 59 | await entry.runtime_data.client.send_message( 60 | "getDevice", {"deviceId": device.device_id, "subscribe": True} 61 | ) 62 | await entry.runtime_data.client.send_message( 63 | "getDeviceEvents", {"deviceId": device.device_id, "subscribe": True} 64 | ) 65 | 66 | async def update_device(data: dict) -> None: 67 | """Update a device in our runtime data when it is changed.""" 68 | update = DeviceUpdate.from_api_response(data) 69 | 70 | for device in entry.runtime_data.devices: 71 | if device.device_id == update.device_id: 72 | updated_device = Device.from_api_response( 73 | await entry.runtime_data.client.send_message( 74 | "getDevice", {"deviceId": update.device_id, "subscribe": True} 75 | ) 76 | ) 77 | device.update_from(updated_device) 78 | if device.device_transit_policy_id is not None: 79 | await _retrieve_current_transit_policy(entry, device) 80 | _LOGGER.debug("Updated device: %s", device) 81 | break 82 | else: 83 | _LOGGER.warning( 84 | "Device with ID %s not found in runtime data", update.device_id 85 | ) 86 | 87 | async def subscribe_to_device_event(data: dict) -> None: 88 | """Subscribe to a device event to get updates about the event in the future.""" 89 | await entry.runtime_data.client.send_message( 90 | "getEvent", 91 | { 92 | "deviceId": data["deviceId"], 93 | "eventId": data["eventId"], 94 | "subscribe": True, 95 | }, 96 | ) 97 | 98 | await refresh_subscriptions(None) 99 | entry.runtime_data.client.add_event_listener("connect", refresh_subscriptions) 100 | entry.runtime_data.client.add_event_listener("userUpdate", refresh_subscriptions) 101 | entry.runtime_data.client.add_event_listener("deviceUpdate", update_device) 102 | entry.runtime_data.client.add_event_listener( 103 | "deviceEventUpdate", subscribe_to_device_event 104 | ) 105 | 106 | await async_setup_services(hass) 107 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 108 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 109 | return True 110 | 111 | 112 | async def _initialize_devices(entry: OnlyCatConfigEntry) -> None: 113 | device_ids = ( 114 | device["deviceId"] 115 | for device in await entry.runtime_data.client.send_message( 116 | "getDevices", {"subscribe": True} 117 | ) 118 | ) 119 | for device_id in device_ids: 120 | device = Device.from_api_response( 121 | await entry.runtime_data.client.send_message( 122 | "getDevice", {"deviceId": device_id, "subscribe": True} 123 | ) 124 | ) 125 | entry.runtime_data.devices.append(device) 126 | 127 | for device in entry.runtime_data.devices: 128 | if device.device_transit_policy_id is not None: 129 | await _retrieve_current_transit_policy(entry, device) 130 | 131 | 132 | async def _retrieve_current_transit_policy( 133 | entry: OnlyCatConfigEntry, device: Device 134 | ) -> None: 135 | if device.device_transit_policy_id is None: 136 | return 137 | transit_policy = DeviceTransitPolicy.from_api_response( 138 | await entry.runtime_data.client.send_message( 139 | "getDeviceTransitPolicy", 140 | {"deviceTransitPolicyId": device.device_transit_policy_id}, 141 | ) 142 | ) 143 | transit_policy.device = device 144 | device.device_transit_policy = transit_policy 145 | 146 | 147 | async def _initialize_pets(entry: OnlyCatConfigEntry) -> None: 148 | for device in entry.runtime_data.devices: 149 | events = [ 150 | Event.from_api_response(event) 151 | for event in await entry.runtime_data.client.send_message( 152 | "getDeviceEvents", {"deviceId": device.device_id} 153 | ) 154 | ] 155 | rfids = await entry.runtime_data.client.send_message( 156 | "getLastSeenRfidCodesByDevice", {"deviceId": device.device_id} 157 | ) 158 | for rfid in rfids: 159 | rfid_code = rfid["rfidCode"] 160 | last_seen = datetime.fromisoformat(rfid["timestamp"]) 161 | rfid_profile = await entry.runtime_data.client.send_message( 162 | "getRfidProfile", {"rfidCode": rfid_code} 163 | ) 164 | label = rfid_profile.get("label") 165 | pet = Pet(device, rfid_code, last_seen, label=label) 166 | _LOGGER.debug( 167 | "Found Pet %s for device %s", 168 | label if label else rfid_code, 169 | device.device_id, 170 | ) 171 | entry.runtime_data.pets.append(pet) 172 | 173 | # Get last seen event to determine current presence state 174 | for event in events: 175 | if event.rfid_codes and pet.rfid_code in event.rfid_codes: 176 | pet.last_seen_event = event 177 | break 178 | 179 | 180 | async def async_unload_entry( 181 | hass: HomeAssistant, 182 | entry: OnlyCatConfigEntry, 183 | ) -> bool: 184 | """Handle removal of an entry.""" 185 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 186 | 187 | 188 | async def async_reload_entry( 189 | hass: HomeAssistant, 190 | entry: OnlyCatConfigEntry, 191 | ) -> None: 192 | """Reload config entry.""" 193 | await async_unload_entry(hass, entry) 194 | await async_setup_entry(hass, entry) 195 | -------------------------------------------------------------------------------- /custom_components/onlycat/data/policy.py: -------------------------------------------------------------------------------- 1 | """Custom types for onlycat representing transit policies.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from dataclasses import dataclass 7 | from datetime import datetime, timedelta, tzinfo 8 | from enum import Enum, StrEnum 9 | from typing import TYPE_CHECKING 10 | 11 | from .event import Event, EventClassification, EventTriggerSource 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Callable 15 | 16 | from .device import Device 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | def map_api_list_or_obj(api_obj: list | object, mapper: Callable) -> list | None: 22 | """Map a single object or list of objects from the API using the mapper function.""" 23 | if isinstance(api_obj, list): 24 | return [mapper(obj) for obj in api_obj] 25 | if api_obj: 26 | return [mapper(api_obj)] 27 | return None 28 | 29 | 30 | class PolicyResult(Enum): 31 | """Enum representing the result of a policy given a specific event.""" 32 | 33 | UNKNOWN = 0 34 | LOCKED = 1 35 | UNLOCKED = 2 36 | 37 | 38 | class SoundAction(StrEnum): 39 | """Enum representing the sound actions available in a transit policy rule.""" 40 | 41 | UNKNOWN = "unknown" 42 | AFFIRM = "affirm" 43 | ALARM = "alarm" 44 | ANGRY_MEOW = "angry-meow" 45 | BELL = "bell" 46 | CHOIR = "choir" 47 | COIN = "coin" 48 | DENY = "deny" 49 | FANFARE = "fanfare" 50 | SUCCESS = "success" 51 | 52 | @classmethod 53 | def _missing_(cls, value: str) -> SoundAction: 54 | """Handle missing enum values in case of API extensions.""" 55 | _LOGGER.warning("Unknown sound action: %s", value) 56 | return cls.UNKNOWN 57 | 58 | 59 | @dataclass 60 | class RuleAction: 61 | """Data representing an action in a transit policy rule.""" 62 | 63 | lock: bool 64 | lockout_duration: int 65 | sound: SoundAction | None = None 66 | 67 | @classmethod 68 | def from_api_response(cls, api_action: dict) -> RuleAction | None: 69 | """Create a RuleAction instance from API response data.""" 70 | if api_action is None: 71 | return None 72 | 73 | sound = api_action.get("sound") 74 | 75 | return cls( 76 | lock=api_action.get("lock"), 77 | lockout_duration=api_action.get("lockoutDuration"), 78 | sound=SoundAction(sound) if sound else None, 79 | ) 80 | 81 | 82 | @dataclass 83 | class TimeRange: 84 | """Data representing a range of time when a rule criteria is active.""" 85 | 86 | start_hour: int 87 | start_minute: int 88 | end_hour: int 89 | end_minute: int 90 | 91 | @classmethod 92 | def from_api_response(cls, api_time_range: str) -> TimeRange | None: 93 | """Create a TimeRange instance from API response data.""" 94 | if api_time_range is None: 95 | return None 96 | 97 | start_time, end_time = api_time_range.split("-") 98 | start_hour, start_minute = map(int, start_time.split(":")) 99 | end_hour, end_minute = map(int, end_time.split(":")) 100 | 101 | return cls( 102 | start_hour=start_hour, 103 | start_minute=start_minute, 104 | end_hour=end_hour, 105 | end_minute=end_minute, 106 | ) 107 | 108 | def contains_timestamp(self, timestamp: datetime, timezone: tzinfo) -> bool: 109 | """Check if the given timestamp is within this time range.""" 110 | event_time = timestamp.astimezone(timezone) 111 | start_time = event_time.replace( 112 | hour=self.start_hour, minute=self.start_minute, second=0, microsecond=0 113 | ) 114 | end_time = event_time.replace( 115 | hour=self.end_hour, minute=self.end_minute, second=59, microsecond=999999 116 | ) 117 | 118 | # Handle overnight ranges (e.g., 22:00-02:00) 119 | if start_time > end_time: 120 | if start_time > event_time: 121 | start_time = start_time - timedelta(days=1) 122 | else: 123 | end_time = end_time + timedelta(days=1) 124 | 125 | return start_time <= event_time <= end_time 126 | 127 | 128 | @dataclass 129 | class RuleCriteria: 130 | """Data representing criteria for a rule in a transit policy.""" 131 | 132 | event_trigger_sources: list[EventTriggerSource] 133 | event_classifications: list[EventClassification] 134 | time_ranges: list[TimeRange] 135 | rfid_codes: list[str] 136 | rfid_timeout: int 137 | 138 | @classmethod 139 | def from_api_response(cls, api_criteria: dict) -> RuleCriteria | None: 140 | """Create a RuleCriteria instance from API response data.""" 141 | if api_criteria is None: 142 | return None 143 | 144 | trigger_source = map_api_list_or_obj( 145 | api_criteria.get("eventTriggerSource"), lambda x: EventTriggerSource(x) 146 | ) 147 | classification = map_api_list_or_obj( 148 | api_criteria.get("eventClassification"), lambda x: EventClassification(x) 149 | ) 150 | time_range = map_api_list_or_obj( 151 | api_criteria.get("timeRange"), lambda x: TimeRange.from_api_response(x) 152 | ) 153 | rfid_code = map_api_list_or_obj(api_criteria.get("rfidCode"), lambda x: x) 154 | 155 | return cls( 156 | event_trigger_sources=trigger_source, 157 | event_classifications=classification, 158 | time_ranges=time_range, 159 | rfid_codes=rfid_code, 160 | rfid_timeout=api_criteria.get("rfidTimeout"), 161 | ) 162 | 163 | def matches(self, event: Event, timezone: tzinfo) -> bool: 164 | """Check if the event matches the criteria of this rule.""" 165 | if ( 166 | self.event_trigger_sources 167 | and event.event_trigger_source not in self.event_trigger_sources 168 | ): 169 | return False 170 | 171 | if ( 172 | self.event_classifications 173 | and event.event_classification not in self.event_classifications 174 | ): 175 | return False 176 | 177 | if self.rfid_codes and not any( 178 | code in self.rfid_codes for code in event.rfid_codes 179 | ): 180 | return False 181 | 182 | return not self.time_ranges or any( 183 | time_range.contains_timestamp(event.timestamp, timezone) 184 | for time_range in self.time_ranges 185 | ) 186 | 187 | 188 | @dataclass 189 | class Rule: 190 | """Data representing a rule in a transit policy.""" 191 | 192 | action: RuleAction 193 | criteria: RuleCriteria 194 | description: str 195 | enabled: bool | None 196 | 197 | @classmethod 198 | def from_api_rule(cls, api_rule: dict) -> Rule | None: 199 | """Create a Rule instance from API response data.""" 200 | if api_rule is None: 201 | return None 202 | 203 | return cls( 204 | action=RuleAction.from_api_response(api_rule.get("action")), 205 | criteria=RuleCriteria.from_api_response(api_rule.get("criteria")), 206 | description=api_rule.get("description"), 207 | enabled=api_rule.get("enabled", True), # Default to True if not specified 208 | ) 209 | 210 | 211 | @dataclass 212 | class TransitPolicy: 213 | """Data representing a transit policy for an OnlyCat device.""" 214 | 215 | rules: list[Rule] 216 | idle_lock: bool 217 | idle_lock_battery: bool 218 | 219 | @classmethod 220 | def from_api_response(cls, api_policy: dict) -> TransitPolicy | None: 221 | """Create a TransitPolicy instance from API response data.""" 222 | if api_policy is None: 223 | return None 224 | 225 | rules = api_policy.get("rules") 226 | 227 | return cls( 228 | rules=[Rule.from_api_rule(rule) for rule in rules] if rules else None, 229 | idle_lock=api_policy.get("idleLock"), 230 | idle_lock_battery=api_policy.get("idleLockBattery"), 231 | ) 232 | 233 | 234 | @dataclass 235 | class DeviceTransitPolicy: 236 | """Data representing a transit policy for an OnlyCat device.""" 237 | 238 | device_transit_policy_id: int 239 | device_id: str 240 | name: str | None = None 241 | transit_policy: TransitPolicy | None = None 242 | device: Device | None = None 243 | 244 | @classmethod 245 | def from_api_response(cls, api_policy: dict) -> DeviceTransitPolicy | None: 246 | """Create a DeviceTransitPolicy instance from API response data.""" 247 | if api_policy is None or "deviceTransitPolicyId" not in api_policy: 248 | return None 249 | 250 | return cls( 251 | device_transit_policy_id=api_policy["deviceTransitPolicyId"], 252 | device_id=api_policy["deviceId"], 253 | name=api_policy.get("name"), 254 | transit_policy=TransitPolicy.from_api_response( 255 | api_policy.get("transitPolicy") 256 | ), 257 | ) 258 | 259 | def determine_policy_result(self, event: Event) -> PolicyResult: 260 | """Determine the policy result for a given event.""" 261 | if not self.transit_policy: 262 | _LOGGER.warning( 263 | "No transit policy set, unable to determine policy result for event %s", 264 | event.event_id, 265 | ) 266 | return PolicyResult.UNKNOWN 267 | 268 | if self.transit_policy.rules: 269 | for rule in self.transit_policy.rules: 270 | if not rule.criteria or not rule.criteria.matches( 271 | event, self.device.time_zone 272 | ): 273 | continue 274 | result = ( 275 | PolicyResult.LOCKED if rule.action.lock else PolicyResult.UNLOCKED 276 | ) 277 | _LOGGER.debug( 278 | "Rule %s matched for event %s, result is: %s", 279 | rule, 280 | event.event_id, 281 | result, 282 | ) 283 | return result 284 | 285 | _LOGGER.debug( 286 | "No matching rules found for event %s, result is equal to idle lock: %s", 287 | event.event_id, 288 | self.transit_policy.idle_lock, 289 | ) 290 | return ( 291 | PolicyResult.LOCKED 292 | if self.transit_policy.idle_lock 293 | else PolicyResult.UNLOCKED 294 | ) 295 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------