diff --git a/.devcontainer/.config_dir b/.devcontainer/.config_dir deleted file mode 120000 index ef1066e..0000000 --- a/.devcontainer/.config_dir +++ /dev/null @@ -1 +0,0 @@ -/config \ No newline at end of file diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml deleted file mode 100644 index 1c5ebb3..0000000 --- a/.devcontainer/configuration.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Loads default set of integrations. Do not remove. -default_config: - -#homeassistant: -# internal_url: !env_var INTERNAL_URL -logger: - default: info - logs: - async_reolink.rest: debug - async_reolink.api: debug - custom_components.reolink_rest: debug - - -# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) -debugpy: \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b0571be..a74c5a2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,8 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ghcr.io/ludeeus/devcontainer/integration:stable", - "name": "ReoLink Discovery integration development", - "context": "..", + "name": "xannor/ha_reolink_discovery", + "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.11", + "postCreateCommand": "scripts/setup", "appPort": [ "9123:8123", "3000:3000/udp" @@ -22,25 +22,51 @@ } }, "forwardPorts": [], - "postCreateCommand": "container install", - "extensions": [ - "ms-python.python", - "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 4, - "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } + "otherPortsAttributes": { + "onAutoForward": "ignore" + }, + "runArgs": ["-e", "GIT_EDITOR=code --wait"], + "customizations": { + "vscode": { + "extensions": [ + "ms-python.vscode-pylance", + "visualstudioexptteam.vscodeintellicode", + "redhat.vscode-yaml", + "esbenp.prettier-vscode", + "GitHub.vscode-pull-request-github" + ], + // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json + "settings": { + "python.pythonPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.blackPath": "/usr/local/bin/black", + "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", + "python.linting.mypyPath": "/usr/local/bin/mypy", + "python.linting.pylintPath": "/usr/local/bin/pylint", + "python.formatting.provider": "black", + "python.testing.pytestArgs": ["--no-cov"], + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", + "yaml.customTags": [ + "!input scalar", + "!secret scalar", + "!include_dir_named scalar", + "!include_dir_list scalar", + "!include_dir_merge_list scalar", + "!include_dir_merge_named scalar" + ] + } + } + }, + "remoteUser": "vscode" } \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..eca98fc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Ensure Docker script files uses LF to support Docker for Windows. +# Ensure "git config --global core.autocrlf input" before you clone +* text eol=lf +*.py whitespace=error + +*.ico binary +*.jpg binary +*.png binary +*.zip binary +*.mp3 binary +*.pcm binary + +Dockerfile.dev linguist-language=Dockerfile diff --git a/.gitignore b/.gitignore index be63c4a..8a4154e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,134 @@ -# Python Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -.config/ \ No newline at end of file +/config +config2/* + +tests/testing_config/deps +tests/testing_config/home-assistant.log* + +# hass-release +data/ +.token + +# Translations +homeassistant/components/*/translations + +# Hide sublime text stuff +*.sublime-project +*.sublime-workspace + +# Hide some OS X stuff +.DS_Store +.AppleDouble +.LSOverride +Icon + +# Thumbnails +._* + +# IntelliJ IDEA +.idea +*.iml + +# pytest +.pytest_cache +.cache + +# GITHUB Proposed Python stuff: +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +.eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +pip-wheel-metadata + +# Logs +*.log +pip-log.txt + +# Unit test / coverage reports +.coverage +coverage.xml +nosetests.xml +htmlcov/ +test-reports/ +test-results.xml +test-output.xml +pytest-*.txt + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +.python-version + +# emacs auto backups +*~ +*# +*.orig + +# venv stuff +pyvenv.cfg +pip-selfcheck.json +venv +.venv +Pipfile* +share/* +/Scripts/ + +# vimmy stuff +*.swp +*.swo +tags +ctags.tmp + +# vagrant stuff +virtualization/vagrant/setup_done +virtualization/vagrant/.vagrant +virtualization/vagrant/config + +# Visual Studio Code +.vscode/* +!.vscode/cSpell.json +!.vscode/extensions.json +!.vscode/tasks.json +.env + +# Windows Explorer +desktop.ini +/home-assistant.pyproj +/home-assistant.sln +/.vs/* + +# mypy +/.mypy_cache/* +/.dmypy.json + +# Secrets +.lokalise_token + +# monkeytype +monkeytype.sqlite3 + +# This is left behind by Azure Restore Cache +tmp_cache + +# python-language-server / Rope +.ropeproject diff --git a/.vscode/extensions.json b/.vscode/extensions.json index ef9347f..eb75a72 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,4 @@ { - "recommendations": [ - "ms-python.black-formatter", - "ms-python.vscode-pylance", - "redhat.vscode-yaml", - "esbenp.prettier-vscode", - "GitHub.vscode-pull-request-github" - ] -} \ No newline at end of file + "recommendations": ["esbenp.prettier-vscode", "ms-python.python"] + } + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 46035b3..f603c09 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,38 +1,64 @@ { + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { - // Example of attaching to local debug server - "name": "Python: Attach Local", - "type": "python", - "request": "attach", - "port": 5678, - "host": "localhost", - "pathMappings": [ - { - "localRoot": "${workspaceFolder}", - "remoteRoot": "." - }, - { - "localRoot": "${workspaceFolder}/custom_components", - "remoteRoot": "/config/custom_components" - } - ] - }, - { - // Example of attaching to my production server - "name": "Python: Attach Remote", - "type": "python", - "request": "attach", - "port": 5678, - "host": "homeassistant.local", - "pathMappings": [ - { - "localRoot": "${workspaceFolder}", - "remoteRoot": "/usr/src/homeassistant" - } - ] - } + { + "name": "Home Assistant", + "type": "python", + "request": "launch", + "module": "homeassistant", + "justMyCode": false, + "args": ["--debug", "-c", "config"], + "preLaunchTask": "Update strings" + }, + { + "name": "Home Assistant (skip pip)", + "type": "python", + "request": "launch", + "module": "homeassistant", + "justMyCode": false, + "args": ["--debug", "-c", "config", "--skip-pip"], + "preLaunchTask": "Update strings" + }, + { + "name": "Home Assistant: Changed tests", + "type": "python", + "request": "launch", + "module": "pytest", + "justMyCode": false, + "args": ["--timeout=10", "--picked"], + }, + { + // Debug by attaching to local Home Assistant server using Remote Python Debugger. + // See https://www.home-assistant.io/integrations/debugpy/ + "name": "Home Assistant: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + // Debug by attaching to remote Home Assistant server using Remote Python Debugger. + // See https://www.home-assistant.io/integrations/debugpy/ + "name": "Home Assistant: Attach Remote", + "type": "python", + "request": "attach", + "port": 5678, + "host": "homeassistant.local", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/usr/src/homeassistant" + } + ] + } ] -} \ No newline at end of file + } diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json new file mode 100644 index 0000000..0327253 --- /dev/null +++ b/.vscode/settings.default.json @@ -0,0 +1,10 @@ +{ + // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json + "python.formatting.provider": "black", + // Added --no-cov to work around TypeError: message must be set + // https://github.com/microsoft/vscode-python/issues/14067 + "python.testing.pytestArgs": ["--no-cov"], + // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings + "python.testing.pytestEnabled": false + } + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 6855a81..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.defaultInterpreterPath": "/usr/bin/python", - "files.associations": { - "*.yaml": "home-assistant" - } -} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 58da3e5..1d0a475 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,28 +2,11 @@ "version": "2.0.0", "tasks": [ { - "label": "Run Home Assistant on port 9123", + "label": "Run Home Assistant on port 8123", "type": "shell", - "command": "container start", - "problemMatcher": [] - }, - { - "label": "Run Home Assistant configuration against /config", - "type": "shell", - "command": "container check", - "problemMatcher": [] - }, - { - "label": "Upgrade Home Assistant to latest dev", - "type": "shell", - "command": "container install", - "problemMatcher": [] - }, - { - "label": "Install a specific version of Home Assistant", - "type": "shell", - "command": "container set-version", - "problemMatcher": [] + "command": "scripts/develop", + "problemMatcher": [], + "dependsOn": ["Update strings"] }, { "label": "Update strings", @@ -32,4 +15,4 @@ "group": "build" } ] -} \ No newline at end of file +} diff --git a/README.md b/README.md index 18dad15..757ddc6 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,65 @@ -# Reolink Device Discovery for Home Assistant - -[![GitHub Release][releases-shield]][releases] -[![GitHub Activity][commits-shield]][commits] -[![License][license-shield]][license] - -[![hacs][hacsbadge]][hacs] -[![Project Maintenance][maintenance-shield]][user_profile] - - -[![Community Forum][forum-shield]][forum] - -This "helper" component will implement the ReoLink Device discovery protocol, broadcasting to the local network a request for -reolink devices to identify themselves. For every device that replies, the information is relayed into Home Assistant via a local event. - -This component is HACS compatible and only has a dependency on the existance of the network component (should be in a standard setup) - -This does require the ability to send a broadcast package to the network and the ability to receive a reply. Because of the type of packet there are some limitations, or additional work that may need to be done, depending on your setup. - -This component only relays an event, it provides no functionallity for handling/managing discovered devices, that is left to separate components. This is so other compoments can take advantage of discovery without needing a hard coded reference or library. - -[Developer Info](./developer.md) - -## Installation - -1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). -2. If you do not have a `custom_components` directory (folder) there, you need to create it. -3. In the `custom_components` directory (folder) create a new folder called `reolink_discovery`. -4. Download _all_ the files from the `custom_components/reolink_discovery/` directory (folder) in this repository. -5. Place the files you downloaded in the new directory (folder) you created. -6. Restart Home Assistant -7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Reolink Discovery" - -## Configuration is done in the UI - -The default is to send a UDP (port 2000) request to the local network broadcast range of the install and await a UDP reply on port 3000. If your setup is behind a firewall, or is in a separate network (for example a custom docker setup) you may need to add 3000/udp to the forwarded ports. - -Once the integration is installed it should be operating, to see it you can use the developer tools panel and listen for event reolink_discovery, it should occur about every 30 seconds by default. - -The two configurable options are the interval and the broadcast address. - -Interval lets you adjust how often it will send a request. - -Broadcast address is to handle setups where the network that Home Assistant sees, is not the actual local network, in here you can provide a new IP address to broadcast to. For example, if you are running this in a devcontainer, you will not see any results because broadcast packets are not forwarded by default, if you change this to your computers ip address, you should start to see replies. - -[Developer info](./developer.md) - - - -*** - -[reolink_discovery]: https://github.com/xannor/ha_reolink_discovery -[commits-shield]: https://img.shields.io/github/commit-activity/y/xannor/ha_reolink_discovery.svg?style=for-the-badge -[commits]: https://github.com/xannor/ha_reolink_discovery/commits/master -[hacs]: https://hacs.xyz -[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge -[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge -[forum]: https://community.home-assistant.io/ -[license]: https://github.com/xannor/ha_reolink_discovery/blob/main/LICENSE -[license-shield]: https://img.shields.io/github/license/xannor/ha_reolink_discovery.svg?style=for-the-badge -[maintenance-shield]: https://img.shields.io/badge/maintainer-Xannor%20%40xannor-blue.svg?style=for-the-badge -[releases-shield]: https://img.shields.io/github/release/xannor/ha_reolink_discovery.svg?style=for-the-badge -[releases]: https://github.com/xannor/ha_reolink_discovery/releases -[user_profile]: https://github.com/xannor - +# Reolink Device Discovery for Home Assistant + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]][license] + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] + + +[![Community Forum][forum-shield]][forum] + +This "helper" component will implement the ReoLink Device discovery protocol, broadcasting to the local network a request for +reolink devices to identify themselves. For every device that replies, the information is relayed into Home Assistant via a local event. + +This component is HACS compatible and only has a dependency on the existance of the network component (should be in a standard setup) + +This does require the ability to send a broadcast package to the network and the ability to receive a reply. Because of the type of packet there are some limitations, or additional work that may need to be done, depending on your setup. + +This component only relays an event, it provides no functionallity for handling/managing discovered devices, that is left to separate components. This is so other compoments can take advantage of discovery without needing a hard coded reference or library. + +[Developer Info](./developer.md) + +## Installation + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +2. If you do not have a `custom_components` directory (folder) there, you need to create it. +3. In the `custom_components` directory (folder) create a new folder called `reolink_discovery`. +4. Download _all_ the files from the `custom_components/reolink_discovery/` directory (folder) in this repository. +5. Place the files you downloaded in the new directory (folder) you created. +6. Restart Home Assistant +7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Reolink Discovery" + +## Configuration is done in the UI + +The default is to send a UDP (port 2000) request to the local network broadcast range of the install and await a UDP reply on port 3000. If your setup is behind a firewall, or is in a separate network (for example a custom docker setup) you may need to add 3000/udp to the forwarded ports. + +Once the integration is installed it should be operating, to see it you can use the developer tools panel and listen for event reolink_discovery, it should occur about every 30 seconds by default. + +The two configurable options are the interval and the broadcast address. + +Interval lets you adjust how often it will send a request. + +Broadcast address is to handle setups where the network that Home Assistant sees, is not the actual local network, in here you can provide a new IP address to broadcast to. For example, if you are running this in a devcontainer, you will not see any results because broadcast packets are not forwarded by default, if you change this to your computers ip address, you should start to see replies. + +[Developer info](./developer.md) + + + +*** + +[reolink_discovery]: https://github.com/xannor/ha_reolink_discovery +[commits-shield]: https://img.shields.io/github/commit-activity/y/xannor/ha_reolink_discovery.svg?style=for-the-badge +[commits]: https://github.com/xannor/ha_reolink_discovery/commits/master +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge +[forum]: https://community.home-assistant.io/ +[license]: https://github.com/xannor/ha_reolink_discovery/blob/main/LICENSE +[license-shield]: https://img.shields.io/github/license/xannor/ha_reolink_discovery.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Xannor%20%40xannor-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/xannor/ha_reolink_discovery.svg?style=for-the-badge +[releases]: https://github.com/xannor/ha_reolink_discovery/releases +[user_profile]: https://github.com/xannor + diff --git a/custom_components/reolink_discovery/__init__.py b/custom_components/reolink_discovery/__init__.py index 87fc8cf..d6a54d7 100644 --- a/custom_components/reolink_discovery/__init__.py +++ b/custom_components/reolink_discovery/__init__.py @@ -1,6 +1,5 @@ """Reolink Discovery Component""" -from dataclasses import asdict from datetime import timedelta import logging @@ -10,19 +9,19 @@ from homeassistant.helpers.discovery import discover from homeassistant.helpers.discovery_flow import async_create_flow from homeassistant.helpers.event import async_track_time_interval -from homeassistant.components import dhcp -from homeassistant.loader import async_get_custom_components from homeassistant.const import CONF_SCAN_INTERVAL -from .typing import DiscoveredDevice +from .typing import ReolinkDiscoveryInfo -from .discovery import DiscoveryProtocol, async_listen, async_ping +from .core import ReolinkDiscoveryProtocol from .const import ( CONF_BROADCAST, CONF_COMPONENT, + DEFAULT_INTEGRATION, DEFAULT_SCAN_INTERVAL, + DHCP_INTEGRATIONS, DOMAIN, SUPPORTED_INTEGRATIONS, ) @@ -36,41 +35,6 @@ def async_get_poll_interval(config_entry: config_entries.ConfigEntry): return timedelta(seconds=interval) -class _Ping: - def __init__(self, hass: HomeAssistant, entry: config_entries.ConfigEntry) -> None: - self._interval: timedelta = None - self._broadcast: list[str] = None - self._cleanup: CALLBACK_TYPE = None - hass.create_task(self._update(hass, entry)) - entry.async_on_unload(entry.add_update_listener(self._update)) - - async def _ping(self, *_): - for addr in self._broadcast: - async_ping(addr) - - async def refresh(self): - """Refresh discovery""" - await self._ping() - - async def _update(self, hass: HomeAssistant, entry: config_entries.ConfigEntry): - addr = entry.options.get(CONF_BROADCAST, None) - - if addr and (not self._broadcast or self._broadcast[0] != addr): - self._broadcast = [addr] - elif not addr or not self._broadcast: - self._broadcast = list( - (str(addr) for addr in await async_get_ipv4_broadcast_addresses(hass)) - ) - interval = async_get_poll_interval(entry) - if interval != self._interval: - if self._cleanup: - self._cleanup() - self._interval = interval - self._cleanup = async_track_time_interval(hass, self._ping, self._interval) - entry.async_on_unload(self._cleanup) - await self._ping() - - async def async_setup(hass: HomeAssistant, config: config_entries.ConfigType) -> bool: """Setup ReoLink Discovery Component""" @@ -84,56 +48,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: config_entries.ConfigEnt _LOGGER.debug("Setting up reolink discovery component") hass_config = hass.data.get(DOMAIN) - components = list( - domain - for domain in await async_get_custom_components(hass) - if domain in SUPPORTED_INTEGRATIONS - ) - if CONF_COMPONENT not in entry.options and len(components) == 1: - options = entry.options.copy() - options[CONF_COMPONENT] = components[0] - hass.config_entries.async_update_entry(entry, options=options) + def _discovered(device:ReolinkDiscoveryInfo): + component: str = entry.options.get(CONF_COMPONENT, DEFAULT_INTEGRATION) + if component in SUPPORTED_INTEGRATIONS: + async_create_flow(hass, component, {"source": config_entries.SOURCE_INTEGRATION_DISCOVERY, "provider": DOMAIN}, device) + elif component in DHCP_INTEGRATIONS: + async_create_flow(hass, component, {"source": config_entries.SOURCE_DHCP, "provider": DOMAIN}, device) + else: + discover(hass, DOMAIN, device.asdict(), component, hass_config) - (transport, _) = await async_listen( - __type=_Discoverer, logger=_LOGGER, hass=hass, hass_config=hass_config - ) - entry.async_on_unload(transport.close) + (transport, discovery) = await ReolinkDiscoveryProtocol.async_create_listener(_discovered, logger=_LOGGER) - # pinger = - _Ping(hass, entry) + broadcast:list[str] = [] + interval = timedelta() + time_cleanup: CALLBACK_TYPE = None - return True + def _ping(*_): + for addr in broadcast: + discovery.async_ping(addr) + async def _update(hass: HomeAssistant, entry: config_entries.ConfigEntry): + nonlocal broadcast, interval, time_cleanup -# async def async_remove_entry(hass: HomeAssistant, _: config_entries.ConfigEntry): -# """Remove entry""" + addr = entry.options.get(CONF_BROADCAST, None) + if addr and (not broadcast or broadcast[0] != addr): + broadcast = [addr] + elif not addr or not broadcast: + broadcast = list( + (str(addr) for addr in await async_get_ipv4_broadcast_addresses(hass)) + ) + poll = async_get_poll_interval(entry) + if interval != poll: + if time_cleanup: + time_cleanup() + interval = poll + time_cleanup = async_track_time_interval(hass, _ping, interval) + _ping() + + update_cleanup = entry.add_update_listener(_update) + + def _unload(): + nonlocal transport, update_cleanup + if transport: + transport.close() + transport = None + if time_cleanup: + time_cleanup() + time_cleanup = None + if update_cleanup: + update_cleanup() + update_cleanup = None + + entry.async_on_unload(_unload) + + await _update(hass, entry) -class _Discoverer(DiscoveryProtocol): - def __init__( - self, *, hass: HomeAssistant, hass_config: config_entries.ConfigType, **kwargs - ) -> None: - super().__init__(**kwargs) - self.hass = hass - self._hass_config = hass_config - self.config_entry: config_entries.ConfigEntry = ( - config_entries.current_entry.get() - ) - - def discovered_device(self, device: DiscoveredDevice) -> None: - super().discovered_device(device) - component: str = self.config_entry.options.get(CONF_COMPONENT, SUPPORTED_INTEGRATIONS[0]) - data = asdict(device) - async_create_flow( - self.hass, - component, - {"source": config_entries.SOURCE_INTEGRATION_DISCOVERY, "provider": DOMAIN}, - data, - ) - discover(self.hass,DOMAIN, data, component, self._hass_config) - async_create_flow( - self.hass, - component, - {"source": config_entries.SOURCE_DHCP, "provider": DOMAIN}, - dhcp.DhcpServiceInfo(device.ip, device.name, device.mac), - ) + return True + +# async def async_remove_entry(hass: HomeAssistant, _: config_entries.ConfigEntry): +# """Remove entry""" diff --git a/custom_components/reolink_discovery/config_flow.py b/custom_components/reolink_discovery/config_flow.py index e505bda..96fb3fa 100644 --- a/custom_components/reolink_discovery/config_flow.py +++ b/custom_components/reolink_discovery/config_flow.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_SCAN_INTERVAL -from .const import CONF_BROADCAST, CONF_COMPONENT, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import CONF_BROADCAST, CONF_COMPONENT, DEFAULT_SCAN_INTERVAL, DOMAIN, DEFAULT_INTEGRATION _NETWORKS: list[ ipaddress.IPv4Network @@ -106,7 +106,7 @@ async def async_step_init( CONF_COMPONENT, description={ "suggested_value": user_input.get( - CONF_COMPONENT, vol.UNDEFINED + CONF_COMPONENT, DEFAULT_INTEGRATION ) }, ): str, diff --git a/custom_components/reolink_discovery/const.py b/custom_components/reolink_discovery/const.py index 02877fc..83819ab 100644 --- a/custom_components/reolink_discovery/const.py +++ b/custom_components/reolink_discovery/const.py @@ -10,4 +10,6 @@ CONF_BROADCAST: Final = "broadcast_addr" CONF_COMPONENT: Final = "notify_component" -SUPPORTED_INTEGRATIONS: Final = ["reolink", "reolink_rest"] +DEFAULT_INTEGRATION: Final = "reolink" +SUPPORTED_INTEGRATIONS: Final = [] +DHCP_INTEGRATIONS: Final = [DEFAULT_INTEGRATION] \ No newline at end of file diff --git a/custom_components/reolink_discovery/discovery.py b/custom_components/reolink_discovery/core.py similarity index 58% rename from custom_components/reolink_discovery/discovery.py rename to custom_components/reolink_discovery/core.py index 1d608af..42ef0ca 100644 --- a/custom_components/reolink_discovery/discovery.py +++ b/custom_components/reolink_discovery/core.py @@ -1,15 +1,14 @@ -"""Reolink Discovery""" +"""Core implementation""" from __future__ import annotations -import asyncio +from asyncio import DatagramProtocol, DatagramTransport, get_event_loop import logging import socket from struct import pack -from typing import Final, TypeVar - -from .typing import DiscoveredDevice +from typing import Callable, Final +from .typing import ReolinkDiscoveryInfo PORT: Final = 3000 PING: Final = 2000 @@ -27,17 +26,20 @@ def _nulltermstring(value: bytes, offset: int, maxlength: int = None) -> str | N return value[offset:idx].decode("ascii") -class DiscoveryProtocol(asyncio.DatagramProtocol): +DISCOVERY_CALLBACK = Callable[[ReolinkDiscoveryInfo],None] + +class ReolinkDiscoveryProtocol(DatagramProtocol): """UDP Discovery Protocol""" def __init__( - self, *, logger: logging.Logger = None, ping_message: bytes = PING_MESSAGE + self, *, callback:DISCOVERY_CALLBACK = None, logger: logging.Logger = None, ping_message: bytes = PING_MESSAGE ) -> None: self._logger = logger - self._transport: asyncio.transports.DatagramTransport = None + self._transport: DatagramTransport = None self._reply_verify = ping_message + self._callback = callback - def connection_made(self, transport: asyncio.transports.DatagramTransport) -> None: + def connection_made(self, transport: DatagramTransport) -> None: self._transport = transport if self._logger: self._logger.debug( @@ -81,51 +83,41 @@ def datagram_received(self, data: bytes, addr: tuple[str | any, int]) -> None: data[244:].strip(b"\0").rstrip(b"\0"), ) - message = DiscoveredDevice( + self._discovery_callback(ReolinkDiscoveryInfo( ip=_nulltermstring(data, 108, 20), - mac=_nulltermstring(data, 164, 18) or data[80:86].hex(":").upper(), - name=_nulltermstring(data, 132, 32), + macaddress=_nulltermstring(data, 164, 18) or data[80:86].hex(":"), + hostname=_nulltermstring(data, 132, 32) or "", ident=_nulltermstring(data, 58, 18), uuid=_nulltermstring(data, 228, 32), - ) - - self.discovered_device(message) + )) - def discovered_device(self, device: DiscoveredDevice) -> None: - """Called when a device is discovered""" + def _discovery_callback(self, device: ReolinkDiscoveryInfo) -> None: if self._logger: self._logger.debug("Discovered %s", device) + if self._callback: + self._callback(device) + @classmethod + async def async_create_listener(cls, discovered:DISCOVERY_CALLBACK=None, address:str = "0.0.0.0", port:int = PORT, logger:logging.Logger=None, **kwargs): + """Create Discovery listener""" -_T = TypeVar("_T", bound=DiscoveryProtocol) - - -async def async_listen( - *, - __type: type[_T] = DiscoveryProtocol, - logger: logging.Logger = None, - address: str = "0.0.0.0", - port: int = PORT, - **kwargs, -): - """Create discovery listener""" - - if logger: - logger.debug("Listening on %s", address) + if logger: + logger.debug("Listening on %s", address) - def _factory(): - return __type(logger=logger, **kwargs) + def _factory(): + return cls(logger=logger, callback=discovered, **kwargs) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setblocking(False) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((address, port)) - return await asyncio.get_event_loop().create_datagram_endpoint(_factory, sock=sock) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((address, port)) + return await get_event_loop().create_datagram_endpoint(_factory, sock=sock) + @staticmethod + def async_ping(address: str = "255.255.255.255", port: int = PING): + """Send discovery ping to network""" -def async_ping(address: str = "255.255.255.255", port: int = PING): - """Send disocvery ping to network""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + return sock.sendto(PING_MESSAGE, (address, port)) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.sendto(PING_MESSAGE, (address, port)) diff --git a/custom_components/reolink_discovery/manifest.json b/custom_components/reolink_discovery/manifest.json index 586a19b..3e43d30 100644 --- a/custom_components/reolink_discovery/manifest.json +++ b/custom_components/reolink_discovery/manifest.json @@ -3,7 +3,7 @@ "name": "Reolink Discovery", "documentation": "https://github.com/xannor/ha_reolink_discovery", "issue_tracker": "https://github.com/xannor/ha_reolink_discovery/issues", - "version": "1.2.0", + "version": "2.0.0", "iot_class": "local_polling", "dependencies": ["network"], "codeowners": ["@xannor"], diff --git a/custom_components/reolink_discovery/typing.py b/custom_components/reolink_discovery/typing.py index 5efab76..e829bdf 100644 --- a/custom_components/reolink_discovery/typing.py +++ b/custom_components/reolink_discovery/typing.py @@ -2,10 +2,13 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Final, Protocol, TypedDict +from dataclasses import asdict, dataclass, field +from typing import Any, TypedDict from typing_extensions import NotRequired +from homeassistant.components.dhcp import DhcpServiceInfo +#from typing import Final, Protocol, TypedDict + def _istr(value: any): if not value: @@ -13,49 +16,43 @@ def _istr(value: any): return str(value).lower() -@dataclass(frozen=True) -class DiscoveredDevice: - """Discovered Device""" - - class JSON(TypedDict): - """JSON""" +class ReolinkDiscoveryInfoType(TypedDict): + """Typed dictionary of prepared info from Reolink discovery""" - ip: str - mac: str - name: NotRequired[str] - ident: NotRequired[str] - uuid: NotRequired[str] + ip: str + macaddess: str + hostname: NotRequired[str] + uuid: NotRequired[str] + ident: NotRequired[str] - class Keys(Protocol): - """Keys""" +@dataclass(slots=True) +class ReolinkDiscoveryInfo(DhcpServiceInfo): + """Prepared info from Reolink discovery""" - ip: Final = "ip" - mac: Final = "mac" - name: Final = "name" - ident: Final = "ident" - uuid: Final = "uuid" - - ip: str # pylint: disable=invalid-name - mac: str - name: str | None = field(default=None) - ident: str | None = field(default=None) uuid: str | None = field(default=None) + ident: str | None = field(default=None) - def __post_init__(self): - object.__setattr__(self, self.Keys.mac, _istr(self.mac)) - object.__setattr__(self, self.Keys.uuid, _istr(self.uuid)) + def same_as(self, other: Any): + """compare to another info object""" - def same_as(self, other: DiscoveredDevice | JSON): - """simple comparison""" if isinstance(other, dict): - return ( - self.uuid is not None and self.uuid == _istr(other.get(self.Keys.uuid, None)) - ) or self.mac == _istr(other.get(self.Keys.mac, None)) - - return (self.uuid is not None and self.uuid == other.uuid) or self.mac == other.mac - - def simple_hash(self): - """hash based off of same_as rules""" - if self.uuid is not None: - return hash(self.uuid) - return hash(self.mac) + _other: ReolinkDiscoveryInfoType = other + _hash = self.simple_hash(_other["macaddess"], _other.get("uuid")) + elif not isinstance(other, ReolinkDiscoveryInfo): + return False + else: + _hash = self.simple_hash(other.macaddress, other.uuid) + + return self.simple_hash(self.macaddress, self.uuid) == _hash + + def asdict(self)->ReolinkDiscoveryInfoType: + """Return as TypedDict""" + return asdict(self) + + @staticmethod + def simple_hash(macaddress:str, uuid:str|None=None): + """simple hash method""" + + if uuid is not None: + return hash(uuid.lower()) + return hash(macaddress.lower()) diff --git a/hacs.json b/hacs.json index 6ca9680..e2f8876 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ -{ - "name": "Reolink Discovery", - "homeassistant": "2022.7.0" +{ + "name": "Reolink Discovery", + "homeassistant": "2023.11.1" } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bc25333 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +colorlog==6.7.0 +homeassistant==2023.11.1 +pip>=21.3.1 + diff --git a/scripts/develop b/scripts/develop new file mode 100755 index 0000000..49de63b --- /dev/null +++ b/scripts/develop @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Start Home Assistant +hass -c . --debug diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..9b5b1df --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 0000000..abe537a --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt \ No newline at end of file diff --git a/scripts/strings.py b/scripts/strings.py index 7292561..f7ab4fa 100644 --- a/scripts/strings.py +++ b/scripts/strings.py @@ -2,12 +2,74 @@ import json import os from pathlib import Path +import re +import sys from typing import cast import homeassistant -from script.translations import develop -from script.translations.upload import FILENAME_FORMAT +#from script.translations import develop +class develop: + @staticmethod + def flatten_translations(translations): + """Flatten all translations.""" + stack = [iter(translations.items())] + key_stack = [] + flattened_translations = {} + while stack: + for k, v in stack[-1]: + key_stack.append(k) + if isinstance(v, dict): + stack.append(iter(v.items())) + break + elif isinstance(v, str): + common_key = "::".join(key_stack) + flattened_translations[common_key] = v + key_stack.pop() + else: + stack.pop() + if key_stack: + key_stack.pop() + + return flattened_translations + + @staticmethod + def substitute_translation_references(integration_strings, flattened_translations): + """Recursively processes all translation strings for the integration.""" + result = {} + for key, value in integration_strings.items(): + if isinstance(value, dict): + sub_dict = develop.substitute_translation_references(value, flattened_translations) + result[key] = sub_dict + elif isinstance(value, str): + result[key] = develop.substitute_reference(value, flattened_translations) + + return result + + @staticmethod + def substitute_reference(value, flattened_translations): + """Substitute localization key references in a translation string.""" + matches = re.findall(r"\[\%key:([a-z0-9_]+(?:::(?:[a-z0-9-_])+)+)\%\]", value) + if not matches: + return value + + new = value + for key in matches: + if key in flattened_translations: + new = new.replace( + f"[%key:{key}%]", + # New value can also be a substitution reference + develop.substitute_reference( + flattened_translations[key], flattened_translations + ), + ) + else: + print(f"Invalid substitution key '{key}' found in string '{value}'") + sys.exit(1) + + return new +#from script.translations.upload import FILENAME_FORMAT +FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json") COMPONENTS_DIR = Path(__file__).parent.parent / "custom_components"