-
-
Notifications
You must be signed in to change notification settings - Fork 37.3k
Easywave integration #165895
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
eldateas
wants to merge
35
commits into
home-assistant:dev
Choose a base branch
from
eldateas:easywave-integration
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Easywave integration #165895
Changes from all commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
59d5ee7
upload base structure for the easywave integration
eldateas 94deb39
Fix manifest.json according to hassfest + Add rx_module implementatio…
eldateas 1edcf30
Address Copilot review: device_path passthrough, single_instance_allo…
eldateas 5674b20
Merge branch 'dev' into easywave-integration
eldateas f1028e4
Address second Copilot review feedback
eldateas 8313015
Address third Copilot review + proactive improvements
eldateas a7a9ab8
Address fourth Copilot review (7 comments)
eldateas 4aca3fb
Address fifth Copilot review (4 comments)
eldateas ba84d66
Merge branch 'dev' into easywave-integration
eldateas 20e219d
Address sixth Copilot review (4 comments)
eldateas 8996c7c
Merge branch 'dev' into easywave-integration
eldateas e620204
Address seventh Copilot review (3 comments)
eldateas 636e4ac
Address eighth Copilot review (3 medium-risk comments)
eldateas 245a378
Merge branch 'dev' into easywave-integration
eldateas ef2edd1
Address 9th Copilot review
eldateas f46e2c3
Address Copilot suggestion regarding SOP handling
eldateas d3a8443
Address 10th Copilot review (2 high-risk comments)
eldateas d529264
Address Copilot suggestion for an initial refresh
eldateas ffd5bd8
Address 11th Copilot review (2 comments)
eldateas 61f04e3
Merge branch 'dev' into easywave-integration
eldateas 2403c89
Address 12th Copilot review (set gateway_sensor.hass in tests)
eldateas 85b55b8
Achieve 100% config_flow coverage for Codecov
eldateas ca6b124
Move _is_serial_port_valid() inside health_check interval
eldateas efe5c7e
Merge branch 'dev' into easywave-integration
eldateas 81c55c8
Merge branch 'dev' into easywave-integration
eldateas d6e9a0b
Replace custom rx_module.py with easywave-home-control library
eldateas 376930d
Address Copilot review: thread-safety, race-condition, error handling
eldateas aa4c34e
Merge branch 'dev' into easywave-integration
eldateas ef6dd3b
Fix OSError not caught in _refresh_usb_identity
eldateas c897957
Merge branch 'dev' into easywave-integration
eldateas 3b909ec
Address review feedback for easywave integration
eldateas 59eb883
Merge branch 'dev' into easywave-integration
eldateas 8673035
Merge branch 'dev' into easywave-integration
eldateas 33e908f
Merge branch 'dev' into easywave-integration
eldateas 7caaef4
Update generated requirements files after merge
eldateas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| """The Easywave integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| import logging | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import Platform | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers import issue_registry as ir | ||
|
|
||
| from .const import ( | ||
| CONF_DEVICE_PATH, | ||
| CONF_USB_PID, | ||
| DOMAIN, | ||
| get_frequency_for_pid, | ||
| is_country_allowed_for_frequency, | ||
| ) | ||
| from .coordinator import EasywaveCoordinator | ||
| from .transceiver import RX11Transceiver | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @dataclass | ||
| class EasywaveRuntimeData: | ||
| """Runtime data for the Easywave integration.""" | ||
|
|
||
| coordinator: EasywaveCoordinator | ||
| frequency: str | None | ||
| country: str | None | ||
|
|
||
|
|
||
| type EasywaveConfigEntry = ConfigEntry[EasywaveRuntimeData] | ||
|
|
||
| PLATFORMS: list[Platform] = [Platform.SENSOR] | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: EasywaveConfigEntry) -> bool: | ||
| """Set up Easywave from a config entry.""" | ||
|
|
||
| # ── Regulatory compliance check (868 MHz) ────────────────────────── | ||
| # The operating frequency is derived from the USB device's PID. | ||
| # If the configured HA country is outside the allowed region for that | ||
| # frequency, the integration must not start. | ||
| usb_pid = entry.data.get(CONF_USB_PID) | ||
| frequency = get_frequency_for_pid(usb_pid) | ||
| country_code = hass.config.country # ISO 3166-1 alpha-2 or None | ||
|
|
||
| if frequency and not is_country_allowed_for_frequency(frequency, country_code): | ||
| _LOGGER.warning( | ||
| "This hardware operates on %s, which is not permitted in " | ||
| "your configured region (%s). Integration disabled for " | ||
| "regulatory compliance", | ||
| frequency, | ||
| country_code or "unknown", | ||
| ) | ||
| # Create a persistent repair issue visible in the HA dashboard | ||
| ir.async_create_issue( | ||
| hass, | ||
| DOMAIN, | ||
| f"frequency_not_permitted_{entry.entry_id}", | ||
| is_fixable=False, | ||
| severity=ir.IssueSeverity.ERROR, | ||
| translation_key="frequency_not_permitted", | ||
| translation_placeholders={ | ||
| "frequency": frequency, | ||
| "country": country_code or "unknown", | ||
| }, | ||
| ) | ||
| # Return False for regulatory compliance violation (not a setup error) | ||
| return False | ||
|
|
||
| # If the check passed, make sure any stale repair issue is removed | ||
| # (e.g. user changed their country setting). | ||
| ir.async_delete_issue(hass, DOMAIN, f"frequency_not_permitted_{entry.entry_id}") | ||
|
|
||
| # ── Initialize transceiver and coordinator ────────────────────────── | ||
| # Create transceiver instance, prefer user-selected serial device_path | ||
| transceiver = RX11Transceiver(hass, entry.data.get(CONF_DEVICE_PATH)) | ||
|
|
||
| # Create coordinator for managing connection lifecycle & offline mode | ||
| coordinator = EasywaveCoordinator(hass, transceiver, entry) | ||
|
|
||
| # _async_setup + first data refresh; raises ConfigEntryNotReady on failure | ||
| await coordinator.async_config_entry_first_refresh() | ||
|
|
||
| # Set runtime data for the integration | ||
| entry.runtime_data = EasywaveRuntimeData( | ||
| coordinator=coordinator, | ||
| frequency=frequency, | ||
| country=country_code, | ||
| ) | ||
|
|
||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: EasywaveConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| # Unload platforms first; only shut down the coordinator if this succeeds | ||
| unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
| if not unload_ok: | ||
| return False | ||
|
|
||
| await entry.runtime_data.coordinator.async_shutdown() | ||
|
|
||
| return True | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| """Config flow for the Easywave integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| import serial.tools.list_ports | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||
| from homeassistant.helpers.service_info.usb import UsbServiceInfo | ||
|
|
||
| from .const import ( | ||
| CONF_DEVICE_PATH, | ||
| CONF_USB_MANUFACTURER, | ||
| CONF_USB_PID, | ||
| CONF_USB_PRODUCT, | ||
| CONF_USB_SERIAL_NUMBER, | ||
| CONF_USB_VID, | ||
| DOMAIN, | ||
| USB_DEVICE_NAMES, | ||
| ) | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class EasywaveConfigFlow(ConfigFlow, domain=DOMAIN): | ||
| """Handle the config flow for Easywave.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| def __init__(self) -> None: | ||
| """Initialize.""" | ||
| self._device: dict[str, Any] = {} | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Manual setup: list all serial ports | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Show available serial ports and let the user pick one.""" | ||
| errors: dict[str, str] = {} | ||
| ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) | ||
| port_list = { | ||
| p.device: ( | ||
| f"{p.device}" | ||
| f"{f', s/n: {p.serial_number}' if p.serial_number else ''}" | ||
| f"{f' - {p.manufacturer}' if p.manufacturer else ''}" | ||
| ) | ||
| for p in ports | ||
| } | ||
|
|
||
| if not port_list: | ||
| return self.async_abort(reason="no_devices_found") | ||
|
|
||
| if user_input is not None: | ||
| selected_path = user_input[CONF_DEVICE_PATH] | ||
| # Find the matching port to extract USB metadata | ||
| port = next( | ||
| (p for p in ports if p.device == selected_path), | ||
| None, | ||
| ) | ||
| if port is None: | ||
| errors["base"] = "device_no_longer_available" | ||
| else: | ||
| self._device = { | ||
| "device": port.device, | ||
| "vid": port.vid, | ||
| "pid": port.pid, | ||
| "serial_number": port.serial_number or "unknown", | ||
| "manufacturer": port.manufacturer or "unknown", | ||
| "product": ( | ||
| USB_DEVICE_NAMES[(port.vid, port.pid)]["product"] | ||
| if (port.vid, port.pid) in USB_DEVICE_NAMES | ||
| else port.product or "Easywave Device" | ||
| ), | ||
| } | ||
| return await self.async_step_confirm() | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=vol.Schema({vol.Required(CONF_DEVICE_PATH): vol.In(port_list)}), | ||
| errors=errors, | ||
| ) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # USB auto-discovery (triggered by manifest `usb` matcher) | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: | ||
| """Handle USB discovery.""" | ||
| vid = int(discovery_info.vid, 16) | ||
| pid = int(discovery_info.pid, 16) | ||
| serial_number = discovery_info.serial_number or "unknown" | ||
|
|
||
| unique_id = ( | ||
| f"easywave_{serial_number}" | ||
| if serial_number != "unknown" | ||
| else f"easywave_{vid:04X}_{pid:04X}" | ||
| ) | ||
| await self.async_set_unique_id(unique_id) | ||
| self._abort_if_unique_id_configured() | ||
|
eldateas marked this conversation as resolved.
|
||
|
|
||
|
eldateas marked this conversation as resolved.
|
||
| device_entry = USB_DEVICE_NAMES.get((vid, pid)) | ||
| mfr = device_entry["manufacturer"] if device_entry else "ELDAT EaS GmbH" | ||
| prod = device_entry["product"] if device_entry else "Unknown Easywave Device" | ||
|
|
||
| self._device = { | ||
| "device": discovery_info.device, | ||
| "vid": vid, | ||
| "pid": pid, | ||
| "serial_number": serial_number, | ||
| "manufacturer": discovery_info.manufacturer or mfr, | ||
| "product": prod, | ||
| } | ||
| self.context["title_placeholders"] = {"name": prod} | ||
| return await self.async_step_confirm() | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Confirmation step | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| async def async_step_confirm( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Show confirmation dialog and create the entry on submit.""" | ||
| serial_number = self._device["serial_number"] | ||
| vid = self._device.get("vid") | ||
| pid = self._device.get("pid") | ||
|
|
||
| if serial_number != "unknown": | ||
| unique_id = f"easywave_{serial_number}" | ||
| elif vid is not None and pid is not None: | ||
| unique_id = f"easywave_{vid:04X}_{pid:04X}" | ||
| else: | ||
| unique_id = f"easywave_{self._device['device'].replace('/', '_')}" | ||
|
|
||
| await self.async_set_unique_id(unique_id) | ||
| self._abort_if_unique_id_configured() | ||
|
|
||
|
eldateas marked this conversation as resolved.
|
||
| if user_input is not None: | ||
| return self._create_entry() | ||
|
|
||
| return self.async_show_form( | ||
| step_id="confirm", | ||
| data_schema=vol.Schema({}), | ||
| description_placeholders={ | ||
| "name": self._device["product"], | ||
| "serial_number": serial_number, | ||
| "device": self._device["device"], | ||
| }, | ||
| ) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
|
|
||
| def _create_entry(self) -> ConfigFlowResult: | ||
| """Create the config entry.""" | ||
| d = self._device | ||
| return self.async_create_entry( | ||
| title="Easywave Gateway", | ||
| data={ | ||
| CONF_DEVICE_PATH: d["device"], | ||
| CONF_USB_VID: d["vid"], | ||
| CONF_USB_PID: d["pid"], | ||
| CONF_USB_SERIAL_NUMBER: d["serial_number"], | ||
| CONF_USB_MANUFACTURER: d["manufacturer"], | ||
| CONF_USB_PRODUCT: d["product"], | ||
| }, | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.