diff --git a/.gitignore b/.gitignore index f271e3f..64d49ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Byte-compiled / optimized / DLL files +# Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class diff --git a/README.md b/README.md index 5cbaf16..c4edaea 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,413 @@ -# schellenberg_usb Home Assistant Component +# Schellenberg USB for Home Assistant [![GitHub Release](https://img.shields.io/github/release/GimpArm/schellenberg_usb.svg)](https://github.com/GimpArm/schellenberg_usb/releases) [![License](https://img.shields.io/github/license/GimpArm/schellenberg_usb.svg)](https://github.com/GimpArm/schellenberg_usb/blob/main/LICENSE) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/GimpArm/schellenberg_usb/build-test.yaml) -Home Assistant component that interfaces with the [Schellenberg Usb Funk-Stick](https://www.schellenberg.de/smart-home-produkte/smart-home-steuerzentralen/funk-stick/21009/). +Control Schellenberg RF shutters and blinds in Home Assistant with a Schellenberg +USB FunkStick. -> [!WARNING] -> This integration is not affiliated with Schellenberg, the developers take no responsibility for anything that happens to -> your devices because of this library. +The integration guides you through teaching the USB stick to a motor, testing +movement, measuring travel times, and discovering status messages from the original +remote. It also provides estimated position tracking, manual position correction, +and built-in diagnostics. ![Schellenberg](https://raw.githubusercontent.com/GimpArm/schellenberg_usb/main/images/schellenberg-logo.png) +## Important safety and limitation notes + +> [!WARNING] +> This is a community integration and is not affiliated with or supported by +> Schellenberg. + +> [!CAUTION] +> Shutter motors may involve mains voltage. Do not open a motor, wall switch, or +> electrical enclosure, and do not modify wiring unless you are qualified to do so. +> Follow the instructions supplied with your motor and remote. + +Please keep these limitations in mind: + +- RF control cannot confirm that the motor received or carried out a command. +- USB stick responses such as `t1` and `t0` confirm only that the stick started and + finished transmitting. They do **not** prove that the blind moved. +- Home Assistant does not receive an absolute physical position from the motor. + Position is estimated from commands, recognized status messages, and measured + travel times. +- Estimated position can drift if a radio message is missed. You can correct it + with the manual position sync actions. + ## Features -* Supports blind movement Up, Down, and Stop -* After calibation, position tracking is possible. +- Open, close, and stop control +- Support for multiple blinds +- Guided pairing and motor teach-in +- Automatic travel-time calibration +- Status identity discovery during calibration +- Guided **Discover status from original remote** action +- Support for secondary RF identities +- Estimated position tracking +- Manual position sync: fully open, fully closed, or any value from 0–100% +- Stable Home Assistant entity unique IDs +- Diagnostics and troubleshooting tools +- Compatibility with legacy blind configurations + +## Requirements + +- Home Assistant +- A Schellenberg USB FunkStick +- A compatible Schellenberg RF shutter or blind motor +- The original Schellenberg remote, strongly recommended for pairing and status + discovery. Another method is also suitable if it can put the correct motor or + channel into learning mode. +- USB access from the Home Assistant host + +When available, select a stable serial path such as +`/dev/serial/by-id/...` instead of a changing path such as `/dev/ttyACM0`. ## Installation -### Step 1: Download files +### HACS custom repository -#### Option 1: Via HACS +Unless this integration is listed in the default HACS catalog, add it as a custom +repository: -[![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=GimpArm&repository=schellenberg_usb&category=integration) +1. Open HACS in Home Assistant. +2. Open the HACS menu and choose **Custom repositories**. +3. Add this GitHub repository and select **Integration** as the category. +4. Find **Schellenberg USB** in HACS and install it. +5. Restart Home Assistant. -Make sure you have HACS installed. If you don't, run `wget -O - https://get.hacs.xyz | bash -` in HA. -Choose Integrations under HACS. Click the '+' button on the bottom of the page, search for "schellenberg usb", choose it, and click install in HACS. +### Manual installation -#### Option 2: Manual -Clone this repository or download the source code as a zip file and add/merge the `custom_components/` folder with its contents in your configuration directory. +1. Copy `custom_components/schellenberg_usb` into + `/config/custom_components/schellenberg_usb`. +2. Confirm that `manifest.json` is directly inside that folder. +3. Restart Home Assistant. +### Updating an existing installation -### Step 2: Restart HA -In order for the newly added integration to be loaded, HA needs to be restarted. +Replace or update the integration files, then restart Home Assistant. A full restart +is recommended because a simple configuration reload may not load changed Python +files. -### Step 3: Add integration to HA (<--- this is a step that a lot of people forget) -In HA, go to Configuration > Integrations. -In the bottom right corner, click on the big button with a '+'. +Recent versions assign every blind a stable unique ID. Existing blinds without one +are migrated automatically during setup. After restarting, entities should support +normal Home Assistant management such as renaming, area assignment, icon changes, +and entity registry settings. The default entity name also avoids repeated names +such as `cover.extension_0_extension_0`. -If the component is properly installed, you should be able to find 'Schellenberg USB' in the list. You might need to clear you browser cache for the integration to show up. +## Quick start: add your first blind -Select it, and the schellenberg usb integration is ready for use. +1. Go to **Settings > Devices & services**. +2. Select **Add integration**, search for **Schellenberg USB**, and add it. +3. Select the USB stick path. Prefer `/dev/serial/by-id/...` when offered. +4. Open the Schellenberg USB integration and choose **Add blind**. +5. Select **Pair and test**, the recommended setup method. +6. Follow the on-screen instructions and put the correct motor or channel into + learning mode using the original remote or motor manual. +7. Start pairing. Home Assistant listens for pairing/status information and sends + the teach sequence to the USB stick. +8. Give the blind a friendly name, for example `Living room window`. +9. Confirm whether the short movement test actually moved the blind. +10. Run automatic calibration and save the blind. -### Step 4: Pair your devices +If the blind does not move during the test, repeat the learning procedure. A +successful USB stick ACK alone does not mean that the motor learned the stick. -1. In Home Assistant, go to **Settings > Devices & Services** -2. Find the **Schellenberg USB** integration and click on it -3. Click the **+** button or select **Pair device** from the menu -4. Put your blind motor into pairing mode (see [Device Pairing Instructions](#device-pairing-instructions)) -5. Once pairing is successful, provide a friendly name for your blind +### Other setup methods -### Step 5: Calibrate your blinds +- **Pair and calibrate (legacy)** keeps the older guided pairing workflow. It is + still supported, but **Pair and test** is recommended because it verifies motor + movement before calibration. +- **Add manually** is intended for advanced users who already know the command + identity, optional status identities, and travel times. You can still run teach, + test, discovery, and calibration actions afterward. -Calibration is essential for accurate position tracking. The integration measures how long it takes your blind to fully open and close, allowing it to calculate the current position during operation. +## Pairing and teach-in explained simply -> [!IMPORTANT] -> This calibration is **not** the same as setting the end positions (fully open/closed limits) on your blind motor. End positions must be configured directly on the device itself using the motor's built-in adjustment features or a Schellenberg remote control before using this integration. +The USB FunkStick acts like an additional remote control. Before it can move a +blind, the motor must learn and authorize it. -#### Starting Calibration +Use the original Schellenberg remote or the product manual to put the correct motor +or remote channel into learning mode. Then run the integration's pairing or +**Teach motor / activate USB transmitter** action. The integration sends the +required teach sequence and performs a short Open/Stop test. -You can calibrate a blind: -- **During initial pairing**: After naming your device, you'll be prompted to calibrate -- **After pairing from the device page**: Go to the device and click the **Calibrate** gear icon (⚙️) as shown below +If the motor moves during the test, pairing was probably successful. Always confirm +movement yourself: the stick's ACK only confirms transmission. -![Calibrate button location](images/calibrate-button.png) +### Product-specific pairing examples -*Click the gear icon labeled "Calibrate" in the top right corner of your blind device to start calibration.* +The original product manual takes precedence. Button combinations vary by motor, +remote, and timer model. -#### Calibration Steps +#### ROLLODRIVE 65 PREMIUM / 75 PREMIUM -1. **Step 1 - Close the blind**: Ensure your blind is fully closed (all the way down). Press **Next** when ready. +Common article numbers include 22567, 22576, 22578, 22726, 22727, 22728, and +22767. -2. **Step 2 - Measure open time**: - - Press **Start** in the dialog - - Then press the **open button** on your physical remote/control - - The integration will automatically detect when the blind starts moving and begin timing - - Wait for the blind to fully open - the timer stops automatically when movement stops +1. Press and hold **Sun** and **Up** together. +2. Keep holding for about five seconds until the LED flashes. +3. Continue the teach/pair action in Home Assistant. -3. **Step 3 - Measure close time**: - - Press **Start** in the dialog - - Then press the **close button** on your physical remote/control - - The integration will automatically detect when the blind starts moving and begin timing - - Wait for the blind to fully close - the timer stops automatically when movement stops +#### ROLLOPOWER PLUS / STANDARD -4. **Complete**: The integration will display the measured open and close times and save them for position tracking +Common article numbers include 20106, 20110, 20406, 20410, 20610, 20615, 20620, +20640, 20710, 20720, and 20740. -> [!TIP] -> There's no need to rush when pressing the buttons - the timer doesn't start until the integration receives a "moving" signal from the blind motor. +These motors are often paired through the connected Schellenberg remote control or +timer switch. Follow that device's manual to enter learning mode. -> [!NOTE] -> If calibration times seem incorrect, you can recalibrate at any time from the device options. +#### Radio shutter motors PREMIUM + +Common article numbers include 21106, 21110, 21210, 21220, and 21240. + +Use the pairing sequence for the remote control or timer assigned to the motor. +The required button combination depends on the control device. + +## Calibration explained simply + +Calibration measures how long the blind takes to travel fully open and fully +closed. Home Assistant uses those times to estimate its position: + +- `100%` means fully open. +- `0%` means fully closed. + +During calibration: + +1. Start with the blind in the position requested by the dialog. +2. Follow the prompts to move it fully open. +3. Let it finish moving. +4. Follow the prompts to move it fully closed. +5. Review and save the measured times. + +Calibration does not install or detect an absolute position sensor. It measures +travel time and may also discover useful RF status messages. + +If automatic calibration gives wrong travel times, you can recalibrate or enter +the travel times manually. + +While calibration runs, the integration groups received frames by their radio +identity. If it hears a clear Open, Close, and Stop stream, it saves that group as +the primary status identity. Companion groups can be saved as secondary identities +for diagnostics without affecting position. + +## Status discovery from the original remote + +Use **Discover status from original remote** when: + +- Home Assistant can move the blind, but using the physical remote does not update + the estimated position. +- Diagnostics says that no matching frame has been received. +- Calibration did not discover a primary status identity. + +To run discovery: + +1. Open the blind's configuration menu. +2. Choose **Developer tools > Discover status from original remote**. +3. Select the correct shutter channel on the original remote. +4. Press **Open**. +5. Press **Stop**. +6. Press **Close**. +7. Press **Stop**. +8. Review the detected primary and secondary identities, then save. + +If no clear status stream is found, the blind can still be controlled. Position +will continue to be estimated from Home Assistant commands and travel times. + +## Position tracking + +Home Assistant estimates position using: + +- Open, Close, and Stop commands sent by Home Assistant +- Recognized Open, Close, and Stop frames from the original remote or motor +- The measured open and close travel times +- Manual position corrections + +There is no encoder or absolute physical position sensor in this integration. If +the stick misses RF frames, or if the blind is moved outside Home Assistant without +a recognized status frame, the estimate may drift. + +## Manual position sync + +Open the blind's **Developer tools** menu and use: + +- **Set position fully open** to set the displayed position to `100%` +- **Set position fully closed** to set it to `0%` +- **Set position manually** to enter any value from `0–100%` + +For example: + +- If the blind is physically closed but Home Assistant shows `8%`, choose + **Set position fully closed**. +- If the blind is physically open but Home Assistant shows `96%`, choose + **Set position fully open**. If this happens often, recalibrate or adjust the + travel time. + +A manual sync is marked as manually confirmed. Position becomes estimated again +after the next movement. + +## Diagnostics + +The blind's **Developer tools** menu provides a readable diagnostic summary, +including: + +- USB stick connection, mode, and ready/busy state +- Current transmit target +- Primary status identity +- Secondary status identities +- Last matched frame +- Last primary tracking frame +- Last secondary frame +- Last position update +- Calibration history and end reason +- Status identity candidates observed during calibration or discovery + +The primary identity is the preferred source for position tracking. Secondary +identities are often harmless companion messages from the same motor or remote. +Unknown commands on a secondary identity are normal and are ignored for position. + +Use **Copy diagnostics** when preparing an issue report. Diagnostics may contain +device identifiers, so review the output before posting it publicly. + +## Troubleshooting + +### The integration does not appear + +- Confirm that the folder is + `/config/custom_components/schellenberg_usb`. +- Confirm that `manifest.json` is directly inside that folder. +- Restart Home Assistant. +- Refresh or clear the browser cache if the integration still does not appear. + +### The USB stick is not connected + +- Prefer a stable `/dev/serial/by-id/...` path. +- Check USB passthrough when Home Assistant runs in a virtual machine or container. +- Unplug and reconnect the stick, then restart Home Assistant. +- Use **Developer tools > Reset stick / reconnect serial**. + +### The blind does not move from Home Assistant + +- The motor may not have learned the USB stick. +- Put the correct motor/channel into learning mode and repeat pairing or teach-in. +- Move the USB stick closer to the motor. +- Make sure you selected the correct blind or channel during teach-in. +- Remember that an ACK confirms only USB stick transmission, not motor movement. + +### The blind moves from Home Assistant but remote position updates are missing + +- Run **Discover status from original remote**. +- Check diagnostics for a primary status identity and a last primary tracking + frame. +- Position can still be estimated from Home Assistant commands even without remote + status tracking. + +### The displayed position is wrong + +- Use a manual position sync. +- Run calibration again. +- Adjust the open and close travel times in **Advanced settings**. +- If it consistently stops at `96–98%` or `2–4%`, slightly adjust the travel time + or use manual sync. + +### Diagnostics shows unknown secondary frames + +This is usually normal. Secondary identities may send companion commands that are +not yet interpreted. They remain visible in diagnostics but are ignored for +position tracking. + +### The entity cannot be renamed or managed + +Recent versions use stable unique IDs: + +1. Update the integration files. +2. Restart Home Assistant. +3. Reload the integration page. -## Device Pairing Instructions +Legacy blind entries are migrated automatically. After migration, Home Assistant +should allow renaming, area assignment, icon changes, and other entity registry +settings. -Each Schellenberg device has a specific button combination to enter pairing mode. You must put your device into pairing mode within 2 minutes of starting the pairing process in Home Assistant. +## Advanced settings -### ROLLODRIVE 65 PREMIUM / 75 PREMIUM (Electric Belt Winders) -**Art.Nr.: 22567, 22576, 22578, 22726, 22727, 22728, 22767** +Most users do not need these values. They are available for unusual installations +or manual recovery: -To enter pairing mode: -1. Press and hold the **Sun (☀)** button and the **Up (▲)** button simultaneously -2. Hold for **5 seconds** until the LED flashes -3. The device is now in pairing mode +- Command/transmit device ID and enum +- Primary status device ID and enum +- Additional secondary status identities +- Open and close travel times +- Invert direction +- Raw RF payload testing +- Reset/reconnect and detailed diagnostics -### ROLLOPOWER PLUS / STANDARD (Tube Motors) -**Art.Nr.: 20106, 20110, 20406, 20410, 20610, 20615, 20620, 20640, 20710, 20720, 20740** +Example identities in this documentation are intentionally fake: -These motors are controlled via external switches or remote controls. Pairing is typically done through the connected Schellenberg remote control or timer switch. +- Command identity: `ABC123/10` +- Status identity: `123ABC/0D` -### Funk-Rollladenmotoren PREMIUM (Radio Tube Motors) -**Art.Nr.: 21106, 21110, 21210, 21220, 21240** +To enable debug logging, add this to `configuration.yaml` and restart or reload +logging: -To enter pairing mode, refer to your specific remote control or timer switch manual. The pairing button combination varies by the control device used. +```yaml +logger: + logs: + custom_components.schellenberg_usb: debug +``` -### General Tips +For a local development checkout, copy the integration directory into +`/config/custom_components`, restart Home Assistant, and inspect +**Settings > System > Logs**. HACS may overwrite locally copied test files during +an update. -- Keep the USB Funk-Stick within range (approx. 20m indoors, 100m outdoors) -- Avoid metal obstructions between the stick and the motor -- If pairing fails, try moving the USB stick closer to the device -- Consult your device's manual for the exact pairing procedure if the above doesn't work +## Advanced protocol notes > [!NOTE] -> The pairing instructions above are based on common Schellenberg products. Your specific device may have different procedures - always refer to the device's original manual if unsure. +> This section is intended for developers and advanced troubleshooting. It is not +> required for normal installation or pairing. + +- The command/transmit identity can differ from the receive/status identity. +- A primary status identity is selected from streams containing recognized command + values: `00` Stop, `01` Open/Up, and `02` Close/Down. +- Secondary identities may emit other command families. Unknown secondary commands + are matched for diagnostics but do not change position. +- `t1` indicates that the USB stick transmitter started and `t0` indicates that it + finished. Neither response confirms reception or movement at the motor. +- Movement transmissions use the 11-character format + `ss{two-digit enum}9{two-digit command}0000`. +- For example, Open on enum `10` is `ss109010000`. +- Motor teach-in uses command `60`, followed by finish/allow-pairing command `40` + on the same enum. Do not send teach commands casually; on some devices command + `60` can also affect direction. +- **Send raw RF payload** accepts exactly `ss` followed by nine hexadecimal + characters. Use it only when you understand the protocol and have a recovery + path. + +Protocol behavior is based on the +[reverse-engineered Schellenberg USB protocol](https://github.com/Hypfer/schellenberg-qivicon-usb). + +## Tested setup + +Tested with a Schellenberg USB FunkStick and multiple installed Schellenberg RF +shutter motors. Compatibility can vary by motor, remote, firmware, and local RF +conditions. + +## Contributing and issue reports + +Bug reports and tested improvements are welcome. Please include: + +- Home Assistant version +- Integration version, release, or commit +- USB stick path +- Motor and remote model, if known +- Whether movement from Home Assistant works +- Whether movement from the original remote is reflected in Home Assistant +- Copied diagnostics +- Debug logs only when they are needed + +Remove or replace any identifiers you do not want to share publicly. + +## License + +See [LICENSE](LICENSE). diff --git a/custom_components/schellenberg_usb/__init__.py b/custom_components/schellenberg_usb/__init__.py index e223568..7ddc236 100644 --- a/custom_components/schellenberg_usb/__init__.py +++ b/custom_components/schellenberg_usb/__init__.py @@ -7,28 +7,176 @@ import voluptuous as vol from homeassistant.config_entries import ConfigSubentry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers import device_registry as dr from .api import SchellenbergUsbApi +from .blind_id import claim_blind_id from .const import ( + CMD_DOWN, + CMD_STOP, + CMD_UP, + CONF_BLIND_ID, + CONF_COMMAND, + CONF_CONFIG_ENTRY_ID, + CONF_DEVICE_ID, + CONF_ENUM, CONF_SERIAL_PORT, DOMAIN, PLATFORMS, + SERVICE_TEST_COMMAND, + SUBENTRY_TYPE_BLIND, SUBENTRY_TYPE_HUB, SchellenbergConfigEntry, ) _LOGGER = logging.getLogger(__name__) + +@callback +def _async_backfill_blind_ids( + hass: HomeAssistant, entry: SchellenbergConfigEntry +) -> bool: + """Persist one stable, collision-free UUID for every blind subentry.""" + used_ids: set[str] = set() + changed = False + for subentry in list(entry.subentries.values()): + if subentry.subentry_type != SUBENTRY_TYPE_BLIND: + continue + blind_id, needs_update = claim_blind_id( + subentry.data.get(CONF_BLIND_ID), used_ids + ) + if not needs_update: + continue + data = dict(subentry.data) + data[CONF_BLIND_ID] = blind_id + hass.config_entries.async_update_subentry(entry, subentry, data=data) + changed = True + _LOGGER.info("Assigned stable blind ID %s to %s", blind_id, subentry.title) + return changed + + CONFIG_SCHEMA = vol.Schema( {DOMAIN: cv.config_entry_only_config_schema(DOMAIN)}, extra=vol.ALLOW_EXTRA, ) -# Store setup callbacks for each entry so we can track subentries -_SETUP_CALLBACKS: dict[str, dict] = {} + +def _validate_device_id(value: str) -> str: + """Validate and normalize a six-character protocol device ID.""" + normalized = cv.string(value).strip().upper() + if len(normalized) != 6 or any( + character not in "0123456789ABCDEF" for character in normalized + ): + raise vol.Invalid("device ID must be six hexadecimal characters") + return normalized + + +def _validate_device_enum(value: str) -> str: + """Validate and normalize a two-character protocol enum.""" + normalized = cv.string(value).strip().upper() + if len(normalized) != 2 or any( + character not in "0123456789ABCDEF" for character in normalized + ): + raise vol.Invalid("enum must be two hexadecimal characters") + return normalized + + +TEST_COMMAND_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): _validate_device_id, + vol.Required(CONF_ENUM): _validate_device_enum, + vol.Required(CONF_COMMAND): vol.In({"open", "close", "stop"}), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + } +) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up integration-level diagnostic services.""" + + async def _handle_test_command(call: ServiceCall) -> None: + requested_entry_id = call.data.get(CONF_CONFIG_ENTRY_ID) + loaded_entries: list[tuple[SchellenbergConfigEntry, SchellenbergUsbApi]] = [] + for candidate in hass.config_entries.async_entries(DOMAIN): + api = getattr(candidate, "runtime_data", None) + if isinstance(api, SchellenbergUsbApi): + loaded_entries.append((candidate, api)) + + if requested_entry_id: + api = next( + ( + candidate_api + for candidate, candidate_api in loaded_entries + if candidate.entry_id == requested_entry_id + ), + None, + ) + if api is None: + raise ServiceValidationError( + f"No loaded Schellenberg USB entry {requested_entry_id}" + ) + elif len(loaded_entries) == 1: + api = loaded_entries[0][1] + else: + raise ServiceValidationError( + "Exactly one Schellenberg USB hub must be loaded, or config_entry_id " + "must be supplied" + ) + + requested_command = call.data[CONF_COMMAND] + action = { + "open": CMD_UP, + "close": CMD_DOWN, + "stop": CMD_STOP, + }[requested_command] + _LOGGER.warning( + "test_command service called command_requested=%s device_id=%s enum=%s " + "config_entry_id=%s connected=%s mode=%s ready=%s pairing=%s " + "transmitter_active=%s busy_latched=%s", + requested_command, + call.data[CONF_DEVICE_ID], + call.data[CONF_ENUM], + requested_entry_id or "auto", + api.is_connected, + api.device_mode or "unknown", + api.transmit_ready, + api.pairing_active, + api.transmitter_active, + api.busy_latched, + ) + if not await api.control_blind( + call.data[CONF_ENUM], + action, + device_id=call.data[CONF_DEVICE_ID], + source="service", + ): + _LOGGER.error( + "test_command service failed command_requested=%s device_id=%s " + "enum=%s reason=%s", + requested_command, + call.data[CONF_DEVICE_ID], + call.data[CONF_ENUM], + api.transmit_block_reason or "serial write failed", + ) + raise ServiceValidationError("The serial command could not be queued") + _LOGGER.warning( + "test_command service result command_requested=%s device_id=%s enum=%s " + "result=written_awaiting_ack", + requested_command, + call.data[CONF_DEVICE_ID], + call.data[CONF_ENUM], + ) + + hass.services.async_register( + DOMAIN, + SERVICE_TEST_COMMAND, + _handle_test_command, + schema=TEST_COMMAND_SCHEMA, + ) + return True async def async_setup_entry( @@ -104,42 +252,46 @@ async def async_setup_entry( add_config_subentry_id=hub_subentry.subentry_id, ) + # Legacy subentries predate stable per-blind UUIDs. Persist them before + # platforms create entities so the registry identity is stable immediately. + _async_backfill_blind_ids(hass, entry) + # Forward setup to the hub's platforms (cover, sensor, switch) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Add listener to reload entry when subentries are added + # Reload when blind subentries are added, removed, renamed, or edited. + known_subentries = { + subentry_id: ( + subentry.subentry_type, + subentry.title, + subentry.unique_id, + dict(subentry.data), + ) + for subentry_id, subentry in entry.subentries.items() + } + async def _on_entry_updated( hass_instance: HomeAssistant, updated_entry: SchellenbergConfigEntry ) -> None: - """Handle updates to the hub entry (including subentry additions).""" - current_subentries = set(updated_entry.subentries.keys()) - known_subentries = _SETUP_CALLBACKS.get(entry.entry_id, {}).get( - "subentry_ids", set() - ) - - _LOGGER.debug( - "Entry update detected. Current subentries: %s, Known subentries: %s", - current_subentries, - known_subentries, - ) - + """Handle changes to the hub's blind subentries.""" + nonlocal known_subentries + current_subentries = { + subentry_id: ( + subentry.subentry_type, + subentry.title, + subentry.unique_id, + dict(subentry.data), + ) + for subentry_id, subentry in updated_entry.subentries.items() + } if current_subentries != known_subentries: _LOGGER.info( - "Subentries changed, reloading entry. Old: %s, New: %s", - known_subentries, - current_subentries, + "Subentry configuration changed; reloading entry %s", entry.entry_id ) - # Update tracked subentries before reloading - _SETUP_CALLBACKS[entry.entry_id]["subentry_ids"] = current_subentries - # Reload the entire entry to re-setup all platforms with new subentries + known_subentries = current_subentries await hass_instance.config_entries.async_reload(entry.entry_id) - entry.add_update_listener(_on_entry_updated) - - # Track known subentries - if entry.entry_id not in _SETUP_CALLBACKS: - _SETUP_CALLBACKS[entry.entry_id] = {} - _SETUP_CALLBACKS[entry.entry_id]["subentry_ids"] = set(entry.subentries.keys()) + entry.async_on_unload(entry.add_update_listener(_on_entry_updated)) return True diff --git a/custom_components/schellenberg_usb/api.py b/custom_components/schellenberg_usb/api.py index 7aceb85..0d2acfe 100644 --- a/custom_components/schellenberg_usb/api.py +++ b/custom_components/schellenberg_usb/api.py @@ -4,11 +4,16 @@ import asyncio import logging +from collections import deque from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast -import serial_asyncio +import serial +import serial_asyncio_fast from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import dt as dt_util from .const import ( CMD_ALLOW_PAIRING, @@ -40,15 +45,68 @@ CMD_TRANSMIT, CMD_UP, CMD_VERIFY, + CONF_COMMAND_DEVICE_ID, + CONF_COMMAND_ENUM, + CONF_SECONDARY_STATUS_IDENTITIES, + CONF_STATUS_DEVICE_ID, + CONF_STATUS_IDENTITY_SOURCE, + CONF_STATUS_ENUM, PAIRING_DEVICE_ENUM_START, PAIRING_TIMEOUT, SIGNAL_DEVICE_EVENT, + SIGNAL_MANUAL_POSITION_SYNC, SIGNAL_STICK_STATUS_UPDATED, + STATUS_IDENTITY_SOURCE_UNKNOWN, + STATUS_DISCOVERY_TIMEOUT, VERIFY_TIMEOUT, ) +from .identities import ( + StatusIdentity, + normalize_status_identities, + normalize_status_identity, + summarize_status_discovery_frames, +) _LOGGER = logging.getLogger(__name__) +TRANSMIT_RETRY_DELAY = 0.05 +TRANSMIT_MAX_RETRIES = 3 +TRANSMIT_IDLE_TIMEOUT = 3.0 +RECONNECT_DELAY = 5.0 +RESET_SETTLE_DELAY = 0.25 +DIAGNOSTIC_TRANSMIT_SOURCES = frozenset({"developer_tools", "service"}) + + +@dataclass(frozen=True, slots=True) +class _StatusIdentityRegistration: + """One status identity mapped to a configured cover.""" + + entity_name: str + command_device_id: str + primary: bool + + +def _interpret_status_command(command: str) -> str: + """Return the diagnostic meaning of one received command byte.""" + return { + CMD_STOP: "stop", + CMD_UP: "open", + CMD_DOWN: "close", + }.get(command.upper(), "unknown") + + +def _normalize_protocol_enum(value: object) -> str: + """Normalize one- or two-digit protocol enums without changing other values.""" + text = str(value).strip() + normalized = text.upper() + if ( + normalized + and len(normalized) <= 2 + and all(character in "0123456789ABCDEF" for character in normalized) + ): + return normalized.zfill(2) + return text + class SchellenbergUsbApi: """Manages all communication with the Schellenberg USB stick.""" @@ -62,11 +120,27 @@ def __init__(self, hass: HomeAssistant, port: str) -> None: self._registered_devices: dict[ str, str ] = {} # Dict[device_id, device_enum] for registered entities + self._registered_entity_keys: dict[ + StatusIdentity, _StatusIdentityRegistration + ] = {} + # Exact status identity -> latest raw frame and diagnostic interpretation. + self._last_received_messages: dict[StatusIdentity, dict[str, Any]] = {} + self._last_primary_tracking_messages: dict[StatusIdentity, dict[str, Any]] = {} + self._last_position_updates: dict[str, dict[str, Any]] = {} + self._last_manual_position_syncs: dict[str, dict[str, Any]] = {} + self._last_received_sequence = 0 + self._raw_received_frames: deque[dict[str, Any]] = deque(maxlen=1000) + self._status_discovery_frames: list[dict[str, Any]] | None = None + self._status_discovery_phase = "idle" + self._status_discovery_started_at: str | None = None self._is_connecting = False self._pairing_future: asyncio.Future[str] | None = None + self._pairing_active = False self._stop_pairing_task: asyncio.Task[None] | None = ( None # Track task to stop pairing ) + self._disconnect_requested = False + self._reconnect_handle: asyncio.TimerHandle | None = None # USB stick status self._is_connected = False @@ -78,77 +152,140 @@ def __init__(self, hass: HomeAssistant, port: str) -> None: # Retry queue for commands that failed with "stick busy" self._pending_retry_command: str | None = None + self._pending_transmit_source: str | None = None + self._transmit_retry_count = 0 self._retry_task: asyncio.Task[None] | None = None + self._transmit_lock = asyncio.Lock() + self._transmit_busy = False + + self._transmitter_active = False + self._transmitter_idle = asyncio.Event() + self._transmitter_idle.set() + self._busy_latched = False + + async def connect(self) -> bool: + """Establish, verify, and initialize the serial connection.""" + if self._is_connecting: + _LOGGER.debug("Connection attempt already in progress") + return False + if self._transport is not None and not self._transport.is_closing(): + _LOGGER.debug("Serial connection already established") + return self._is_connected - async def connect(self) -> None: - """Establish a connection to the serial port.""" - if self._is_connecting or ( - self._transport and not self._transport.is_closing() - ): - _LOGGER.debug("Connection attempt already in progress or established") - return - + self._disconnect_requested = False + self._cancel_scheduled_reconnect() self._is_connecting = True + self._transport = None + self._protocol = None _LOGGER.info("Connecting to Schellenberg USB stick at %s", self.port) try: - ( - self._transport, - self._protocol, - ) = await serial_asyncio.create_serial_connection( + transport, protocol = await serial_asyncio_fast.create_serial_connection( self.hass.loop, lambda: SchellenbergProtocol(self._handle_message, self), self.port, baudrate=112500, ) - self._is_connecting = False + self._transport = transport + # The factory above always creates this concrete protocol type. + self._protocol = cast(SchellenbergProtocol, protocol) _LOGGER.info("Successfully connected to Schellenberg USB stick") - # Verify this is a Schellenberg device if not await self.verify_device(): _LOGGER.error( "Device verification failed - not a Schellenberg USB stick" ) - if self._transport: - self._transport.close() + transport.close() self._transport = None + self._protocol = None self._is_connected = False - return + self._device_mode = None + self._schedule_reconnect() + return False self._is_connected = True + self._busy_latched = False + self._transmitter_active = False + self._transmitter_idle.set() self._update_status() - # Enter listening mode if not already in it - if self._device_mode != "listening": - _LOGGER.info( - "Device is in %s mode, entering listening mode", self._device_mode + if not await self._enter_listening_mode(): + _LOGGER.error( + "USB stick could not enter transmit-capable listening mode" ) - # Send any lowercase command to enter listening mode (B:2) - await self.send_command("hello") - # Give the device a moment to switch modes - await asyncio.sleep(0.5) - # Update the mode to listening after sending the command - self._device_mode = "listening" + transport.close() + self._transport = None + self._protocol = None + self._is_connected = False + self._device_mode = None self._update_status() - _LOGGER.info("Device now in listening mode") - else: - _LOGGER.info("Device already in listening mode") + self._schedule_reconnect() + return False - # Get the hub device ID after listening mode hub_id = await self.get_device_id() if hub_id: self._hub_id = hub_id _LOGGER.info("Hub device ID retrieved: %s", self._hub_id) else: _LOGGER.warning("Failed to retrieve hub device ID") - except (serial_asyncio.serial.SerialException, OSError) as err: + return True + except (serial.SerialException, OSError) as err: _LOGGER.error( - "Failed to connect to %s: %s. Retrying in 5 seconds", + "Failed to connect to %s: %s. Retrying in %.0f seconds", self.port, err, + RECONNECT_DELAY, ) + self._transport = None + self._protocol = None + self._is_connected = False + self._device_mode = None + self._update_status() + self._schedule_reconnect() + return False + finally: self._is_connecting = False - # Always retry after 5 seconds - self.hass.loop.call_later(5, lambda: self.hass.create_task(self.connect())) + + async def _enter_listening_mode(self) -> bool: + """Put a connected stick into B:2 listening mode when needed.""" + if self._transport is None or self._transport.is_closing(): + return False + if self._device_mode == "listening": + _LOGGER.info("Device already in listening mode") + return True + if self._device_mode == "bootloader": + _LOGGER.error("Refusing transmit initialization while in bootloader mode") + return False + + _LOGGER.info("Device is in %s mode, entering listening mode", self._device_mode) + if not await self.send_command("hello"): + return False + await asyncio.sleep(0.5) + self._device_mode = "listening" + self._update_status() + _LOGGER.info("Device now in listening mode") + return True + + def _cancel_scheduled_reconnect(self) -> None: + """Cancel a pending automatic reconnect callback.""" + if self._reconnect_handle is not None: + self._reconnect_handle.cancel() + self._reconnect_handle = None + + def _schedule_reconnect(self, delay: float = RECONNECT_DELAY) -> None: + """Schedule one reconnect unless shutdown was explicitly requested.""" + if self._disconnect_requested or self._reconnect_handle is not None: + return + _LOGGER.info("Scheduling serial reconnect in %.1f seconds", delay) + self._reconnect_handle = self.hass.loop.call_later( + delay, self._start_scheduled_reconnect + ) + + @callback + def _start_scheduled_reconnect(self) -> None: + """Start the reconnect task from its timer callback.""" + self._reconnect_handle = None + if not self._disconnect_requested: + self.hass.loop.create_task(self.connect()) @callback def _handle_message(self, message: str) -> None: @@ -172,6 +309,8 @@ def _handle_message(self, message: str) -> None: self._device_mode = "bootloader" elif boot_mode == "1": self._device_mode = "initial" + elif boot_mode == "2": + self._device_mode = "listening" else: self._device_mode = "unknown" break @@ -188,22 +327,115 @@ def _handle_message(self, message: str) -> None: self._update_status() return - # Handle acknowledgments - if message in ("t1", "t0"): - _LOGGER.debug("Transmit ACK: %s", message) + # t1 means the RF transmitter started; it is not idle until t0. + if message == "t1": + self._transmitter_active = True + self._transmitter_idle.clear() + self._busy_latched = False + source = self._pending_transmit_source or "internal" + level = ( + logging.WARNING + if source in DIAGNOSTIC_TRANSMIT_SOURCES + else logging.INFO + ) + _LOGGER.log( + level, + "Serial transmit ACK start response=t1 source=%s mode=%s " + "payload=%s retries=%d stick_ack_only=True motor_result=unknown", + source, + self._device_mode, + self._pending_retry_command, + self._transmit_retry_count, + ) return - if message == "tE": - _LOGGER.warning("Transmit error - stick busy, will retry in 50ms") - # Schedule a retry if we have a pending command - if self._pending_retry_command: - if self._retry_task and not self._retry_task.done(): - self._retry_task.cancel() - self._retry_task = asyncio.create_task( - self._retry_command_after_delay() + if message == "t0": + self._transmitter_active = False + self._transmitter_idle.set() + self._busy_latched = False + source = self._pending_transmit_source or "internal" + level = ( + logging.WARNING + if source in DIAGNOSTIC_TRANSMIT_SOURCES + else logging.INFO + ) + if self._retry_task is not None and not self._retry_task.done(): + _LOGGER.log( + level, + "Serial transmitter idle response=t0 source=%s; queued retry " + "may proceed payload=%s", + source, + self._pending_retry_command, ) + else: + _LOGGER.log( + level, + "Serial transmit ACK complete response=t0 source=%s mode=%s " + "payload=%s retries=%d result=completed stick_ack_only=True " + "motor_result=unknown", + source, + self._device_mode, + self._pending_retry_command, + self._transmit_retry_count, + ) + self._clear_pending_transmit(cancel_retry=False) return + if message == "tE": + command = self._pending_retry_command + source = self._pending_transmit_source or "internal" + self._transmitter_active = True + self._transmitter_idle.clear() + if command is None: + _LOGGER.warning( + "Serial stick reported busy with no pending transmit payload " + "source=%s", + source, + ) + return + + # A burst of duplicate tE responses must not continually cancel and + # postpone the retry that is already waiting to run. + if self._retry_task is not None and not self._retry_task.done(): + _LOGGER.debug( + "Serial stick still busy; retry already scheduled source=%s " + "payload=%s retry=%d/%d", + source, + command, + self._transmit_retry_count, + TRANSMIT_MAX_RETRIES, + ) + return + + if self._transmit_retry_count >= TRANSMIT_MAX_RETRIES: + self._busy_latched = True + _LOGGER.error( + "Serial transmit abandoned after %d attempts because the stick " + "remained busy source=%s mode=%s payload=%s; " + "reset/reconnect required", + TRANSMIT_MAX_RETRIES + 1, + source, + self._device_mode, + command, + ) + self._clear_pending_transmit(cancel_retry=False) + return + + self._transmit_retry_count += 1 + _LOGGER.warning( + "Serial stick busy; retry %d/%d will wait up to %.1fs for idle " + "source=%s mode=%s payload=%s", + self._transmit_retry_count, + TRANSMIT_MAX_RETRIES, + TRANSMIT_IDLE_TIMEOUT, + source, + self._device_mode, + command, + ) + self._retry_task = asyncio.create_task( + self._retry_command_after_delay(command, source) + ) + return # Handle device ID response (format: sr5D3E7C where 5D3E7C is the device ID) if message.startswith("sr") and len(message) >= 8: device_id = message[2:8] @@ -234,15 +466,8 @@ def _handle_message(self, message: str) -> None: # If we're in pairing mode, accept ANY device response # because the user is explicitly trying to pair RIGHT NOW if self._pairing_future and not self._pairing_future.done(): - _LOGGER.info("Pairing successful! New device ID: %s", device_id) + _LOGGER.info("Pairing candidate detected device_id=%s", device_id) self._pairing_future.set_result(device_id) - # Stop pairing mode after a 2 second delay to ensure device has fully paired - self._stop_pairing_task = asyncio.create_task( - self._stop_pairing_mode(delay=True) - ) - self._stop_pairing_task.add_done_callback( - lambda _: setattr(self, "_stop_pairing_task", None) - ) # Don't send dispatcher signal here - let the caller handle persistence return return @@ -266,168 +491,752 @@ def _handle_message(self, message: str) -> None: _LOGGER.debug( "Parsed: enum=%s, id=%s, cmd=%s", device_enum, device_id, command ) + normalized_device_id = device_id.upper() + normalized_device_enum = device_enum.upper() + normalized_command = command.upper() + identity = (normalized_device_id, normalized_device_enum) + registration = self._registered_entity_keys.get(identity) + interpretation = _interpret_status_command(normalized_command) + identity_role = ( + "primary" + if registration is not None and registration.primary + else "secondary" + if registration is not None + else "unmatched" + ) + position_tracking = bool( + registration is not None + and registration.primary + and interpretation != "unknown" + ) + self._last_received_sequence += 1 + frame = { + "device_id": normalized_device_id, + "enum": normalized_device_enum, + "command": normalized_command, + "time": dt_util.now().strftime("%H:%M:%S"), + "sequence": self._last_received_sequence, + "matched": registration is not None, + "identity_role": identity_role, + "interpreted_command": interpretation, + "position_tracking": position_tracking, + } + self._last_received_messages[identity] = frame + raw_frame = dict(frame) + raw_frame["phase"] = ( + self._status_discovery_phase + if self._status_discovery_frames is not None + else "pairing" + if self._pairing_active + else "idle" + ) + self._raw_received_frames.append(raw_frame) + if self._status_discovery_frames is not None: + captured_frame = dict(raw_frame) + if normalized_command == CMD_STOP and captured_frame["phase"] in { + "opening", + "closing", + }: + captured_frame["phase"] = f"{captured_frame['phase']}_endstop" + self._status_discovery_frames.append(captured_frame) + if position_tracking: + # Secondary and unknown primary frames must never overwrite the + # last frame that can legitimately drive the position model. + self._last_primary_tracking_messages[identity] = dict(frame) # If we're in pairing mode and this is a new device if self._pairing_future and not self._pairing_future.done(): - if device_id not in self._registered_devices: - _LOGGER.info("Pairing successful! New device ID: %s", device_id) - self._pairing_future.set_result(device_id) - # Stop pairing mode after a 2 second delay to ensure device has fully paired - self._stop_pairing_task = asyncio.create_task( - self._stop_pairing_mode(delay=True) - ) - self._stop_pairing_task.add_done_callback( - lambda _: setattr(self, "_stop_pairing_task", None) + known_status_id = any( + key[0] == normalized_device_id + for key in self._registered_entity_keys + ) + if ( + device_id not in self._registered_devices + and not known_status_id + ): + _LOGGER.info( + "Pairing candidate detected device_id=%s", device_id ) + self._pairing_future.set_result(device_id) # Don't send dispatcher signal here - let the caller handle persistence return - # If this is the first time we see this device (auto-discovery mode) - if device_id not in self._registered_devices: - _LOGGER.warning( - "Received message for device %s (enum=%s, cmd=%s) but no " - "corresponding entity found. The device may need to be added " - "to Home Assistant", - device_id, - device_enum, - command, + if registration is None: + level = ( + logging.DEBUG + if self._registered_entity_keys + else logging.WARNING + ) + _LOGGER.log( + level, + "Received device_id=%s enum=%s cmd=%s matched=False " + "interpreted=%s; no cover has this status identity", + normalized_device_id, + normalized_device_enum, + normalized_command, + interpretation, ) else: - # The entity will handle the event via the dispatcher _LOGGER.debug( - "Forwarding event to device %s (enum=%s): command=%s", - device_id, - device_enum, - command, + "Received device_id=%s enum=%s cmd=%s matched=True entity=%s " + "identity_role=%s interpreted=%s position_tracking=%s", + normalized_device_id, + normalized_device_enum, + normalized_command, + registration.entity_name, + identity_role, + interpretation, + position_tracking, ) - # Forward the event to the correct entity (if it exists) + # Retain the ID-only signal for calibration before an entity exists. async_dispatcher_send( - self.hass, f"{SIGNAL_DEVICE_EVENT}_{device_id}", command + self.hass, + f"{SIGNAL_DEVICE_EVENT}_{normalized_device_id}", + normalized_command, ) + if registration is not None: + async_dispatcher_send( + self.hass, + f"{SIGNAL_DEVICE_EVENT}_{normalized_device_id}_" + f"{normalized_device_enum}", + normalized_command, + ) except (IndexError, ValueError) as err: _LOGGER.debug("Failed to parse message %s: %s", message, err) - async def send_command(self, command: str) -> None: - """Send a command to the USB stick.""" - if self._transport is None or self._transport.is_closing(): - _LOGGER.warning("Serial port not connected. Command dropped: %s", command) - return + async def send_command(self, command: str, *, source: str = "internal") -> bool: + """Queue a raw command on the serial transport.""" + return await self._write_command(command, is_retry=False, source=source) - # Store command for potential retry on "stick busy" error - self._pending_retry_command = command + async def _write_command( + self, command: str, *, is_retry: bool, source: str + ) -> bool: + """Write one command while serializing access to the transport.""" + async with self._transmit_lock: + self._transmit_busy = True + try: + if is_retry and self._pending_retry_command != command: + _LOGGER.debug( + "Skipping stale serial retry source=%s payload=%s pending=%s", + source, + command, + self._pending_retry_command, + ) + return False - full_command = f"{command}\r\n".encode("ascii") - _LOGGER.debug("Sending to serial device: %s", full_command.strip()) - self._transport.write(full_command) - _LOGGER.debug("Command sent to serial device: %s", full_command.strip()) + if self._transport is None or self._transport.is_closing(): + _LOGGER.error( + "Serial write blocked reason=transport_unavailable source=%s " + "payload=%s connected=%s mode=%s", + source, + command, + self._is_connected, + self._device_mode, + ) + if is_retry or command.startswith(CMD_TRANSMIT): + self._clear_pending_transmit(cancel_retry=False) + return False + + is_transmit = command.startswith(CMD_TRANSMIT) + diagnostic = is_transmit and source in DIAGNOSTIC_TRANSMIT_SOURCES + visible_level = logging.WARNING if diagnostic else logging.DEBUG + if is_transmit: + if self._busy_latched: + _LOGGER.error( + "Serial transmit blocked reason=busy_latched source=%s " + "mode=%s payload=%s; reset/reconnect required", + source, + self._device_mode, + command, + ) + return False + + if not is_retry and not self._transmitter_idle.is_set(): + _LOGGER.log( + logging.WARNING if diagnostic else logging.INFO, + "Waiting for active serial transmit to finish source=%s " + "mode=%s pending_payload=%s new_payload=%s", + source, + self._device_mode, + self._pending_retry_command, + command, + ) + if not await self._wait_for_transmitter_idle( + "before a new transmit" + ): + self._busy_latched = True + return False + + if not is_retry: + if self._pending_retry_command is not None: + _LOGGER.warning( + "Replacing completed serial transmit old_payload=%s " + "new_payload=%s source=%s", + self._pending_retry_command, + command, + source, + ) + self._clear_pending_transmit() + self._pending_retry_command = command + self._pending_transmit_source = source + self._transmit_retry_count = 0 + + # Close the idle window before writing. This prevents another + # coroutine from queuing a command before the stick reports t1. + self._transmitter_active = True + self._transmitter_idle.clear() + + full_command = f"{command}\r\n".encode("ascii") + attempt = self._transmit_retry_count + 1 if is_retry else 1 + max_attempts = TRANSMIT_MAX_RETRIES + 1 + _LOGGER.log( + visible_level, + "Serial write attempt source=%s mode=%s connected=%s pairing=%s " + "transmitter_active=%s payload=%s bytes=%d attempt=%d/%d retry=%s", + source, + self._device_mode, + self._is_connected, + self._pairing_active, + self._transmitter_active, + command, + len(full_command), + attempt, + max_attempts if is_transmit else 1, + is_retry, + ) + try: + self._transport.write(full_command) + except (OSError, RuntimeError): + _LOGGER.exception( + "Serial write failed source=%s payload=%s attempt=%d retry=%s", + source, + command, + attempt, + is_retry, + ) + if self._pending_retry_command == command: + self._clear_pending_transmit(cancel_retry=False) + if is_transmit: + self._transmitter_active = False + self._transmitter_idle.set() + return False + + _LOGGER.log( + visible_level, + "Serial write succeeded source=%s payload=%s attempt=%d " + "retry=%s result=written", + source, + command, + attempt, + is_retry, + ) + return True + finally: + # Never leave local transmit state busy after an exception, + # cancellation, disconnected transport, or stale retry. + self._transmit_busy = False + + async def _wait_for_transmitter_idle(self, reason: str) -> bool: + """Wait for the stick's t0 completion marker with a hard timeout.""" + if self._transmitter_idle.is_set(): + return True + try: + await asyncio.wait_for( + self._transmitter_idle.wait(), timeout=TRANSMIT_IDLE_TIMEOUT + ) + except TimeoutError: + _LOGGER.error( + "Serial transmitter did not return to idle within %.1fs (%s) " + "source=%s mode=%s pending_payload=%s", + TRANSMIT_IDLE_TIMEOUT, + reason, + self._pending_transmit_source or "internal", + self._device_mode, + self._pending_retry_command, + ) + return False + return True - async def _retry_command_after_delay(self) -> None: - """Retry sending the pending command after a 100ms delay.""" + async def _retry_command_after_delay(self, command: str, source: str) -> None: + """Retry a busy transmit only after the stick reports idle.""" + retry_task = asyncio.current_task() + diagnostic = source in DIAGNOSTIC_TRANSMIT_SOURCES try: - await asyncio.sleep(0.1) # 100 milliseconds - if self._pending_retry_command: - command = self._pending_retry_command - _LOGGER.debug("Retrying command after stick busy: %s", command) - await self.send_command(command) + if not await self._wait_for_transmitter_idle("after a busy response"): + self._busy_latched = True + _LOGGER.error( + "Serial transmit abandoned because the stick never returned " + "to idle source=%s payload=%s; reset/reconnect required", + source, + command, + ) + self._clear_pending_transmit(cancel_retry=False) + return + + await asyncio.sleep(TRANSMIT_RETRY_DELAY) + if self._pending_retry_command != command: + _LOGGER.debug( + "Busy retry no longer pending source=%s payload=%s", source, command + ) + return + + # Clear the task reference before writing so a tE response generated + # by this attempt can schedule the next bounded retry. + if self._retry_task is retry_task: + self._retry_task = None + _LOGGER.log( + logging.WARNING if diagnostic else logging.INFO, + "Retrying serial transmit after idle source=%s retry=%d/%d " + "mode=%s payload=%s", + source, + self._transmit_retry_count, + TRANSMIT_MAX_RETRIES, + self._device_mode, + command, + ) + await self._write_command(command, is_retry=True, source=source) except asyncio.CancelledError: - _LOGGER.debug("Retry task cancelled") + _LOGGER.debug( + "Serial transmit retry cancelled source=%s payload=%s", source, command + ) + raise finally: - self._retry_task = None + if self._retry_task is retry_task: + self._retry_task = None + + def _clear_pending_transmit(self, *, cancel_retry: bool = True) -> None: + """Clear retry state and optionally cancel the scheduled retry task.""" + retry_task = self._retry_task + self._retry_task = None + if ( + cancel_retry + and retry_task is not None + and retry_task is not asyncio.current_task() + and not retry_task.done() + ): + retry_task.cancel() + self._pending_retry_command = None + self._pending_transmit_source = None + self._transmit_retry_count = 0 - async def pair_device_and_wait(self) -> tuple[str, str] | None: - """Put the stick into pairing mode and wait for a device to pair. + @callback + def get_recent_raw_frames(self, *, limit: int = 100) -> list[dict[str, Any]]: + """Return recent raw receive frames across pairing, tests, and idle listening.""" + if limit <= 0: + return [] + return [dict(frame) for frame in list(self._raw_received_frames)[-limit:]] - Returns a tuple of (device_id, device_enum) if successful, None if timeout. - """ + @callback + def start_status_frame_capture(self, *, phase: str) -> None: + """Start one bounded receive-frame capture for pairing or calibration.""" + if self._status_discovery_frames is not None: + raise RuntimeError("status frame capture is already active") + self._status_discovery_frames = [] + self._status_discovery_phase = phase + self._status_discovery_started_at = dt_util.now().isoformat() + + @callback + def set_status_frame_capture_phase(self, phase: str) -> None: + """Label subsequently received frames with the current workflow phase.""" + if self._status_discovery_frames is not None: + self._status_discovery_phase = phase + + @callback + def finish_status_frame_capture(self, *, end_reason: str) -> dict[str, Any]: + """Stop capture and return grouped candidates plus raw phase-labelled frames.""" + frames = list(self._status_discovery_frames or []) + started_at = self._status_discovery_started_at + self._status_discovery_frames = None + self._status_discovery_phase = "idle" + self._status_discovery_started_at = None + result = summarize_status_discovery_frames(frames) + result.update( + { + "frames": frames, + "started_at": started_at, + "completed_at": dt_util.now().isoformat(), + "end_reason": end_reason, + } + ) + return result + + async def async_discover_status_identities( + self, *, timeout: float = STATUS_DISCOVERY_TIMEOUT + ) -> dict[str, Any]: + """Capture and classify frames from one guided original-remote sequence.""" + if not self._is_connected: + raise ConnectionError("USB stick is not connected") + if timeout <= 0: + raise ValueError("status discovery timeout must be positive") + + self.start_status_frame_capture(phase="remote_discovery") + _LOGGER.warning( + "Status discovery started timeout=%.1fs; listening for original remote", + timeout, + ) + try: + await asyncio.sleep(timeout) + except BaseException: + self.finish_status_frame_capture(end_reason="cancelled") + raise + result = self.finish_status_frame_capture(end_reason="capture_timeout_complete") + + primary = result["primary"] + _LOGGER.warning( + "Status discovery completed frames=%d groups=%d primary=%s " + "secondary_count=%d position_tracking=%s", + len(result["frames"]), + len(result["groups"]), + ( + f"{primary['device_id']}/{primary['enum']}" + if primary is not None + else "unknown" + ), + len(result["secondary"]), + result["position_tracking_available"], + ) + return result + + async def pair_device_and_wait(self) -> tuple[str, str] | None: + """Put the stick into pairing mode and wait for a device to pair.""" if self._pairing_future and not self._pairing_future.done(): _LOGGER.warning("Pairing already in progress") return None + if not self._is_transmit_capable(): + _LOGGER.error( + "Pairing blocked because stick is not ready connected=%s mode=%s " + "busy_latched=%s", + self._is_connected, + self._device_mode, + self._busy_latched, + ) + return None - # Get the next available device enumerator device_enum = self.initialize_next_device_enum() - - # Format: ssXX9CCPPPP - # ss = transmit prefix - # XX = device enumerator (2 hex chars) - # 9 = number of messages to send - # CC = command (60 = pair) - # PPPP = padding (4 chars) pair_command = f"{CMD_TRANSMIT}{device_enum}9{CMD_PAIR}0000" - + finish_pair_command = f"{CMD_TRANSMIT}{device_enum}9{CMD_ALLOW_PAIRING}0000" _LOGGER.info( - "Initiating pairing with device enum %s. Command: %s", + "Initiating pairing with device enum %s teach_payload=%s finish_payload=%s", device_enum, pair_command, + finish_pair_command, ) - # Create a future to wait for device ID first + self._pairing_active = True + self._device_mode = "pairing" + self._update_status() self._pairing_future = self.hass.loop.create_future() + paired = False try: - # Send sp command to enter pairing/listening mode (like C# does) _LOGGER.debug("Entering pairing mode with command: sp") - await self.send_command(CMD_GET_PARAM_P) + if not await self.send_command(CMD_GET_PARAM_P): + raise ConnectionError("could not enter pairing mode") - # Wait for device to send its ID first (with timeout) device_id = await asyncio.wait_for( self._pairing_future, timeout=PAIRING_TIMEOUT ) - - # Once we have the device ID, send the pairing command _LOGGER.debug( - "Received device ID %s, sending pairing command: %s", + "Received device ID %s, sending pairing teach_payload=%s " + "then finish_payload=%s", device_id, pair_command, + finish_pair_command, ) - await self.send_command(pair_command) + for phase, payload in ( + ("teach_60", pair_command), + ("finish_40", finish_pair_command), + ): + if not await self.send_command(payload, source="pairing"): + raise ConnectionError(f"could not send pairing phase {phase}") + if not await self._wait_for_transmitter_idle( + f"finishing pairing phase {phase}" + ): + self._busy_latched = True + return None + _LOGGER.info( + "Pairing stick ACK completed phase=%s payload=%s " + "motor_authorization=unverified", + phase, + payload, + ) + paired = True except TimeoutError: _LOGGER.warning("Pairing timeout - no device responded with ID") return None + except ConnectionError as err: + _LOGGER.warning("Pairing stopped because serial connection failed: %s", err) + return None else: - # Pairing successful - return the device ID and enum _LOGGER.info( - "Pairing completed successfully: %s with device enum %s", + "Pairing teach command transmitted device_id=%s enum=%s; " + "motor authorization remains unverified until movement succeeds", device_id, device_enum, ) return (device_id, device_enum) finally: self._pairing_future = None + # Await pairing shutdown. A delayed background `sp` used to race the + # first test command and could leave the stick busy/programming. + stop_task = asyncio.create_task( + self._stop_pairing_mode(delay=paired), + name="schellenberg-stop-pairing", + ) + self._stop_pairing_task = stop_task + try: + await stop_task + finally: + if self._stop_pairing_task is stop_task: + self._stop_pairing_task = None async def _stop_pairing_mode(self, delay: bool = False) -> None: - """Stop pairing mode by sending a stop command to the stick. - - Args: - delay: If True, wait 2 seconds before stopping to ensure device has fully paired. - """ + """Toggle pairing off and restore the normal listening-mode state.""" + stopped = False try: if delay: - # Wait 2 seconds before stopping pairing mode to ensure device has fully paired await asyncio.sleep(2) _LOGGER.debug("Stopping pairing mode with command: sp") - await self.send_command(CMD_GET_PARAM_P) - _LOGGER.info("Pairing mode stopped") + stopped = await self.send_command(CMD_GET_PARAM_P) + if stopped: + _LOGGER.info("Pairing mode stopped; stick returned to listening mode") + else: + _LOGGER.warning("Could not send pairing stop command") except OSError as err: _LOGGER.debug("Error stopping pairing mode (communication error): %s", err) + finally: + self._pairing_active = False + if self._is_connected: + self._device_mode = "listening" if stopped else "unknown" + self._update_status() - async def control_blind(self, device_enum: str, action: str) -> None: - """Send a control command to a specific blind. + async def teach_motor( + self, + device_enum: str, + *, + device_id: str | None = None, + source: str = "developer_tools", + ) -> bool: + """Transmit the full remote-assisted motor teach sequence.""" + normalized_enum = _normalize_protocol_enum(device_enum) + if reason := self._transmit_capability_block_reason(): + _LOGGER.error( + "Motor teach blocked reason=%s source=%s device_id=%s enum=%s", + reason, + source, + device_id or "unknown", + normalized_enum, + ) + return False - Args: - device_enum: The device enumerator (hex string like "10") - action: Command (CMD_UP, CMD_DOWN, CMD_STOP) + # The motor must first be put into programming mode with an authorized + # physical remote. The stick then transmits 0x60 (teach its identity and + # rolling-code start), followed directly by 0x40 to finish pairing. + teach_payload = f"{CMD_TRANSMIT}{normalized_enum}9{CMD_PAIR}0000" + finish_payload = f"{CMD_TRANSMIT}{normalized_enum}9{CMD_ALLOW_PAIRING}0000" + _LOGGER.warning( + "Motor teach sequence source=%s device_id=%s enum=%s " + "teach_payload=%s finish_payload=%s " + "prerequisite=authorized_remote_programming_mode", + source, + device_id or "unknown", + normalized_enum, + teach_payload, + finish_payload, + ) - """ + for phase, payload in ( + ("teach_60", teach_payload), + ("finish_40", finish_payload), + ): + _LOGGER.warning( + "Motor teach phase write source=%s device_id=%s enum=%s " + "phase=%s payload=%s", + source, + device_id or "unknown", + normalized_enum, + phase, + payload, + ) + if not await self.send_command(payload, source=source): + _LOGGER.error( + "Motor teach write failed source=%s device_id=%s enum=%s " + "phase=%s payload=%s", + source, + device_id or "unknown", + normalized_enum, + phase, + payload, + ) + return False + if not await self._wait_for_transmitter_idle( + f"finishing motor teach phase {phase}" + ): + self._busy_latched = True + _LOGGER.error( + "Motor teach ACK timeout source=%s device_id=%s enum=%s " + "phase=%s payload=%s", + source, + device_id or "unknown", + normalized_enum, + phase, + payload, + ) + return False + _LOGGER.warning( + "Motor teach phase stick ACK completed source=%s device_id=%s " + "enum=%s phase=%s payload=%s stick_ack_only=True", + source, + device_id or "unknown", + normalized_enum, + phase, + payload, + ) + + _LOGGER.warning( + "Motor teach sequence stick ACK completed source=%s device_id=%s " + "enum=%s phases=60_then_40 stick_ack_only=True " + "motor_authorization=unverified", + source, + device_id or "unknown", + normalized_enum, + ) + return True + + async def send_raw_transmit( + self, payload: str, *, source: str = "developer_tools" + ) -> bool: + """Validate and send one exact 11-character Schellenberg RF payload.""" + candidate = str(payload).strip() + normalized = f"ss{candidate[2:].upper()}" if len(candidate) >= 2 else candidate + if ( + len(candidate) != 11 + or candidate[:2].lower() != "ss" + or any(character not in "0123456789ABCDEF" for character in normalized[2:]) + ): + raise ValueError( + "raw RF payload must be exactly 'ss' plus 9 hexadecimal characters" + ) + if reason := self._transmit_capability_block_reason(): + _LOGGER.error( + "Raw RF transmit blocked reason=%s source=%s payload=%s", + reason, + source, + normalized, + ) + return False + + _LOGGER.warning( + "Raw RF transmit requested source=%s payload=%s payload_chars=%d", + source, + normalized, + len(normalized), + ) + sent = await self.send_command(normalized, source=source) + if not sent: + _LOGGER.error( + "Raw RF transmit result source=%s payload=%s result=write_failed " + "stick_ack_only=True motor_result=unknown", + source, + normalized, + ) + return False + + acknowledged = await self._wait_for_transmitter_idle( + "finishing raw RF transmit" + ) + if not acknowledged: + self._busy_latched = True + _LOGGER.log( + logging.WARNING if acknowledged else logging.ERROR, + "Raw RF transmit result source=%s payload=%s result=%s " + "stick_ack_only=True motor_result=unknown", + source, + normalized, + "stick_ack_completed" if acknowledged else "stick_ack_timeout", + ) + return acknowledged + + async def control_blind( + self, + device_enum: str, + action: str, + *, + device_id: str | None = None, + source: str = "cover", + ) -> bool: + """Send a control command to a specific blind.""" if action not in (CMD_UP, CMD_DOWN, CMD_STOP): - _LOGGER.error("Invalid blind action: %s", action) - return + _LOGGER.error( + "Blind command blocked reason=invalid_action source=%s action=%s", + source, + action, + ) + return False - # Format: ssXX9AAZZZ - # XX = device enum, 9 = number of messages, AA = command, ZZZ = padding - command = f"{CMD_TRANSMIT}{device_enum}9{action}0000" - _LOGGER.debug("Sending blind control: %s", command) - await self.send_command(command) + normalized_enum = _normalize_protocol_enum(device_enum) + diagnostic = source in DIAGNOSTIC_TRANSMIT_SOURCES + visible_level = logging.WARNING if diagnostic else logging.INFO + action_name = {CMD_UP: "open", CMD_DOWN: "close", CMD_STOP: "stop"}[action] + _LOGGER.log( + visible_level, + "Blind transmit requested source=%s command=%s device_id=%s enum=%s " + "connected=%s mode=%s ready=%s pairing=%s transmitter_active=%s " + "busy_latched=%s", + source, + action_name, + device_id or "unknown", + normalized_enum, + self._is_connected, + self._device_mode, + self.transmit_ready, + self._pairing_active, + self._transmitter_active, + self._busy_latched, + ) + if reason := self._transmit_capability_block_reason(): + _LOGGER.error( + "Blind command blocked reason=%s source=%s command=%s " + "device_id=%s enum=%s", + reason, + source, + action_name, + device_id or "unknown", + normalized_enum, + ) + return False + + raw_payload = f"{CMD_TRANSMIT}{normalized_enum}9{action}0000" + _LOGGER.log( + visible_level, + "Blind transmit payload source=%s command=%s device_id=%s enum=%s " + "payload=%s", + source, + action_name, + device_id or "unknown", + normalized_enum, + raw_payload, + ) + sent = await self.send_command(raw_payload, source=source) + if sent: + _LOGGER.log( + visible_level, + "Blind transmit write result source=%s command=%s payload=%s " + "result=written awaiting_ack=t1/t0", + source, + action_name, + raw_payload, + ) + else: + _LOGGER.error( + "Blind transmit write result source=%s command=%s payload=%s " + "result=failed", + source, + action_name, + raw_payload, + ) + return sent def initialize_next_device_enum(self) -> str: """Get the next available device enum based on registered devices. @@ -470,57 +1279,300 @@ def initialize_next_device_enum(self) -> str: ) return result - def register_existing_devices(self, devices: list[dict]) -> None: - """Register existing devices from storage. + def _register_status_identity( + self, + identity: StatusIdentity, + *, + entity_name: str, + command_device_id: str, + primary: bool, + ) -> None: + """Register one normalized primary or secondary status identity.""" + existing = self._registered_entity_keys.get(identity) + if existing is not None and existing.entity_name != entity_name: + _LOGGER.warning( + "Status identity %s/%s reassigned from entity=%s to entity=%s", + identity[0], + identity[1], + existing.entity_name, + entity_name, + ) + self._registered_entity_keys[identity] = _StatusIdentityRegistration( + entity_name=entity_name, + command_device_id=command_device_id.upper(), + primary=primary, + ) + if last_message := self._last_received_messages.get(identity): + interpretation = str(last_message["interpreted_command"]) + last_message.update( + { + "matched": True, + "identity_role": "primary" if primary else "secondary", + "position_tracking": primary and interpretation != "unknown", + } + ) + if bool(last_message["position_tracking"]): + self._last_primary_tracking_messages[identity] = dict(last_message) - Args: - devices: List of device dicts with 'id' and 'enum' keys - """ + def register_existing_devices(self, devices: list[dict[str, Any]]) -> None: + """Register existing devices and all persisted status identities.""" for device in devices: - device_id = device.get("id") - device_enum = device.get("enum") - if device_id and device_enum: - self._registered_devices[device_id] = device_enum - _LOGGER.debug( - "Registered existing device %s with enum %s", device_id, device_enum + command_device_id = ( + device.get(CONF_COMMAND_DEVICE_ID) + or device.get("id") + or device.get(CONF_STATUS_DEVICE_ID) + ) + command_enum = ( + device.get(CONF_COMMAND_ENUM) + or device.get("enum") + or device.get(CONF_STATUS_ENUM) + ) + status_source = device.get(CONF_STATUS_IDENTITY_SOURCE) + if status_source == STATUS_IDENTITY_SOURCE_UNKNOWN: + status_device_id = device.get(CONF_STATUS_DEVICE_ID) + status_enum = device.get(CONF_STATUS_ENUM) + else: + # Entries created before status provenance existed retain their + # historical fallback until the user runs discovery or edits them. + status_device_id = ( + device.get(CONF_STATUS_DEVICE_ID) + or device.get("id") + or command_device_id ) + status_enum = ( + device.get(CONF_STATUS_ENUM) or device.get("enum") or command_enum + ) + entity_name = str( + device.get("name") or status_device_id or command_device_id or "Blind" + ) + if command_device_id and command_enum: + self._registered_devices[str(command_device_id)] = str(command_enum) + primary_identity = normalize_status_identity(status_device_id, status_enum) + if primary_identity is not None and command_device_id: + self._register_status_identity( + primary_identity, + entity_name=entity_name, + command_device_id=str(command_device_id), + primary=True, + ) + secondary_identities = normalize_status_identities( + device.get(CONF_SECONDARY_STATUS_IDENTITIES) + ) + for identity in secondary_identities: + if identity != primary_identity and command_device_id: + self._register_status_identity( + identity, + entity_name=entity_name, + command_device_id=str(command_device_id), + primary=False, + ) + _LOGGER.debug( + "Registered existing entity=%s command=%s/%s primary_status=%s/%s " + "secondary_statuses=%s", + entity_name, + command_device_id, + command_enum, + status_device_id, + status_enum, + secondary_identities, + ) def remove_known_device(self, device_id: str) -> None: - """Remove a device from the registered entities. - - After removal, messages from this device will be treated as unknown. - """ + """Remove a command device and all of its registered status identities.""" self._registered_devices.pop(device_id, None) + normalized_id = device_id.upper() + self._registered_entity_keys = { + key: registration + for key, registration in self._registered_entity_keys.items() + if registration.command_device_id != normalized_id + } _LOGGER.debug("Removed device %s from registered entities", device_id) - def register_entity(self, device_id: str, device_enum: str) -> None: - """Register that an entity exists for this device ID with its enum.""" - self._registered_devices[device_id] = device_enum + def register_entity( + self, + status_device_id: str | None, + status_enum: str | None, + entity_name: str | None = None, + *, + command_device_id: str | None = None, + command_enum: str | None = None, + secondary_status_identities: object = None, + ) -> None: + """Register command, primary status, and diagnostic secondary identities.""" + command_id = command_device_id or status_device_id + command_slot = command_enum or status_enum + if command_id is None or command_slot is None: + _LOGGER.error( + "Cannot register cover without command identity entity=%s", + entity_name or "unknown", + ) + return + display_name = entity_name or status_device_id or command_id + self._registered_devices[command_id] = command_slot + primary_identity = normalize_status_identity(status_device_id, status_enum) + if primary_identity is not None: + self._register_status_identity( + primary_identity, + entity_name=display_name, + command_device_id=command_id, + primary=True, + ) + secondary_identities = normalize_status_identities(secondary_status_identities) + for identity in secondary_identities: + if identity != primary_identity: + self._register_status_identity( + identity, + entity_name=display_name, + command_device_id=command_id, + primary=False, + ) _LOGGER.debug( - "Registered entity for device %s with enum %s", device_id, device_enum + "Registered entity=%s command=%s/%s primary_status=%s/%s " + "secondary_statuses=%s", + display_name, + command_id, + command_slot, + status_device_id, + status_enum, + secondary_identities, ) - async def verify_device(self) -> bool: - """Verify this is a Schellenberg USB stick by sending !? command. + @callback + def get_last_received( + self, status_device_id: str, status_enum: str + ) -> dict[str, Any] | None: + """Return a copy of the latest frame for an exact status identity.""" + identity = normalize_status_identity(status_device_id, status_enum) + message = self._last_received_messages.get(identity) if identity else None + return dict(message) if message is not None else None - Returns True if verification succeeds, False otherwise. - """ + @callback + def get_last_received_for_identities( + self, identities: object + ) -> dict[str, Any] | None: + """Return the newest frame matching any supplied status identity.""" + candidates = [ + self._last_received_messages[identity] + for identity in normalize_status_identities(identities) + if identity in self._last_received_messages + ] + if not candidates: + return None + return dict(max(candidates, key=lambda message: int(message["sequence"]))) + + @callback + def get_last_primary_tracking_frame( + self, status_device_id: str, status_enum: str + ) -> dict[str, Any] | None: + """Return the last recognized 00/01/02 frame on the primary identity.""" + identity = normalize_status_identity(status_device_id, status_enum) + message = ( + self._last_primary_tracking_messages.get(identity) if identity else None + ) + return dict(message) if message is not None else None + + @callback + def get_last_secondary_frame(self, identities: object) -> dict[str, Any] | None: + """Return the newest frame among configured secondary identities.""" + candidates = [ + self._last_received_messages[identity] + for identity in normalize_status_identities(identities) + if identity in self._last_received_messages + ] + if not candidates: + return None + return dict(max(candidates, key=lambda message: int(message["sequence"]))) + + @callback + def record_position_update( + self, + command_device_id: str, + *, + source: str, + direction: str, + previous_position: int | None, + new_position: int | None, + status: str, + position_source: str | None = None, + confirmed_since_restart: bool | None = None, + ) -> None: + """Store the latest cover position calculation for diagnostics.""" + update = { + "source": source, + "direction": direction, + "previous_position": previous_position, + "new_position": new_position, + "status": status, + "time": dt_util.now().strftime("%H:%M:%S"), + "position_source": position_source or source, + "confirmed_since_restart": bool(confirmed_since_restart), + } + normalized_id = command_device_id.upper() + self._last_position_updates[normalized_id] = update + if status == "confirmed/manual": + self._last_manual_position_syncs[normalized_id] = dict(update) + + @callback + def get_last_position_update(self, command_device_id: str) -> dict[str, Any] | None: + """Return the latest position calculation for a command identity.""" + update = self._last_position_updates.get(command_device_id.upper()) + return dict(update) if update is not None else None + + @callback + def get_last_manual_position_sync( + self, command_device_id: str + ) -> dict[str, Any] | None: + """Return the most recent manual position correction for a cover.""" + update = self._last_manual_position_syncs.get(command_device_id.upper()) + return dict(update) if update is not None else None + + @callback + def manual_sync_position(self, command_device_id: str, position: int) -> bool: + """Request an immediate manual position correction on a live cover.""" + if position < 0 or position > 100: + raise ValueError("manual position must be between 0 and 100") + normalized_id = command_device_id.upper() + if not any( + registered_id.upper() == normalized_id + for registered_id in self._registered_devices + ): + _LOGGER.error( + "Manual position sync blocked command_device_id=%s position=%d " + "reason=cover_not_registered", + normalized_id, + position, + ) + return False + _LOGGER.warning( + "Manual position sync requested command_device_id=%s position=%d", + normalized_id, + position, + ) + async_dispatcher_send( + self.hass, + f"{SIGNAL_MANUAL_POSITION_SYNC}_{normalized_id}", + position, + ) + return True + + async def verify_device(self) -> bool: + """Verify that the connected serial device is a Schellenberg stick.""" if self._verify_future and not self._verify_future.done(): _LOGGER.warning("Device verification already in progress") return False _LOGGER.debug("Verifying Schellenberg USB device") self._verify_future = self.hass.loop.create_future() - try: - # Send the verification command - await self.send_command(CMD_VERIFY) - - # Wait for verification response with timeout + if not await self.send_command(CMD_VERIFY): + return False result = await asyncio.wait_for(self._verify_future, timeout=VERIFY_TIMEOUT) except TimeoutError: _LOGGER.error("Device verification timeout - device did not respond to !?") return False + except ConnectionError as err: + _LOGGER.error("Device verification interrupted: %s", err) + return False else: _LOGGER.info("Device verification successful") return result @@ -533,10 +1585,68 @@ def _update_status(self) -> None: async_dispatcher_send(self.hass, SIGNAL_STICK_STATUS_UPDATED) def update_connection_status(self, connected: bool) -> None: - """Update connection status (called from protocol).""" + """Update connection status for compatibility with existing callers.""" self._is_connected = connected self._update_status() + @callback + def handle_connection_lost( + self, protocol: SchellenbergProtocol, exc: Exception | None + ) -> None: + """Clear dead serial state and schedule reconnection.""" + if protocol is not self._protocol: + _LOGGER.debug("Ignoring connection_lost from a stale serial protocol") + return + + if self._disconnect_requested: + _LOGGER.info("Serial port closed intentionally") + else: + _LOGGER.warning("Serial port connection lost: %s", exc) + + self._transport = None + self._protocol = None + self._is_connected = False + self._device_mode = None + self._pairing_active = False + self._clear_pending_transmit() + self._transmit_busy = False + self._transmitter_active = False + self._transmitter_idle.set() + self._busy_latched = False + + error = ConnectionError(f"serial connection lost: {exc}") + for future in ( + self._verify_future, + self._device_id_future, + self._pairing_future, + ): + if future is not None and not future.done(): + future.set_exception(error) + + self._update_status() + if not self._disconnect_requested: + self._schedule_reconnect() + + def _transmit_capability_block_reason(self) -> str | None: + """Return the exact reason RF transmission is currently unavailable.""" + if not self._is_connected: + return "serial stick is disconnected" + if self._transport is None: + return "serial transport is unavailable" + if self._transport.is_closing(): + return "serial transport is closing" + if self._pairing_active: + return "pairing is active" + if self._device_mode != "listening": + return f"stick mode is {self._device_mode or 'unknown'}, expected listening" + if self._busy_latched: + return "stick busy state is latched; reset/reconnect required" + return None + + def _is_transmit_capable(self) -> bool: + """Return whether normal RF commands may use the current stick mode.""" + return self._transmit_capability_block_reason() is None + @property def is_connected(self) -> bool: """Return whether the USB stick is connected.""" @@ -549,7 +1659,7 @@ def device_version(self) -> str | None: @property def device_mode(self) -> str | None: - """Return the device mode (boot, initial, or listening).""" + """Return the device mode (bootloader, initial, listening, or pairing).""" return self._device_mode @property @@ -557,6 +1667,41 @@ def hub_id(self) -> str | None: """Return the hub device ID.""" return self._hub_id + @property + def pairing_active(self) -> bool: + """Return whether a pairing workflow currently owns the stick.""" + return self._pairing_active + + @property + def busy_latched(self) -> bool: + """Return whether a busy timeout requires reset/reconnect.""" + return self._busy_latched + + @property + def transmitter_active(self) -> bool: + """Return whether t1 was seen without a subsequent t0.""" + return self._transmitter_active + + @property + def transmit_block_reason(self) -> str | None: + """Return why a new diagnostic command cannot be sent immediately.""" + if reason := self._transmit_capability_block_reason(): + return reason + if self._transmitter_active or not self._transmitter_idle.is_set(): + return "transmitter is active; waiting for t0" + if self._pending_retry_command is not None: + return f"transmit is pending for payload {self._pending_retry_command}" + if self._transmit_busy: + return "serial write is in progress" + if self._transmit_lock.locked(): + return "serial transmit lock is held" + return None + + @property + def transmit_ready(self) -> bool: + """Return whether a new Developer Tools command can be sent immediately.""" + return self.transmit_block_reason is None + # LED Control Methods async def led_on(self) -> None: """Turn the USB stick LED on.""" @@ -662,26 +1807,23 @@ async def manual_down(self, device_enum: str) -> None: # USB Stick System Commands async def get_device_id(self) -> str | None: - """Get the USB stick's unique device ID. - - Returns the device ID string or None if request fails. - """ + """Get the USB stick's unique device ID.""" if self._device_id_future and not self._device_id_future.done(): _LOGGER.warning("Device ID request already in progress") return None _LOGGER.debug("Requesting device ID") self._device_id_future = self.hass.loop.create_future() - try: - # Send the request command - await self.send_command(CMD_GET_DEVICE_ID) - - # Wait for device ID response with timeout + if not await self.send_command(CMD_GET_DEVICE_ID): + return None device_id = await asyncio.wait_for(self._device_id_future, timeout=5) except TimeoutError: _LOGGER.error("Device ID request timeout - device did not respond") return None + except ConnectionError as err: + _LOGGER.error("Device ID request interrupted: %s", err) + return None else: _LOGGER.info("Device ID retrieved successfully: %s", device_id) return device_id @@ -714,16 +1856,68 @@ async def reboot_stick(self) -> None: await self.send_command(CMD_REBOOT) async def disconnect(self) -> None: - """Disconnect from the serial port.""" - # Cancel any pending retry task - if self._retry_task and not self._retry_task.done(): - self._retry_task.cancel() - self._retry_task = None - - if self._transport: - self._transport.close() - self._transport = None - _LOGGER.info("Disconnected from Schellenberg USB stick") + """Disconnect intentionally and cancel all pending serial work.""" + self._disconnect_requested = True + self._cancel_scheduled_reconnect() + + self._clear_pending_transmit() + stop_task = self._stop_pairing_task + self._stop_pairing_task = None + if ( + stop_task is not None + and stop_task is not asyncio.current_task() + and not stop_task.done() + ): + stop_task.cancel() + await asyncio.gather(stop_task, return_exceptions=True) + + error = ConnectionError("serial connection closed") + for future in ( + self._verify_future, + self._device_id_future, + self._pairing_future, + ): + if future is not None and not future.done(): + future.set_exception(error) + + transport = self._transport + self._transport = None + self._protocol = None + self._is_connected = False + self._device_mode = None + self._pairing_active = False + self._transmit_busy = False + self._transmitter_active = False + self._transmitter_idle.set() + self._busy_latched = False + if transport is not None: + transport.close() + self._update_status() + _LOGGER.info("Disconnected from Schellenberg USB stick") + + async def reset_and_reconnect(self) -> bool: + """Close and reopen the serial port, restoring listening mode.""" + _LOGGER.warning( + "Resetting Schellenberg stick serial state connected=%s mode=%s " + "pairing=%s transmitter_active=%s busy_latched=%s", + self._is_connected, + self._device_mode, + self._pairing_active, + self._transmitter_active, + self._busy_latched, + ) + await self.disconnect() + await asyncio.sleep(RESET_SETTLE_DELAY) + connected = await self.connect() + ready = connected and self.transmit_ready + _LOGGER.log( + logging.INFO if ready else logging.ERROR, + "Stick reset/reconnect completed connected=%s mode=%s ready=%s", + self._is_connected, + self._device_mode, + ready, + ) + return ready class SchellenbergProtocol(asyncio.Protocol): @@ -754,5 +1948,4 @@ def data_received(self, data: bytes) -> None: def connection_lost(self, exc: Exception | None) -> None: """Called when the connection is lost.""" - _LOGGER.warning("Serial port connection lost: %s", exc) - self.api.update_connection_status(False) + self.api.handle_connection_lost(self, exc) diff --git a/custom_components/schellenberg_usb/blind_id.py b/custom_components/schellenberg_usb/blind_id.py new file mode 100644 index 0000000..1174dd3 --- /dev/null +++ b/custom_components/schellenberg_usb/blind_id.py @@ -0,0 +1,36 @@ +"""Stable per-blind identifier helpers.""" + +from __future__ import annotations + +from collections.abc import MutableSet +from uuid import UUID, uuid4 + + +def normalize_blind_id(value: object) -> str | None: + """Return one canonical UUID string or None for legacy/invalid values.""" + try: + return str(UUID(str(value))) + except (AttributeError, TypeError, ValueError): + return None + + +def generate_blind_id() -> str: + """Generate a new random per-blind UUID.""" + return str(uuid4()) + + +def claim_blind_id(value: object, used_ids: MutableSet[str]) -> tuple[str, bool]: + """Claim a valid unique ID or generate a collision-free replacement. + + Return the claimed ID and whether persisted data must be updated. + """ + candidate = normalize_blind_id(value) + if candidate is not None and candidate not in used_ids: + used_ids.add(candidate) + return candidate, candidate != value + + while True: + candidate = generate_blind_id() + if candidate not in used_ids: + used_ids.add(candidate) + return candidate, True diff --git a/custom_components/schellenberg_usb/config_flow.py b/custom_components/schellenberg_usb/config_flow.py index ab21588..bd886e3 100644 --- a/custom_components/schellenberg_usb/config_flow.py +++ b/custom_components/schellenberg_usb/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from typing import Any, Awaitable, cast @@ -17,18 +18,62 @@ from homeassistant.helpers import selector from homeassistant.helpers.service_info.usb import UsbServiceInfo +from .blind_id import generate_blind_id from .const import ( + CMD_DOWN, + CMD_STOP, + CMD_UP, + CONF_BLIND_ID, CONF_CLOSE_TIME, + CONF_CLOSE_TIME_SECONDS, + CONF_COMMAND_DEVICE_ID, + CONF_COMMAND_ENUM, + CONF_DEVICE_ENUM, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_INVERT_DIRECTION, + CONF_LAST_CALIBRATION, CONF_OPEN_TIME, + CONF_OPEN_TIME_SECONDS, + CONF_SECONDARY_STATUS_IDENTITIES, CONF_SERIAL_PORT, + CONF_STATUS_DEVICE_ID, + CONF_STATUS_ENUM, + CONF_STATUS_IDENTITY_SOURCE, DOMAIN, + STATUS_IDENTITY_SOURCE_CALIBRATION, + STATUS_IDENTITY_SOURCE_MANUAL, + STATUS_IDENTITY_SOURCE_REMOTE_DISCOVERY, + STATUS_IDENTITY_SOURCE_UNKNOWN, SUBENTRY_TYPE_BLIND, + TEST_COMMAND_DELAY, +) +from .identities import ( + format_status_identities, + normalize_status_identities, + normalize_status_identity, + parse_status_identities_text, + serialize_status_identities, ) from .options_flow import SchellenbergOptionsFlowHandler from .options_flow_calibration import CalibrationFlowHandler _LOGGER = logging.getLogger(__name__) +DEVELOPER_TOOLS_MENU_OPTIONS = { + "test_open": "Test Open", + "test_close": "Test Close", + "test_stop": "Test Stop", + "discover_status": "Discover status from original remote", + "set_position_open": "Set position fully open", + "set_position_closed": "Set position fully closed", + "set_position_manual": "Set position manually", + "teach_motor": "Teach motor / activate USB transmitter", + "send_raw_command": "Send raw RF payload", + "reset_stick": "Reset stick / reconnect serial", + "copy_diagnostics": "Copy diagnostics", +} + class SchellenbergUsbConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Schellenberg USB.""" @@ -198,9 +243,21 @@ def __init__(self) -> None: """Initialize the subentry flow.""" super().__init__() self.calibration_handler: CalibrationFlowHandler | None = None + self._pending_blind_id = generate_blind_id() self._pending_device_id: str | None = None self._pending_device_enum: str | None = None self._pending_device_name: str | None = None + self._pending_status_device_id: str | None = None + self._pending_status_enum: str | None = None + self._pending_secondary_status_identities: list[dict[str, str]] = [] + self._pending_status_identity_source = STATUS_IDENTITY_SOURCE_UNKNOWN + self._status_discovery_result: dict[str, Any] | None = None + self._status_discovery_updates_existing = False + self._pending_open_time: float | None = None + self._pending_close_time: float | None = None + self._pending_invert_direction = False + self._pairing_workflow = "legacy" + self._developer_notice = "No test command sent in this session." def _get_calibration_handler(self) -> CalibrationFlowHandler: """Return (and lazily create) the calibration flow handler.""" @@ -218,7 +275,7 @@ async def _await_subentry_result( async def async_step_blind( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: - """Entry point when the user clicks the 'Pair device' button. + """Entry point when the user clicks the 'Add blind' button. Home Assistant calls async_step_{subentry_type}() where subentry_type is the key returned by async_get_supported_subentry_types. Since our type is @@ -226,17 +283,39 @@ async def async_step_blind( async_step_pairing, which caused the flow to fall back and the translation key for the initiate button to be missing. """ - _LOGGER.debug("Subentry blind flow initiated (pairing new device)") + _LOGGER.debug("Subentry blind flow initiated") return await self.async_step_user(user_input) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: - """Handle pairing initialization.""" + """Choose between pairing/calibration and manual setup.""" + return self.async_show_menu( + step_id="user", menu_options=["pair_test", "pair_device", "manual"] + ) + + async def async_step_pair_device( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Run the original pair-then-calibrate workflow.""" + self._pairing_workflow = "legacy" + return await self._async_pair_device("pair_device", user_input) + + async def async_step_pair_test( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Pair a blind and verify outgoing control before calibration.""" + self._pairing_workflow = "hybrid" + return await self._async_pair_device("pair_test", user_input) + + async def _async_pair_device( + self, step_id: str, user_input: dict[str, Any] | None + ) -> SubentryFlowResult: + """Pair a device for either supported pairing workflow.""" _LOGGER.debug("Pairing step user input: %s", user_input) if user_input is None: _LOGGER.info("Showing pairing form") - return self.async_show_form(step_id="user", data_schema=vol.Schema({})) + return self.async_show_form(step_id=step_id, data_schema=vol.Schema({})) # Get the hub entry (parent config entry) hub_entry = self._get_entry() @@ -253,9 +332,352 @@ async def async_step_user( device_id, device_enum = pairing_result self._pending_device_id = device_id self._pending_device_enum = device_enum + self._pending_status_device_id = None + self._pending_status_enum = None + self._pending_status_identity_source = STATUS_IDENTITY_SOURCE_UNKNOWN + self._pending_secondary_status_identities = [] self._pending_device_name = None return await self.async_step_name_device() + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Collect manual command and primary/secondary status identities.""" + errors: dict[str, str] = {} + + if user_input is not None: + device_name = str(user_input[CONF_DEVICE_NAME]).strip() + command_device_id = str(user_input[CONF_DEVICE_ID]).strip().upper() + command_enum = str(user_input[CONF_DEVICE_ENUM]).strip().upper() + status_device_id = ( + str(user_input.get(CONF_STATUS_DEVICE_ID, "")).strip().upper() + ) + status_enum = str(user_input.get(CONF_STATUS_ENUM, "")).strip().upper() + try: + secondary_identities = parse_status_identities_text( + user_input.get(CONF_SECONDARY_STATUS_IDENTITIES, "") + ) + except ValueError: + secondary_identities = () + errors[CONF_SECONDARY_STATUS_IDENTITIES] = "invalid_status_identities" + primary_identity = ( + (status_device_id, status_enum) + if status_device_id and status_enum + else None + ) + secondary_identities = tuple( + identity + for identity in secondary_identities + if primary_identity is None or identity != primary_identity + ) + open_time = float(user_input[CONF_OPEN_TIME_SECONDS]) + close_time = float(user_input[CONF_CLOSE_TIME_SECONDS]) + + if not device_name: + errors[CONF_DEVICE_NAME] = "required" + if not self._is_hex_value(command_device_id, 6): + errors[CONF_DEVICE_ID] = "invalid_device_id" + if not self._is_hex_value(command_enum, 2): + errors[CONF_DEVICE_ENUM] = "invalid_device_enum" + if bool(status_device_id) != bool(status_enum): + errors[ + CONF_STATUS_ENUM if status_device_id else CONF_STATUS_DEVICE_ID + ] = "status_identity_incomplete" + if status_device_id and not self._is_hex_value(status_device_id, 6): + errors[CONF_STATUS_DEVICE_ID] = "invalid_device_id" + if status_enum and not self._is_hex_value(status_enum, 2): + errors[CONF_STATUS_ENUM] = "invalid_device_enum" + if open_time <= 0: + errors[CONF_OPEN_TIME_SECONDS] = "invalid_travel_time" + if close_time <= 0: + errors[CONF_CLOSE_TIME_SECONDS] = "invalid_travel_time" + + hub_entry = self._get_entry() + if any( + str( + subentry.data.get(CONF_COMMAND_DEVICE_ID) + or subentry.data.get(CONF_DEVICE_ID, "") + ).upper() + == command_device_id + for subentry in hub_entry.subentries.values() + ): + errors[CONF_DEVICE_ID] = "already_configured" + + if not errors: + self._pending_device_name = device_name + self._pending_device_id = command_device_id + self._pending_device_enum = command_enum + self._pending_status_enum = status_enum or None + self._pending_status_device_id = status_device_id or None + self._pending_status_identity_source = ( + STATUS_IDENTITY_SOURCE_MANUAL + if status_device_id and status_enum + else STATUS_IDENTITY_SOURCE_UNKNOWN + ) + self._pending_secondary_status_identities = serialize_status_identities( + secondary_identities + ) + self._pending_open_time = open_time + self._pending_close_time = close_time + self._pending_invert_direction = bool( + user_input.get(CONF_INVERT_DIRECTION, False) + ) + self._pairing_workflow = "manual" + return await self.async_step_manual_next() + + return self.async_show_form( + step_id="manual", + data_schema=self._manual_schema(), + errors=errors, + ) + + @staticmethod + def _is_hex_value(value: str, length: int) -> bool: + """Return whether value is an exact-length hexadecimal string.""" + return len(value) == length and all( + character in "0123456789ABCDEF" for character in value + ) + + def _manual_schema(self) -> vol.Schema: + """Build the manual form with pending values as defaults.""" + open_time_key = ( + vol.Required( + CONF_OPEN_TIME_SECONDS, + default=self._pending_open_time, + ) + if self._pending_open_time is not None + else vol.Required(CONF_OPEN_TIME_SECONDS) + ) + close_time_key = ( + vol.Required( + CONF_CLOSE_TIME_SECONDS, + default=self._pending_close_time, + ) + if self._pending_close_time is not None + else vol.Required(CONF_CLOSE_TIME_SECONDS) + ) + return vol.Schema( + { + vol.Required( + CONF_DEVICE_NAME, + default=self._pending_device_name or "", + ): selector.TextSelector(), + vol.Required( + CONF_DEVICE_ID, + default=self._pending_device_id or "", + ): selector.TextSelector(), + vol.Required( + CONF_DEVICE_ENUM, + default=self._pending_device_enum or "", + ): selector.TextSelector(), + vol.Optional( + CONF_STATUS_DEVICE_ID, + default=self._pending_status_device_id or "", + ): selector.TextSelector(), + vol.Optional( + CONF_STATUS_ENUM, + default=self._pending_status_enum or "", + ): selector.TextSelector(), + vol.Optional( + CONF_SECONDARY_STATUS_IDENTITIES, + default=format_status_identities( + self._pending_secondary_status_identities + ), + ): selector.TextSelector(selector.TextSelectorConfig(multiline=True)), + open_time_key: selector.NumberSelector( + selector.NumberSelectorConfig( + min=0.1, + step=0.1, + unit_of_measurement="s", + mode=selector.NumberSelectorMode.BOX, + ) + ), + close_time_key: selector.NumberSelector( + selector.NumberSelectorConfig( + min=0.1, + step=0.1, + unit_of_measurement="s", + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Optional( + CONF_INVERT_DIRECTION, + default=self._pending_invert_direction, + ): selector.BooleanSelector(), + } + ) + + def _pending_data(self) -> dict[str, Any]: + """Return config-subentry data for the pending blind.""" + assert self._pending_device_id is not None + assert self._pending_device_enum is not None + + assert self._pending_open_time is not None + assert self._pending_close_time is not None + data: dict[str, Any] = { + CONF_BLIND_ID: self._pending_blind_id, + # Legacy command keys stay populated for backward compatibility. + CONF_DEVICE_ID: self._pending_device_id, + CONF_DEVICE_ENUM: self._pending_device_enum, + CONF_COMMAND_DEVICE_ID: self._pending_device_id, + CONF_COMMAND_ENUM: self._pending_device_enum, + CONF_STATUS_IDENTITY_SOURCE: self._pending_status_identity_source, + CONF_SECONDARY_STATUS_IDENTITIES: list( + self._pending_secondary_status_identities + ), + CONF_OPEN_TIME: self._pending_open_time, + CONF_CLOSE_TIME: self._pending_close_time, + CONF_INVERT_DIRECTION: self._pending_invert_direction, + } + if ( + self._pending_status_device_id is not None + and self._pending_status_enum is not None + ): + data[CONF_STATUS_DEVICE_ID] = self._pending_status_device_id + data[CONF_STATUS_ENUM] = self._pending_status_enum + return data + + async def async_step_manual_next( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Offer command testing before saving a manually configured blind.""" + return self.async_show_menu( + step_id="manual_next", menu_options=["test_motor", "save_manual"] + ) + + async def async_step_save_manual( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Save the pending blind without calibration.""" + if ( + not self._pending_device_name + or not self._pending_device_id + or self._pending_open_time is None + or self._pending_close_time is None + ): + return self.async_abort(reason="device_not_found") + return self.async_create_entry( + title=self._pending_device_name, + data=self._pending_data(), + unique_id=self._pending_device_id, + ) + + async def async_step_test_motor( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Send a short logical-open command followed by stop.""" + if not self._pending_device_id or not self._pending_device_enum: + return self.async_abort(reason="device_not_found") + + placeholders = { + "device_id": self._pending_device_id, + "device_enum": self._pending_device_enum, + } + if user_input is None: + return self.async_show_form( + step_id="test_motor", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + api = self._get_entry().runtime_data + action = CMD_DOWN if self._pending_invert_direction else CMD_UP + if not await api.control_blind( + self._pending_device_enum, + action, + device_id=self._pending_device_id, + ): + return self.async_show_form( + step_id="test_motor", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + errors={"base": "command_failed"}, + ) + try: + await asyncio.sleep(TEST_COMMAND_DELAY) + finally: + stopped = await api.control_blind( + self._pending_device_enum, + CMD_STOP, + device_id=self._pending_device_id, + ) + if not stopped: + return self.async_show_form( + step_id="test_motor", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + errors={"base": "command_failed"}, + ) + return await self.async_step_did_motor_move() + + async def async_step_did_motor_move( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Ask whether the short command moved the motor.""" + if user_input is None: + return self.async_show_form( + step_id="did_motor_move", + data_schema=vol.Schema( + { + vol.Required("motor_moved", default=True): ( + selector.BooleanSelector() + ) + } + ), + description_placeholders={ + "device_id": self._pending_device_id or "unknown", + "device_enum": self._pending_device_enum or "unknown", + }, + ) + + if not user_input["motor_moved"]: + if self._pairing_workflow == "existing": + return await self.async_step_edit() + # Reuse the manual form with the detected/current values prefilled. + return await self.async_step_manual() + + if self._pairing_workflow == "existing": + return self.async_abort(reason="command_test_successful") + if self._pairing_workflow == "hybrid": + return self.async_show_menu( + step_id="test_success", + menu_options=["calibration_close", "manual_times"], + ) + return await self.async_step_save_manual() + + async def async_step_manual_times( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Collect travel times after a successful paired command test.""" + if user_input is not None: + self._pending_open_time = float(user_input[CONF_OPEN_TIME_SECONDS]) + self._pending_close_time = float(user_input[CONF_CLOSE_TIME_SECONDS]) + return await self.async_step_discover_status() + + return self.async_show_form( + step_id="manual_times", + data_schema=vol.Schema( + { + vol.Required(CONF_OPEN_TIME_SECONDS): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0.1, + step=0.1, + unit_of_measurement="s", + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Required(CONF_CLOSE_TIME_SECONDS): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0.1, + step=0.1, + unit_of_measurement="s", + mode=selector.NumberSelectorMode.BOX, + ) + ), + } + ), + ) + async def async_step_name_device( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -280,7 +702,7 @@ async def async_step_name_device( }, ) - # User provided a name – begin calibration prior to creating subentry + # User provided a name; configure calibration state for either workflow. if not device_id or not device_enum: return self.async_abort(reason="pairing_failed") @@ -292,16 +714,28 @@ async def async_step_name_device( # Provide minimal device to handler handler.set_selected_device( { + # Pairing identity is used transiently to preserve the existing + # calibration listener; it is not persisted as primary status. "id": device_id, + "entity_id": device_id, "name": device_name, "enum": device_enum, } ) handler.enable_subentry_creation( + blind_id=self._pending_blind_id, device_id=device_id, device_enum=device_enum, device_name=device_name, + status_device_id=self._pending_status_device_id, + status_enum=self._pending_status_enum, + secondary_status_identities=(self._pending_secondary_status_identities), + status_identity_source=self._pending_status_identity_source, + invert_direction=self._pending_invert_direction, ) + if self._pairing_workflow == "hybrid": + return await self.async_step_test_motor() + _LOGGER.debug( "Starting calibration for paired device %s (%s) before creating subentry", device_id, @@ -314,30 +748,1114 @@ async def async_step_name_device( async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: - """Configure a blind: run calibration for the single device under this subentry. + """Choose whether to edit settings or recalibrate a blind.""" + return self.async_show_menu( + step_id="reconfigure", + menu_options=["edit", "test_existing", "developer_tools", "calibrate"], + ) - We bypass storage lookup and set the calibration handler's selected device - directly from the subentry data to avoid device_not_found errors before - calibration has ever run. - """ + def _developer_details(self) -> dict[str, Any]: + """Return normalized protocol details for the selected blind.""" + subentry = self._get_reconfigure_subentry() + data = subentry.data + command_device_id = str( + data.get(CONF_COMMAND_DEVICE_ID) or data.get(CONF_DEVICE_ID, "") + ).upper() + command_enum = str( + data.get(CONF_COMMAND_ENUM) or data.get(CONF_DEVICE_ENUM, "") + ).upper() + configured_source = str(data.get(CONF_STATUS_IDENTITY_SOURCE) or "legacy") + if configured_source == STATUS_IDENTITY_SOURCE_UNKNOWN: + primary_identity = None + else: + primary_identity = normalize_status_identity( + data.get(CONF_STATUS_DEVICE_ID) or command_device_id, + data.get(CONF_STATUS_ENUM) or command_enum, + ) + primary_status_device_id = ( + primary_identity[0] if primary_identity is not None else "Unknown" + ) + primary_status_enum = ( + primary_identity[1] if primary_identity is not None else "--" + ) + secondary_status_identities = normalize_status_identities( + data.get(CONF_SECONDARY_STATUS_IDENTITIES) + ) + status_identities = ( + *((primary_identity,) if primary_identity is not None else ()), + *secondary_status_identities, + ) + source_label = { + STATUS_IDENTITY_SOURCE_MANUAL: "manually entered", + STATUS_IDENTITY_SOURCE_CALIBRATION: ( + "automatically discovered during calibration" + ), + STATUS_IDENTITY_SOURCE_REMOTE_DISCOVERY: ( + "automatically discovered from original remote" + ), + STATUS_IDENTITY_SOURCE_UNKNOWN: "unknown / not discovered", + }.get(configured_source, "legacy configuration / unverified") + last_calibration_value = data.get(CONF_LAST_CALIBRATION) + last_calibration = ( + last_calibration_value if isinstance(last_calibration_value, dict) else {} + ) + calibration_frames = last_calibration.get("frames", []) + calibration_groups = last_calibration.get("groups", []) + calibration_frames_text = ( + "\n".join( + f"{frame.get('time', '--')} " + f"{frame.get('device_id', 'Unknown')}/{frame.get('enum', '--')} " + f"cmd={frame.get('command', '--')} phase={frame.get('phase', 'unknown')}" + for frame in calibration_frames + if isinstance(frame, dict) + ) + or "None recorded" + ) + calibration_candidates_text = ( + "\n".join( + f"{group.get('device_id', 'Unknown')}/{group.get('enum', '--')}: " + f"{','.join(group.get('commands', []))}" + for group in calibration_groups + if isinstance(group, dict) + ) + or "None" + ) + return { + "name": subentry.title, + "command_device_id": command_device_id, + "command_enum": command_enum, + # Backward-compatible names used in existing command logs/tests. + "status_device_id": primary_status_device_id, + "status_enum": primary_status_enum, + "primary_status_device_id": primary_status_device_id, + "primary_status_enum": primary_status_enum, + "status_identity_source": source_label, + "secondary_status_identities": secondary_status_identities, + "secondary_status_identities_text": ( + format_status_identities(secondary_status_identities) or "None" + ), + "status_identities": status_identities, + "invert_direction": bool(data.get(CONF_INVERT_DIRECTION, False)), + "open_time": float(data.get(CONF_OPEN_TIME, 60.0)), + "close_time": float(data.get(CONF_CLOSE_TIME, 60.0)), + "last_calibration_time": str(last_calibration.get("completed_at", "Never")), + "calibration_end_reason": str( + last_calibration.get("end_reason", "Not recorded") + ), + "calibration_frames_text": calibration_frames_text, + "calibration_candidates_text": calibration_candidates_text, + } + + def _developer_snapshot( + self, + ) -> tuple[ + dict[str, Any], + dict[str, Any], + dict[str, Any], + dict[str, Any], + dict[str, Any], + dict[str, Any], + ]: + """Return separated frame and position diagnostics for one blind.""" + details = self._developer_details() + api = self._get_entry().runtime_data + empty_frame = { + "device_id": "No matching frame received", + "enum": "--", + "command": "--", + "time": "--", + "identity_role": "none", + "interpreted_command": "unknown", + "position_tracking": False, + } + last_matched = api.get_last_received_for_identities( + details["status_identities"] + ) or dict(empty_frame) + last_primary = api.get_last_primary_tracking_frame( + details["primary_status_device_id"], + details["primary_status_enum"], + ) or dict(empty_frame) + last_secondary = api.get_last_secondary_frame( + details["secondary_status_identities"] + ) or dict(empty_frame) + last_position = api.get_last_position_update(details["command_device_id"]) or { + "source": "No position update recorded", + "direction": "--", + "position_source": "unknown", + "confirmed_since_restart": False, + "previous_position": None, + "new_position": None, + "status": "--", + "time": "--", + } + last_manual_sync = api.get_last_manual_position_sync( + details["command_device_id"] + ) + if not isinstance(last_manual_sync, dict): + last_manual_sync = { + "new_position": None, + "time": "Never", + } + return ( + details, + last_matched, + last_primary, + last_secondary, + last_position, + last_manual_sync, + ) + + async def async_step_developer_tools( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Show live protocol diagnostics and direct test actions.""" + ( + details, + last_received, + last_primary, + last_secondary, + last_position, + last_manual_sync, + ) = self._developer_snapshot() + api = self._get_entry().runtime_data + return self.async_show_menu( + step_id="developer_tools", + menu_options=DEVELOPER_TOOLS_MENU_OPTIONS, + description_placeholders={ + "selected_blind": str(details["name"]), + "last_device_id": last_received["device_id"], + "last_enum": last_received["enum"], + "last_command": last_received["command"], + "last_time": last_received["time"], + "command_device_id": details["command_device_id"], + "command_enum": details["command_enum"], + "primary_status_device_id": details["primary_status_device_id"], + "primary_status_enum": details["primary_status_enum"], + "status_identity_source": details["status_identity_source"], + "last_calibration_time": details["last_calibration_time"], + "calibration_end_reason": details["calibration_end_reason"], + "calibration_frames": details["calibration_frames_text"], + "calibration_candidates": details["calibration_candidates_text"], + "secondary_status_identities": details[ + "secondary_status_identities_text" + ], + "last_identity_role": last_received["identity_role"], + "last_interpretation": last_received["interpreted_command"], + "last_position_tracking": str(last_received["position_tracking"]), + "primary_last_device_id": last_primary["device_id"], + "primary_last_enum": last_primary["enum"], + "primary_last_command": last_primary["command"], + "primary_last_interpretation": last_primary["interpreted_command"], + "primary_last_time": last_primary["time"], + "secondary_last_device_id": last_secondary["device_id"], + "secondary_last_enum": last_secondary["enum"], + "secondary_last_command": last_secondary["command"], + "secondary_last_interpretation": last_secondary["interpreted_command"], + "secondary_last_time": last_secondary["time"], + "position_source": last_position.get( + "position_source", last_position["source"] + ), + "position_direction": last_position["direction"], + "position_previous": ( + "--" + if last_position["previous_position"] is None + else f"{last_position['previous_position']}%" + ), + "position_new": ( + "--" + if last_position["new_position"] is None + else f"{last_position['new_position']}%" + ), + "position_status": last_position["status"], + "position_time": last_position["time"], + "current_position": ( + "--" + if last_position["new_position"] is None + else f"{last_position['new_position']}%" + ), + "last_manual_sync_time": last_manual_sync["time"], + "position_confidence": ( + last_position["status"] + if last_position["new_position"] is not None + else "unknown" + ), + "position_confirmed_since_restart": ( + "Yes" if last_position.get("confirmed_since_restart") else "No" + ), + "stick_connected": str(api.is_connected), + "stick_mode": str(api.device_mode or "unknown"), + "stick_ready": str(api.transmit_ready), + "stick_busy": str(api.busy_latched), + "result": self._developer_notice, + }, + ) + + async def _async_developer_command(self, command: str) -> SubentryFlowResult: + """Send one logical command from the developer tools menu.""" + api = self._get_entry().runtime_data + details = self._developer_details() + _LOGGER.warning( + "Developer Tools command clicked selected_blind=%s " + "command_requested=%s command_device_id=%s command_enum=%s " + "status_device_id=%s status_enum=%s secondary_statuses=%s " + "stick_connected=%s stick_mode=%s stick_ready=%s pairing=%s " + "transmitter_active=%s " + "busy_latched=%s", + details["name"], + command, + details["command_device_id"], + details["command_enum"], + details["status_device_id"], + details["status_enum"], + details["secondary_status_identities_text"], + api.is_connected, + api.device_mode or "unknown", + api.transmit_ready, + api.pairing_active, + api.transmitter_active, + api.busy_latched, + ) + + if reason := api.transmit_block_reason: + _LOGGER.error( + "Developer Tools command blocked selected_blind=%s " + "command_requested=%s reason=%s", + details["name"], + command, + reason, + ) + self._developer_notice = ( + f"{command.title()} command blocked: {reason}. " + "Use Reset stick / reconnect serial if the condition does not clear." + ) + return await self.async_step_developer_tools() + + invert_direction = details["invert_direction"] + action = { + "open": CMD_DOWN if invert_direction else CMD_UP, + "close": CMD_UP if invert_direction else CMD_DOWN, + "stop": CMD_STOP, + }[command] + _LOGGER.warning( + "Developer Tools command dispatching selected_blind=%s " + "command_requested=%s protocol_action=%s command_device_id=%s " + "command_enum=%s", + details["name"], + command, + action, + details["command_device_id"], + details["command_enum"], + ) + try: + sent = await api.control_blind( + details["command_enum"], + action, + device_id=details["command_device_id"], + source="developer_tools", + ) + except Exception: + _LOGGER.exception( + "Developer Tools command raised an exception selected_blind=%s " + "command_requested=%s", + details["name"], + command, + ) + sent = False + + if sent: + _LOGGER.warning( + "Developer Tools command result selected_blind=%s " + "command_requested=%s result=written_awaiting_ack", + details["name"], + command, + ) + self._developer_notice = f"{command.title()} command written successfully." + else: + _LOGGER.error( + "Developer Tools command result selected_blind=%s " + "command_requested=%s result=failed", + details["name"], + command, + ) + self._developer_notice = ( + f"{command.title()} command failed; check the integration logs." + ) + return await self.async_step_developer_tools() + + async def async_step_test_open( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Send a direct logical-open test command.""" + return await self._async_developer_command("open") + + async def async_step_test_close( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Send a direct logical-close test command.""" + return await self._async_developer_command("close") + + async def async_step_test_stop( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Send a direct stop test command.""" + return await self._async_developer_command("stop") + + async def _async_manual_position_sync(self, position: int) -> SubentryFlowResult: + """Apply one Developer Tools position correction to the live cover.""" + api = self._get_entry().runtime_data + details = self._developer_details() + _LOGGER.warning( + "Developer Tools manual position sync clicked selected_blind=%s " + "command_device_id=%s position=%d", + details["name"], + details["command_device_id"], + position, + ) + try: + synced = api.manual_sync_position(details["command_device_id"], position) + except (TypeError, ValueError): + _LOGGER.exception( + "Developer Tools manual position sync rejected " + "selected_blind=%s position=%s", + details["name"], + position, + ) + synced = False + + if synced: + self._developer_notice = ( + f"Position manually confirmed at {position}%. No RF command was sent." + ) + else: + self._developer_notice = ( + "Manual position sync failed because the live cover entity is not " + "registered. Reload the integration and try again." + ) + return await self.async_step_developer_tools() + + async def async_step_set_position_open( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manually confirm that the blind is fully open.""" + return await self._async_manual_position_sync(100) + + async def async_step_set_position_closed( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manually confirm that the blind is fully closed.""" + return await self._async_manual_position_sync(0) + + async def async_step_set_position_manual( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Accept and apply an exact manual blind position.""" + if user_input is not None: + return await self._async_manual_position_sync(int(user_input["position"])) + + *_, last_position, _last_manual_sync = self._developer_snapshot() + default_position = last_position["new_position"] + if not isinstance(default_position, int): + default_position = 50 + return self.async_show_form( + step_id="set_position_manual", + data_schema=vol.Schema( + { + vol.Required("position", default=default_position): ( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=100, + step=1, + mode=selector.NumberSelectorMode.BOX, + ) + ) + ) + } + ), + description_placeholders={ + "selected_blind": str(self._developer_details()["name"]), + "current_position": f"{default_position}%", + }, + ) + + def _prepare_existing_status_discovery(self) -> None: + """Load the selected persisted blind as a discovery update target.""" + subentry = self._get_reconfigure_subentry() + data = subentry.data + self._pending_device_name = subentry.title + self._pending_device_id = str( + data.get(CONF_COMMAND_DEVICE_ID) or data.get(CONF_DEVICE_ID, "") + ).upper() + self._pending_device_enum = ( + str(data.get(CONF_COMMAND_ENUM) or data.get(CONF_DEVICE_ENUM, "")) + .upper() + .zfill(2) + ) + self._status_discovery_updates_existing = True + + def _apply_remote_status_discovery(self, result: dict[str, Any]) -> None: + """Apply one guided capture result without aliasing transmit identity.""" + self._status_discovery_result = result + primary = result.get("primary") + if primary is None: + self._pending_status_device_id = None + self._pending_status_enum = None + self._pending_status_identity_source = STATUS_IDENTITY_SOURCE_UNKNOWN + else: + self._pending_status_device_id = str(primary["device_id"]) + self._pending_status_enum = str(primary["enum"]) + self._pending_status_identity_source = ( + STATUS_IDENTITY_SOURCE_REMOTE_DISCOVERY + ) + self._pending_secondary_status_identities = [ + {"device_id": str(group["device_id"]), "enum": str(group["enum"])} + for group in result.get("secondary", []) + ] + + def _status_discovery_placeholders(self) -> dict[str, str]: + """Format a complete confirmation summary without requiring log access.""" + result = self._status_discovery_result or {} + primary = result.get("primary") + secondary = result.get("secondary", []) + unknown = result.get("unknown_commands", []) + return { + "command_identity": ( + f"{self._pending_device_id or 'Unknown'}/" + f"{self._pending_device_enum or '--'}" + ), + "primary_identity": ( + f"{primary['device_id']}/{primary['enum']}" + if primary is not None + else "Not discovered" + ), + "primary_commands": ( + ", ".join(primary.get("commands", [])) + if primary is not None + else "None" + ), + "primary_timestamps": ( + ", ".join(primary.get("timestamps", [])) + if primary is not None + else "None" + ), + "secondary_identities": ( + "\n".join( + f"{group['device_id']}/{group['enum']}: " + f"{','.join(group.get('commands', []))}" + for group in secondary + ) + or "None" + ), + "unknown_commands": ( + "\n".join( + f"{group['device_id']}/{group['enum']}: " + f"{','.join(group.get('commands', []))}" + for group in unknown + ) + or "None" + ), + "position_tracking": ( + "Available from received 00/01/02 status frames" + if primary is not None + else ( + "No remote/status tracking identity was discovered. The blind " + "can still be controlled, but position tracking will use Home " + "Assistant commands only." + ) + ), + "frame_count": str(len(result.get("frames", []))), + } + + async def async_step_discover_status( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Guide an original-remote sequence and capture all received identities.""" + if not self._pending_device_id: + self._prepare_existing_status_discovery() + if user_input is None: + return self.async_show_form( + step_id="discover_status", + data_schema=vol.Schema({}), + description_placeholders={ + "selected_blind": self._pending_device_name or "Blind", + "command_identity": ( + f"{self._pending_device_id or 'Unknown'}/" + f"{self._pending_device_enum or '--'}" + ), + }, + ) + + api = self._get_entry().runtime_data + try: + result = await api.async_discover_status_identities() + except ConnectionError: + return self.async_show_form( + step_id="discover_status", + data_schema=vol.Schema({}), + errors={"base": "status_discovery_unavailable"}, + description_placeholders={ + "selected_blind": self._pending_device_name or "Blind", + "command_identity": ( + f"{self._pending_device_id or 'Unknown'}/" + f"{self._pending_device_enum or '--'}" + ), + }, + ) + except RuntimeError: + return self.async_show_form( + step_id="discover_status", + data_schema=vol.Schema({}), + errors={"base": "status_discovery_busy"}, + description_placeholders={ + "selected_blind": self._pending_device_name or "Blind", + "command_identity": ( + f"{self._pending_device_id or 'Unknown'}/" + f"{self._pending_device_enum or '--'}" + ), + }, + ) + self._apply_remote_status_discovery(result) + return await self.async_step_confirm_status_discovery() + + async def async_step_confirm_status_discovery( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Confirm detected primary/secondary identities before persistence.""" + if user_input is None: + return self.async_show_form( + step_id="confirm_status_discovery", + data_schema=vol.Schema({}), + description_placeholders=self._status_discovery_placeholders(), + ) + + if self._status_discovery_updates_existing: + entry = self._get_entry() + subentry = self._get_reconfigure_subentry() + data = dict(subentry.data) + data[CONF_STATUS_IDENTITY_SOURCE] = self._pending_status_identity_source + data[CONF_SECONDARY_STATUS_IDENTITIES] = list( + self._pending_secondary_status_identities + ) + if ( + self._pending_status_device_id is not None + and self._pending_status_enum is not None + ): + data[CONF_STATUS_DEVICE_ID] = self._pending_status_device_id + data[CONF_STATUS_ENUM] = self._pending_status_enum + else: + data.pop(CONF_STATUS_DEVICE_ID, None) + data.pop(CONF_STATUS_ENUM, None) + return self.async_update_and_abort(entry, subentry, data=data) + + if ( + not self._pending_device_name + or self._pending_open_time is None + or self._pending_close_time is None + ): + return self.async_abort(reason="device_not_found") + return self.async_create_entry( + title=self._pending_device_name, + data=self._pending_data(), + unique_id=self._pending_device_id, + ) + + async def async_step_teach_motor( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Teach the USB transmitter to a motor, then send Open and Stop.""" + details = self._developer_details() + if user_input is None: + return self.async_show_form( + step_id="teach_motor", + data_schema=vol.Schema({}), + description_placeholders={ + "selected_blind": str(details["name"]), + "command_device_id": details["command_device_id"], + "command_enum": details["command_enum"], + }, + ) + + api = self._get_entry().runtime_data + if reason := api.transmit_block_reason: + _LOGGER.error( + "Motor teach blocked selected_blind=%s reason=%s", + details["name"], + reason, + ) + self._developer_notice = f"Motor teach blocked: {reason}." + return await self.async_step_developer_tools() + + _LOGGER.warning( + "Motor teach requested selected_blind=%s command_device_id=%s " + "command_enum=%s status_device_id=%s status_enum=%s", + details["name"], + details["command_device_id"], + details["command_enum"], + details["status_device_id"], + details["status_enum"], + ) + try: + taught = await api.teach_motor( + details["command_enum"], + device_id=details["command_device_id"], + source="developer_tools", + ) + opened = False + stopped = False + if taught: + open_action = CMD_DOWN if details["invert_direction"] else CMD_UP + opened = await api.control_blind( + details["command_enum"], + open_action, + device_id=details["command_device_id"], + source="developer_tools", + ) + if opened: + await asyncio.sleep(TEST_COMMAND_DELAY) + stopped = await api.control_blind( + details["command_enum"], + CMD_STOP, + device_id=details["command_device_id"], + source="developer_tools", + ) + except Exception: + _LOGGER.exception( + "Motor teach/test raised an exception selected_blind=%s", + details["name"], + ) + taught = opened = stopped = False + + if taught and opened and stopped: + self._developer_notice = ( + "Teach, Open, and Stop were transmitted. Stick ACKs confirm only " + "radio transmission; verify that the motor reacted." + ) + else: + self._developer_notice = ( + "Teach/test transmission failed; inspect the integration logs." + ) + return await self.async_step_developer_tools() + + async def async_step_send_raw_command( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Validate and send an exact Schellenberg RF command payload.""" + details = self._developer_details() + default_payload = f"ss{details['command_enum']}9{CMD_UP}0000" + errors: dict[str, str] = {} + if user_input is not None: + payload = str(user_input.get("payload", "")).strip() + _LOGGER.warning( + "Developer Tools raw RF requested selected_blind=%s payload=%s", + details["name"], + payload, + ) + try: + sent = await self._get_entry().runtime_data.send_raw_transmit( + payload, source="developer_tools" + ) + except ValueError: + sent = False + errors["payload"] = "invalid_raw_payload" + if sent: + self._developer_notice = ( + f"Raw payload {payload} was written. Stick ACKs do not confirm " + "motor movement." + ) + return await self.async_step_developer_tools() + if not errors: + errors["base"] = "transmit_failed" + + return self.async_show_form( + step_id="send_raw_command", + data_schema=vol.Schema( + { + vol.Required("payload", default=default_payload): ( + selector.TextSelector() + ) + } + ), + errors=errors, + description_placeholders={ + "selected_blind": str(details["name"]), + "command_enum": details["command_enum"], + }, + ) + + async def async_step_reset_stick( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reset local stick state and reopen the serial connection.""" + api = self._get_entry().runtime_data + ready = await api.reset_and_reconnect() + self._developer_notice = ( + "Stick reset and serial reconnect completed; ready for transmit." + if ready + else ( + "Stick reset/reconnect did not become ready " + f"(connected={api.is_connected}, mode={api.device_mode or 'unknown'}). " + "Check the integration logs and USB connection." + ) + ) + return await self.async_step_developer_tools() + + async def async_step_copy_diagnostics( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Show a copyable text snapshot for troubleshooting.""" + if user_input is not None: + return await self.async_step_developer_tools() + + ( + details, + last_received, + last_primary, + last_secondary, + last_position, + last_manual_sync, + ) = self._developer_snapshot() + api = self._get_entry().runtime_data + diagnostics = "\n".join( + ( + "Schellenberg USB blind diagnostics", + f"Selected blind: {details['name']}", + "", + "Stick state:", + f"Connected: {api.is_connected}", + f"Mode: {api.device_mode or 'unknown'}", + f"Ready: {api.transmit_ready}", + f"Pairing active: {api.pairing_active}", + f"Transmitter active: {api.transmitter_active}", + f"Busy latched: {api.busy_latched}", + "", + "ACK semantics:", + "t1/t0 confirm only that the USB stick transmitter turned on/off.", + "Motor reception and movement remain unverified (unidirectional RF).", + "", + "Last matched frame:", + f"Device ID: {last_received['device_id']}", + f"Enum: {last_received['enum']}", + f"Identity role: {last_received['identity_role']}", + f"Command: {last_received['command']}", + f"Interpretation: {last_received['interpreted_command']}", + f"Position tracking: {last_received['position_tracking']}", + f"Time: {last_received['time']}", + "", + "Last primary tracking frame:", + f"Device ID: {last_primary['device_id']}", + f"Enum: {last_primary['enum']}", + f"Command: {last_primary['command']}", + f"Interpretation: {last_primary['interpreted_command']}", + f"Time: {last_primary['time']}", + "", + "Last secondary frame:", + f"Device ID: {last_secondary['device_id']}", + f"Enum: {last_secondary['enum']}", + f"Command: {last_secondary['command']}", + f"Interpretation: {last_secondary['interpreted_command']}", + f"Time: {last_secondary['time']}", + "", + "Last position update:", + "Source: " + + str(last_position.get("position_source", last_position["source"])), + f"Details: {last_position['source']}", + f"Direction: {last_position['direction']}", + f"Previous position: {last_position['previous_position']}", + f"New position: {last_position['new_position']}", + f"Status: {last_position['status']}", + f"Time: {last_position['time']}", + "", + "Position confidence:", + f"Current estimated position: {last_position['new_position']}", + f"Last manual sync time: {last_manual_sync['time']}", + f"Confidence: {last_position['status']}", + "Confirmed since restart: " + + ("Yes" if last_position.get("confirmed_since_restart") else "No"), + "", + "Current transmit target:", + f"Device ID: {details['command_device_id']}", + f"Enum: {details['command_enum']}", + "", + "Configured primary status identity:", + f"Device ID: {details['primary_status_device_id']}", + f"Enum: {details['primary_status_enum']}", + f"Source: {details['status_identity_source']}", + "Configured secondary status identities:", + details["secondary_status_identities_text"], + "", + "Last calibration run:", + f"Completed: {details['last_calibration_time']}", + f"End reason: {details['calibration_end_reason']}", + "Frames observed during calibration:", + details["calibration_frames_text"], + "Candidate status identities:", + details["calibration_candidates_text"], + f"Open time: {details['open_time']:.2f} seconds", + f"Close time: {details['close_time']:.2f} seconds", + f"Invert direction: {details['invert_direction']}", + ) + ) + return self.async_show_form( + step_id="copy_diagnostics", + data_schema=vol.Schema( + { + vol.Required("diagnostics", default=diagnostics): ( + selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ) + ) + } + ), + ) + + async def async_step_test_existing( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Load an existing blind into the short command test.""" + subentry = self._get_reconfigure_subentry() + data = subentry.data + self._pending_device_name = subentry.title + self._pending_device_id = str( + data.get(CONF_COMMAND_DEVICE_ID) or data.get(CONF_DEVICE_ID, "") + ) + self._pending_device_enum = str( + data.get(CONF_COMMAND_ENUM) or data.get(CONF_DEVICE_ENUM, "") + ) + self._pending_status_device_id = str( + data.get(CONF_STATUS_DEVICE_ID) or self._pending_device_id + ) + self._pending_status_enum = str( + data.get(CONF_STATUS_ENUM) or self._pending_device_enum + ) + self._pending_secondary_status_identities = serialize_status_identities( + normalize_status_identities(data.get(CONF_SECONDARY_STATUS_IDENTITIES)) + ) + self._pending_open_time = float(data.get(CONF_OPEN_TIME, 60.0)) + self._pending_close_time = float(data.get(CONF_CLOSE_TIME, 60.0)) + self._pending_invert_direction = bool(data.get(CONF_INVERT_DIRECTION, False)) + self._pairing_workflow = "existing" + return await self.async_step_test_motor(user_input) + + async def async_step_edit( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Edit a blind while preserving its subentry and entity unique IDs.""" + entry = self._get_entry() + subentry = self._get_reconfigure_subentry() + current_data = dict(subentry.data) + command_device_id = str( + current_data.get(CONF_COMMAND_DEVICE_ID) + or current_data.get(CONF_DEVICE_ID, "") + ) + command_enum = str( + current_data.get(CONF_COMMAND_ENUM) + or current_data.get(CONF_DEVICE_ENUM, "") + ) + current_status_source = current_data.get(CONF_STATUS_IDENTITY_SOURCE) + if current_status_source == STATUS_IDENTITY_SOURCE_UNKNOWN: + status_device_id = str(current_data.get(CONF_STATUS_DEVICE_ID) or "") + status_enum = str(current_data.get(CONF_STATUS_ENUM) or "") + else: + status_device_id = str( + current_data.get(CONF_STATUS_DEVICE_ID) or command_device_id + ) + status_enum = str(current_data.get(CONF_STATUS_ENUM) or command_enum) + secondary_status_text = format_status_identities( + current_data.get(CONF_SECONDARY_STATUS_IDENTITIES) + ) + errors: dict[str, str] = {} + + if user_input is not None: + device_name = str(user_input[CONF_DEVICE_NAME]).strip() + command_device_id = str(user_input[CONF_DEVICE_ID]).strip().upper() + command_enum = str(user_input[CONF_DEVICE_ENUM]).strip().upper() + status_device_id = ( + str(user_input.get(CONF_STATUS_DEVICE_ID, "")).strip().upper() + ) + status_enum = str(user_input.get(CONF_STATUS_ENUM, "")).strip().upper() + secondary_status_text = str( + user_input.get(CONF_SECONDARY_STATUS_IDENTITIES, "") + ) + try: + secondary_identities = parse_status_identities_text( + secondary_status_text + ) + except ValueError: + secondary_identities = () + errors[CONF_SECONDARY_STATUS_IDENTITIES] = "invalid_status_identities" + primary_identity = ( + (status_device_id, status_enum) + if status_device_id and status_enum + else None + ) + secondary_identities = tuple( + identity + for identity in secondary_identities + if primary_identity is None or identity != primary_identity + ) + open_time = float(user_input[CONF_OPEN_TIME_SECONDS]) + close_time = float(user_input[CONF_CLOSE_TIME_SECONDS]) + + if not device_name: + errors[CONF_DEVICE_NAME] = "required" + if not self._is_hex_value(command_device_id, 6): + errors[CONF_DEVICE_ID] = "invalid_device_id" + if not self._is_hex_value(command_enum, 2): + errors[CONF_DEVICE_ENUM] = "invalid_device_enum" + if bool(status_device_id) != bool(status_enum): + errors[ + CONF_STATUS_ENUM if status_device_id else CONF_STATUS_DEVICE_ID + ] = "status_identity_incomplete" + if status_device_id and not self._is_hex_value(status_device_id, 6): + errors[CONF_STATUS_DEVICE_ID] = "invalid_device_id" + if status_enum and not self._is_hex_value(status_enum, 2): + errors[CONF_STATUS_ENUM] = "invalid_device_enum" + if open_time <= 0: + errors[CONF_OPEN_TIME_SECONDS] = "invalid_travel_time" + if close_time <= 0: + errors[CONF_CLOSE_TIME_SECONDS] = "invalid_travel_time" + + if any( + candidate.subentry_id != subentry.subentry_id + and str( + candidate.data.get(CONF_COMMAND_DEVICE_ID) + or candidate.data.get(CONF_DEVICE_ID, "") + ).upper() + == command_device_id + for candidate in entry.subentries.values() + ): + errors[CONF_DEVICE_ID] = "already_configured" + + if not errors: + current_data.update( + { + CONF_DEVICE_ID: command_device_id, + CONF_DEVICE_ENUM: command_enum, + CONF_COMMAND_DEVICE_ID: command_device_id, + CONF_COMMAND_ENUM: command_enum, + CONF_STATUS_IDENTITY_SOURCE: ( + STATUS_IDENTITY_SOURCE_MANUAL + if status_device_id and status_enum + else STATUS_IDENTITY_SOURCE_UNKNOWN + ), + CONF_SECONDARY_STATUS_IDENTITIES: ( + serialize_status_identities(secondary_identities) + ), + CONF_OPEN_TIME: open_time, + CONF_CLOSE_TIME: close_time, + CONF_INVERT_DIRECTION: bool( + user_input.get(CONF_INVERT_DIRECTION, False) + ), + } + ) + if status_device_id and status_enum: + current_data[CONF_STATUS_DEVICE_ID] = status_device_id + current_data[CONF_STATUS_ENUM] = status_enum + else: + current_data.pop(CONF_STATUS_DEVICE_ID, None) + current_data.pop(CONF_STATUS_ENUM, None) + return self.async_update_and_abort( + entry, + subentry, + title=device_name, + data=current_data, + ) + + return self.async_show_form( + step_id="edit", + data_schema=vol.Schema( + { + vol.Required( + CONF_DEVICE_NAME, + default=subentry.title, + ): selector.TextSelector(), + vol.Required( + CONF_DEVICE_ID, + default=command_device_id, + ): selector.TextSelector(), + vol.Required( + CONF_DEVICE_ENUM, + default=command_enum, + ): selector.TextSelector(), + vol.Optional( + CONF_STATUS_DEVICE_ID, + default=status_device_id, + ): selector.TextSelector(), + vol.Optional( + CONF_STATUS_ENUM, + default=status_enum, + ): selector.TextSelector(), + vol.Optional( + CONF_SECONDARY_STATUS_IDENTITIES, + default=secondary_status_text, + ): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Required( + CONF_OPEN_TIME_SECONDS, + default=current_data.get(CONF_OPEN_TIME, 60.0), + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0.1, + step=0.1, + unit_of_measurement="s", + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Required( + CONF_CLOSE_TIME_SECONDS, + default=current_data.get(CONF_CLOSE_TIME, 60.0), + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0.1, + step=0.1, + unit_of_measurement="s", + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Optional( + CONF_INVERT_DIRECTION, + default=current_data.get(CONF_INVERT_DIRECTION, False), + ): selector.BooleanSelector(), + } + ), + errors=errors, + ) + + async def async_step_calibrate( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Run calibration for the selected blind subentry.""" handler = self._get_calibration_handler() handler.disable_subentry_creation() subentry = self._get_reconfigure_subentry() - device_id = subentry.data.get("device_id") - device_enum = subentry.data.get("device_enum") - if not device_id: + command_device_id = subentry.data.get( + CONF_COMMAND_DEVICE_ID, subentry.data.get(CONF_DEVICE_ID) + ) + command_enum = subentry.data.get( + CONF_COMMAND_ENUM, subentry.data.get(CONF_DEVICE_ENUM) + ) + status_device_id = subentry.data.get(CONF_STATUS_DEVICE_ID, command_device_id) + status_enum = subentry.data.get(CONF_STATUS_ENUM, command_enum) + if not command_device_id or not status_device_id: return self.async_abort(reason="device_not_found") - # Build a minimal device record; calibration handler will enrich after timing - device_name = subentry.title or f"Blind {device_id}" + stable_id = ( + subentry.unique_id + if isinstance(subentry.unique_id, str) and subentry.unique_id + else command_device_id + ) + device_name = subentry.title or f"Blind {stable_id}" handler.set_selected_device( { - "id": device_id, + "id": status_device_id, + "entity_id": stable_id, "name": device_name, CONF_OPEN_TIME: subentry.data.get(CONF_OPEN_TIME), CONF_CLOSE_TIME: subentry.data.get(CONF_CLOSE_TIME), - "enum": device_enum, + CONF_INVERT_DIRECTION: subentry.data.get(CONF_INVERT_DIRECTION, False), + "enum": status_enum, } ) diff --git a/custom_components/schellenberg_usb/const.py b/custom_components/schellenberg_usb/const.py index db1c864..1845e11 100644 --- a/custom_components/schellenberg_usb/const.py +++ b/custom_components/schellenberg_usb/const.py @@ -25,7 +25,26 @@ # Configuration keys CONF_SERIAL_PORT = "serial_port" +CONF_BLIND_ID = "blind_id" CONF_DEVICE_NAME = "device_name" +CONF_DEVICE_ENUM = "device_enum" +CONF_COMMAND_DEVICE_ID = "command_device_id" +CONF_COMMAND_ENUM = "command_enum" +CONF_STATUS_DEVICE_ID = "status_device_id" +CONF_STATUS_ENUM = "status_enum" +CONF_SECONDARY_STATUS_IDENTITIES = "secondary_status_identities" +CONF_STATUS_IDENTITY_SOURCE = "status_identity_source" +CONF_LAST_CALIBRATION = "last_calibration" +STATUS_IDENTITY_SOURCE_CALIBRATION = "calibration" +STATUS_IDENTITY_SOURCE_REMOTE_DISCOVERY = "remote_discovery" +STATUS_IDENTITY_SOURCE_MANUAL = "manual" +STATUS_IDENTITY_SOURCE_UNKNOWN = "unknown" +CONF_INVERT_DIRECTION = "invert_direction" +CONF_COMMAND = "command" +CONF_ENUM = "enum" +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_OPEN_TIME_SECONDS = "open_time_seconds" +CONF_CLOSE_TIME_SECONDS = "close_time_seconds" # Data keys DATA_API_INSTANCE = "api_instance" @@ -90,6 +109,11 @@ SIGNAL_PAIRING_TIMEOUT = f"{DOMAIN}_pairing_timeout" SIGNAL_STICK_STATUS_UPDATED = f"{DOMAIN}_stick_status_updated" SIGNAL_CALIBRATION_COMPLETED = f"{DOMAIN}_calibration_completed" +SIGNAL_MANUAL_POSITION_SYNC = f"{DOMAIN}_manual_position_sync" + +# Services +SERVICE_TEST_COMMAND = "test_command" +TEST_COMMAND_DELAY = 0.75 # Device verification VERIFY_TIMEOUT = 5 # seconds to wait for verification response @@ -98,6 +122,7 @@ # Pairing constants PAIRING_TIMEOUT = 120 # seconds to wait for pairing response PAIRING_DEVICE_ENUM_START = 0x10 # Start from 0x10 for new devices +STATUS_DISCOVERY_TIMEOUT = 45 # seconds to capture original-remote frames # Calibration constants CALIBRATION_TIMEOUT = 300 # Maximum 5 minutes (300 seconds) for calibration diff --git a/custom_components/schellenberg_usb/cover.py b/custom_components/schellenberg_usb/cover.py index 23ac1ec..7638ee4 100644 --- a/custom_components/schellenberg_usb/cover.py +++ b/custom_components/schellenberg_usb/cover.py @@ -12,7 +12,8 @@ CoverEntity, CoverEntityFeature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,23 +21,37 @@ from homeassistant.helpers.restore_state import RestoreEntity from .api import SchellenbergUsbApi +from .blind_id import normalize_blind_id from .const import ( CMD_DOWN, CMD_STOP, CMD_UP, + CONF_BLIND_ID, CONF_CLOSE_TIME, + CONF_COMMAND_DEVICE_ID, + CONF_COMMAND_ENUM, + CONF_DEVICE_ENUM, + CONF_DEVICE_ID, + CONF_INVERT_DIRECTION, CONF_OPEN_TIME, + CONF_SECONDARY_STATUS_IDENTITIES, CONF_SERIAL_PORT, + CONF_STATUS_DEVICE_ID, + CONF_STATUS_ENUM, + CONF_STATUS_IDENTITY_SOURCE, DOMAIN, EVENT_STARTED_MOVING_DOWN, EVENT_STARTED_MOVING_UP, EVENT_STOPPED, SIGNAL_CALIBRATION_COMPLETED, SIGNAL_DEVICE_EVENT, + SIGNAL_MANUAL_POSITION_SYNC, SIGNAL_STICK_STATUS_UPDATED, - SUBENTRY_TYPE_LED, + STATUS_IDENTITY_SOURCE_UNKNOWN, + SUBENTRY_TYPE_BLIND, SchellenbergConfigEntry, ) +from .identities import normalize_status_identities, normalize_status_identity _LOGGER = logging.getLogger(__name__) DEFAULT_TRAVEL_TIME = 60.0 # seconds, a sensible default @@ -65,41 +80,115 @@ async def async_setup_entry( api = entry.runtime_data # Get paired devices from subentries - subentries = entry.subentries.values() - _LOGGER.info("Hub has %d subentries (paired devices)", len(entry.subentries)) - - if not entry.subentries: - _LOGGER.info("No subentries (paired devices) found for hub") + subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == SUBENTRY_TYPE_BLIND + ] + _LOGGER.info("Hub has %d saved blind subentries", len(subentries)) + + if not subentries: + _LOGGER.info("No saved blind subentries found for hub") return - _LOGGER.info("Loading %d paired Schellenberg devices", len(entry.subentries)) + _LOGGER.info("Loading %d saved Schellenberg blinds", len(subentries)) for subentry in subentries: - # Skip LED subentry; handled by switch platform - if subentry.subentry_type == SUBENTRY_TYPE_LED: - continue - device_id = subentry.data.get("device_id") - device_enum = subentry.data.get("device_enum") + legacy_device_id = subentry.data.get(CONF_DEVICE_ID) + legacy_device_enum = subentry.data.get(CONF_DEVICE_ENUM) + command_device_id = ( + subentry.data.get(CONF_COMMAND_DEVICE_ID) or legacy_device_id + ) + command_enum = subentry.data.get(CONF_COMMAND_ENUM) or legacy_device_enum + status_identity_source = subentry.data.get(CONF_STATUS_IDENTITY_SOURCE) + if status_identity_source == STATUS_IDENTITY_SOURCE_UNKNOWN: + status_device_id = subentry.data.get(CONF_STATUS_DEVICE_ID) + status_enum = subentry.data.get(CONF_STATUS_ENUM) + else: + # Preserve historical behavior only for entries that predate the + # explicit unknown/automatic/manual provenance field. + status_device_id = ( + subentry.data.get(CONF_STATUS_DEVICE_ID) + or legacy_device_id + or command_device_id + ) + status_enum = ( + subentry.data.get(CONF_STATUS_ENUM) + or legacy_device_enum + or command_enum + ) + secondary_status_identities = normalize_status_identities( + subentry.data.get(CONF_SECONDARY_STATUS_IDENTITIES) + ) + subentry_unique_id = getattr(subentry, "unique_id", None) + stable_device_id = ( + subentry_unique_id + if isinstance(subentry_unique_id, str) and subentry_unique_id + else legacy_device_id or command_device_id + ) device_name = subentry.title - if not device_id or not device_enum: + if not all( + ( + stable_device_id, + command_device_id, + command_enum, + ) + ): # This subentry lacks motor identification info; it's likely a non-motor type # or pairing is incomplete. Downgrade to debug to avoid user confusion. _LOGGER.debug( - "Skipping subentry %s (type=%s) missing device_id/device_enum", + "Skipping subentry %s (type=%s) with incomplete command identity", subentry.subentry_id, getattr(subentry, "subentry_type", "unknown"), ) continue - # Check if entity already exists to avoid duplicates. - # NOTE: The cover entity sets its unique_id to f"schellenberg_{device_id}" in the entity class. - # We must use the same pattern here; previously this used f"{device_id}_cover" which never matched - # and caused a new entity to be created on every reload, losing the restored position (defaulting to 0). - entity_unique_id = f"schellenberg_{device_id}" + stable_device_id = str(stable_device_id) + command_device_id = str(command_device_id).strip().upper() + command_enum = str(command_enum).strip().upper().zfill(2) + if status_device_id is not None and status_enum is not None: + status_device_id = str(status_device_id).strip().upper() + status_enum = str(status_enum).strip().upper().zfill(2) + else: + status_device_id = None + status_enum = None + blind_id = normalize_blind_id(subentry.data.get(CONF_BLIND_ID)) or str( + subentry.subentry_id or stable_device_id + ) + + # A UUID remains stable when the blind name or radio identity changes. + # Existing protocol-derived registry entries are migrated below. + entity_unique_id = f"{DOMAIN}_blind_{blind_id}" existing_entity_id = entity_registry.async_get_entity_id( "cover", DOMAIN, entity_unique_id ) + if existing_entity_id is None: + for legacy_unique_id in dict.fromkeys( + ( + f"schellenberg_{stable_device_id}", + f"schellenberg_{command_device_id}", + ) + ): + legacy_entity_id = entity_registry.async_get_entity_id( + "cover", DOMAIN, legacy_unique_id + ) + if legacy_entity_id is None: + continue + entity_registry.async_update_entity( + legacy_entity_id, + new_unique_id=entity_unique_id, + config_subentry_id=subentry.subentry_id, + ) + existing_entity_id = legacy_entity_id + _LOGGER.info( + "Migrated cover entity %s from %s to stable blind ID %s", + legacy_entity_id, + legacy_unique_id, + blind_id, + ) + break + if existing_entity_id: # Entity registry entry already exists (e.g. after reload). We still need # to create a new entity object so Home Assistant can manage runtime state. @@ -124,29 +213,54 @@ async def async_setup_entry( device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, config_subentry_id=subentry.subentry_id, - identifiers={(DOMAIN, device_id)}, + identifiers={(DOMAIN, stable_device_id)}, name=device_name, manufacturer="Schellenberg", - model=f"USB Stick Motor ({device_id}/{device_enum})", + model=( + f"USB Stick Motor (command {command_device_id}/{command_enum}, " + f"primary status " + f"{f'{status_device_id}/{status_enum}' if status_device_id else 'unknown'}, " + f"secondary statuses {len(secondary_status_identities)})" + ), ) _LOGGER.debug( "Created/updated device %s for paired device %s", device.id, - device_id, + stable_device_id, + ) + + # Register persisted status identities immediately. Incoming frames can + # arrive before Home Assistant calls async_added_to_hass on the entity. + api.register_entity( + status_device_id, + status_enum, + device_name, + command_device_id=command_device_id, + command_enum=command_enum, + secondary_status_identities=secondary_status_identities, ) # Create cover entity linked to this device # Create and add the new cover entity attached to the subentry - _LOGGER.debug("Creating cover entity for device %s", device_id) + _LOGGER.debug("Creating cover entity for device %s", stable_device_id) async_add_entities( [ SchellenbergCover( api=api, - device_id=device_id, - device_enum=device_enum, + device_id=stable_device_id, + device_enum=command_enum, device_name=device_name, + blind_id=blind_id, device_data=subentry.data, config_entry_id=entry.entry_id, + command_device_id=command_device_id, + status_device_id=status_device_id, + status_enum=status_enum, + status_identity_source=str(status_identity_source or "legacy"), + secondary_status_identities=secondary_status_identities, + invert_direction=bool( + subentry.data.get(CONF_INVERT_DIRECTION, False) + ), ) ], config_subentry_id=subentry.subentry_id, @@ -176,8 +290,15 @@ def __init__( device_id: str, device_enum: str, device_name: str, + blind_id: str | None = None, device_data: Mapping[str, Any] | None = None, config_entry_id: str | None = None, + command_device_id: str | None = None, + status_device_id: str | None = None, + status_enum: str | None = None, + status_identity_source: str | None = None, + secondary_status_identities: object = None, + invert_direction: bool = False, ) -> None: """Initialize the Schellenberg cover entity. @@ -186,18 +307,62 @@ def __init__( device_id: The unique device ID (6-character hex) device_enum: The device enumerator for commands (2-character hex) device_name: Friendly name for the device + blind_id: Stable per-blind UUID used for the entity registry device_data: Device data dict containing calibration times config_entry_id: The config entry ID for linking to device + command_device_id: Protocol ID associated with outgoing commands + status_device_id: Protocol ID expected in incoming status messages + status_enum: Enum expected in primary incoming status messages + status_identity_source: How the primary status identity was obtained + secondary_status_identities: Additional identities matched diagnostically + invert_direction: Swap physical up/down commands for logical open/close """ self._api = api self._device_id = device_id - self._device_enum = device_enum + self._command_device_id = command_device_id or device_id + self._command_enum = device_enum + self._status_identity_source = status_identity_source or "legacy" + if self._status_identity_source == STATUS_IDENTITY_SOURCE_UNKNOWN: + primary_identity = None + else: + primary_identity = normalize_status_identity( + status_device_id or self._command_device_id, + status_enum or device_enum, + ) + self._status_device_id: str | None + self._status_enum: str | None + if primary_identity is None: + self._status_device_id = None + self._status_enum = None + else: + self._status_device_id, self._status_enum = primary_identity + secondary_source = secondary_status_identities + if secondary_source is None and device_data is not None: + secondary_source = device_data.get(CONF_SECONDARY_STATUS_IDENTITIES) + primary_identity = normalize_status_identity( + self._status_device_id, self._status_enum + ) + self._secondary_status_identities = tuple( + identity + for identity in normalize_status_identities(secondary_source) + if identity != primary_identity + ) + self._invert_direction = invert_direction + # Backward-compatible alias retained for diagnostics. + self._device_enum = self._command_enum self._config_entry_id = config_entry_id + blind_id_source = blind_id + if blind_id_source is None and device_data is not None: + blind_id_source = device_data.get(CONF_BLIND_ID) + self._blind_id = normalize_blind_id(blind_id_source) or str( + blind_id_source or device_id + ) # Entity attributes - self._attr_unique_id = f"schellenberg_{device_id}" - self._attr_name = device_name + self._attr_unique_id = f"{DOMAIN}_blind_{self._blind_id}" + self._device_name = device_name + self._attr_name = None self._attr_is_closed = None self._attr_is_opening = False self._attr_is_closing = False @@ -229,6 +394,10 @@ def __init__( self._target_position: int | None = ( None # Target position for set_cover_position ) + self._position_update_source = "not recorded" + self._position_source_kind = "startup default" + self._position_confirmed_since_restart = False + self._full_travel_resync_direction: str | None = None # NOTE: Debug/troubleshooting instrumentation removed now that persistence works reliably. @property @@ -262,9 +431,17 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() # Register this entity with the API so it knows we're listening - self._api.register_entity(self._device_id, self._device_enum) + self._api.register_entity( + self._status_device_id, + self._status_enum, + self._device_name, + command_device_id=self._command_device_id, + command_enum=self._command_enum, + secondary_status_identities=self._secondary_status_identities, + ) # Restore the last known state + restored_from_ha = False last_state = await self.async_get_last_state() if last_state: # HA stores cover position attribute as 'current_position'. Some code historically @@ -295,9 +472,10 @@ async def async_added_to_hass(self) -> None: # Use exact restored value without inferring 100 from 'open' state; allows partial positions. self._attr_current_cover_position = max(0, min(100, restored_position)) self._attr_is_closed = self._attr_current_cover_position == 0 + restored_from_ha = True _LOGGER.debug( "Restored position for %s (%s) to %d%% (raw=%s)", - self._attr_name, + self._device_name, self._device_id, self._attr_current_cover_position, raw_position, @@ -308,22 +486,52 @@ async def async_added_to_hass(self) -> None: self._attr_is_closed = True _LOGGER.debug( "No previous state for %s (%s); defaulting position to 0%% (closed)", - self._attr_name, + self._device_name, self._device_id, ) + if restored_from_ha: + self._position_source_kind = "restored HA state" + self._position_update_source = "restored HA state" + startup_status = "restored / estimated / not confirmed since restart" + else: + self._position_source_kind = "startup default" + self._position_update_source = "startup default (no restored HA state)" + startup_status = "estimated / not confirmed since restart" + self._position_confirmed_since_restart = False + self._full_travel_resync_direction = None + # IMPORTANT: We must write the restored (or default) position to the state machine now. # add_to_platform_finish() already wrote an initial state before restoration ran, so without # this call the restored position would not be visible until the first movement/event. # Initial write after restoration (debug instrumentation removed). self.async_write_ha_state() + self._record_position_update( + source=self._position_update_source, + direction="idle", + previous_position=None, + new_position=self._attr_current_cover_position, + status=startup_status, + ) + + # Only an observed or manually supplied primary status identity may drive + # received-frame position tracking. Unknown status never aliases command ID. + if self._status_device_id is not None and self._status_enum is not None: + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SIGNAL_DEVICE_EVENT}_{self._status_device_id}_{self._status_enum}", + self._handle_event, + ) + ) - # Register listeners for events and status updates + # Developer Tools position corrections target the command identity because it + # is unique per configured cover and does not depend on a received RF frame. self.async_on_remove( async_dispatcher_connect( self.hass, - f"{SIGNAL_DEVICE_EVENT}_{self._device_id}", - self._handle_event, + f"{SIGNAL_MANUAL_POSITION_SYNC}_{self._command_device_id.upper()}", + self._handle_manual_position_sync, ) ) @@ -345,11 +553,74 @@ async def async_added_to_hass(self) -> None: ) ) + # Persist the latest estimate and stop the non-critical loop before HA's + # final-write shutdown stage. + self.async_on_remove( + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop + ) + ) + + def _record_position_update( + self, + *, + source: str, + direction: str, + previous_position: int | None, + new_position: int | None, + status: str, + ) -> None: + """Publish one position model update for Developer Tools diagnostics.""" + self._api.record_position_update( + self._command_device_id, + source=source, + direction=direction, + previous_position=previous_position, + new_position=new_position, + position_source=self._position_source_kind, + confirmed_since_restart=self._position_confirmed_since_restart, + status=status, + ) + @callback def _handle_status_update(self) -> None: """Handle status update from API (connection state changed).""" self.async_write_ha_state() + @callback + def _handle_manual_position_sync(self, position: int) -> None: + """Apply an exact user-provided position without transmitting to the motor.""" + normalized_position = max(0, min(100, int(position))) + previous_position = self._attr_current_cover_position + self._stop_position_tracking() + self._attr_current_cover_position = normalized_position + self._attr_is_closed = normalized_position == 0 + self._attr_is_opening = False + self._attr_is_closing = False + self._move_start_time = None + self._move_start_position = None + self._target_position = None + self._position_update_source = "Developer Tools manual position sync" + self._position_source_kind = "manual sync" + self._position_confirmed_since_restart = True + self._full_travel_resync_direction = None + self._record_position_update( + source=self._position_update_source, + direction="manual", + previous_position=previous_position, + new_position=normalized_position, + status="confirmed/manual", + ) + _LOGGER.warning( + "Manual position sync applied cover=%s command_device_id=%s " + "previous_position=%s new_position=%d status=confirmed/manual", + self._device_name, + self._command_device_id, + previous_position, + normalized_position, + ) + self.async_write_ha_state() + @callback def _handle_calibration_completed( self, device_id: str, open_time: float, close_time: float @@ -360,17 +631,29 @@ def _handle_calibration_completed( return # Update travel times with new calibration values + previous_position = self._attr_current_cover_position self._travel_time_open = open_time self._travel_time_close = close_time # The device is fully closed after calibration, so set position to 0 self._attr_current_cover_position = 0 self._attr_is_closed = True + self._position_update_source = "completed calibration" + self._position_source_kind = "calibration" + self._position_confirmed_since_restart = True + self._full_travel_resync_direction = None + self._record_position_update( + source=self._position_update_source, + direction="stop", + previous_position=previous_position, + new_position=0, + status="confirmed", + ) _LOGGER.info( "Device %s calibration updated: open_time=%.2fs, close_time=%.2fs. " "Cover position set to fully closed (0%%)", - self._attr_name, + self._device_name, open_time, close_time, ) @@ -378,42 +661,110 @@ def _handle_calibration_completed( # Update entity state self.async_write_ha_state() + async def _async_handle_hass_stop(self, _event: Event) -> None: + """Persist and stop position tracking before final-write shutdown.""" + await self._async_shutdown_position_tracking("Home Assistant stopping") + + async def _async_shutdown_position_tracking(self, reason: str) -> None: + """Persist the latest estimate and await position-loop cancellation.""" + task = self._position_update_task + if task is None: + return + + previous_position = self._attr_current_cover_position + self._update_position() + if self.entity_id is not None: + self.async_write_ha_state() + _LOGGER.debug( + "Stopping position tracking cover=%s reason=%s position=%s previous=%s", + self._device_name, + reason, + self._attr_current_cover_position, + previous_position, + ) + await self._async_cancel_position_tracking(reason) + + async def _async_cancel_position_tracking(self, reason: str) -> None: + """Cancel and await the currently tracked position loop, if any.""" + task = self._position_update_task + if task is None: + return + if task is asyncio.current_task(): + if self._position_update_task is task: + self._position_update_task = None + return + + if self._position_update_task is task: + self._position_update_task = None + if not task.done(): + task.cancel(reason) + try: + await task + except asyncio.CancelledError: + pass + except Exception: + _LOGGER.exception( + "Position tracking task failed while stopping cover=%s reason=%s", + self._device_name, + reason, + ) + async def async_will_remove_from_hass(self) -> None: - """Clean up when entity is removed.""" + """Clean up when entity is removed or its config entry is unloaded.""" + await self._async_shutdown_position_tracking("entity removal or entry unload") await super().async_will_remove_from_hass() - # Stop any running position tracking tasks - self._stop_position_tracking() @callback def _handle_event(self, event: str) -> None: """Handle events from the USB stick for this device.""" _LOGGER.info( "Device %s (%s) received activity event: %s", - self._attr_name, + self._device_name, self._device_id, event, ) - if event == EVENT_STARTED_MOVING_UP: - _LOGGER.info("Device %s started moving UP", self._attr_name) - self._attr_is_opening = True - self._attr_is_closing = False - self._move_start_time = time.monotonic() - self._move_start_position = self._attr_current_cover_position - # Start real-time position tracking - self._start_position_tracking() - elif event == EVENT_STARTED_MOVING_DOWN: - _LOGGER.info("Device %s started moving DOWN", self._attr_name) - self._attr_is_opening = False - self._attr_is_closing = True + if event in (EVENT_STARTED_MOVING_UP, EVENT_STARTED_MOVING_DOWN): + previous_position = self._attr_current_cover_position + physical_up = event == EVENT_STARTED_MOVING_UP + logical_opening = physical_up != self._invert_direction + _LOGGER.info( + "Device %s physical_direction=%s logical_direction=%s", + self._device_name, + "up" if physical_up else "down", + "opening" if logical_opening else "closing", + ) + self._attr_is_opening = logical_opening + self._attr_is_closing = not logical_opening self._move_start_time = time.monotonic() self._move_start_position = self._attr_current_cover_position - # Start real-time position tracking + self._position_source_kind = "primary status" + self._position_confirmed_since_restart = True + self._full_travel_resync_direction = None + self._position_update_source = ( + f"primary status {self._status_device_id}/{self._status_enum} " + f"command {event}" + ) + self._record_position_update( + source=self._position_update_source, + direction="opening" if logical_opening else "closing", + previous_position=previous_position, + new_position=self._attr_current_cover_position, + status="confirmed", + ) self._start_position_tracking() elif event == EVENT_STOPPED: + previous_position = self._attr_current_cover_position + self._position_source_kind = "primary status" + self._position_confirmed_since_restart = True + self._full_travel_resync_direction = None + self._position_update_source = ( + f"primary status {self._status_device_id}/{self._status_enum} " + f"command {event}" + ) _LOGGER.info( "Device %s STOPPED (position: %d%%)", - self._attr_name, + self._device_name, self._attr_current_cover_position, ) # Stop real-time position tracking @@ -435,35 +786,96 @@ def _handle_event(self, event: str) -> None: self._attr_is_closed = self._attr_current_cover_position == 0 self._attr_is_opening = False self._attr_is_closing = False + self._record_position_update( + source=self._position_update_source, + direction="stop", + previous_position=previous_position, + new_position=self._attr_current_cover_position, + status="confirmed", + ) # Clear movement tracking variables self._move_start_time = None self._move_start_position = None self._target_position = None # Clear target position on stop else: _LOGGER.debug( - "Device %s received unknown event: %s", self._attr_name, event + "Device %s received unknown event: %s", self._device_name, event ) self.async_write_ha_state() def _start_position_tracking(self) -> None: - """Start tracking position updates every second.""" - # Cancel any existing tracking task - self._stop_position_tracking() + """Ensure exactly one lifecycle-managed position loop is running.""" + existing_task = self._position_update_task + if ( + existing_task is not None + and not existing_task.done() + and not existing_task.cancelling() + ): + return + if self._position_update_task is existing_task: + self._position_update_task = None + + task_name = f"{DOMAIN} position update {self._blind_id}" + config_entry = ( + self.hass.config_entries.async_get_entry(self._config_entry_id) + if self._config_entry_id is not None + else None + ) + if config_entry is not None: + task = config_entry.async_create_background_task( + self.hass, + self._async_position_update_loop(), + task_name, + ) + else: + task = self.hass.async_create_background_task( + self._async_position_update_loop(), task_name + ) + self._position_update_task = task - # Create a new task to update position every second - self._position_update_task = self.hass.async_create_task( - self._async_position_update_loop() + def _stop_position_tracking( + self, reason: str = "position tracking stopped" + ) -> None: + """Request immediate cancellation from synchronous event handlers.""" + task = self._position_update_task + if task is not None and not task.done(): + task.cancel(reason) + if self._position_update_task is task: + self._position_update_task = None + + def _waiting_for_full_travel_resync(self, direction: str) -> bool: + """Return whether an unconfirmed startup estimate needs more travel time.""" + if ( + self._full_travel_resync_direction != direction + or self._move_start_time is None + ): + return False + travel_time = ( + self._travel_time_open + if direction == "opening" + else self._travel_time_close ) + return time.monotonic() - self._move_start_time < travel_time - def _stop_position_tracking(self) -> None: - """Stop the position tracking task.""" - if self._position_update_task and not self._position_update_task.done(): - self._position_update_task.cancel() - self._position_update_task = None + def _confirm_full_travel_resync(self, direction: str, position: int) -> None: + """Anchor an endpoint after one complete configured travel interval.""" + if self._full_travel_resync_direction != direction: + return + previous_position = self._move_start_position + self._position_confirmed_since_restart = True + self._full_travel_resync_direction = None + self._record_position_update( + source=self._position_update_source, + direction=direction, + previous_position=previous_position, + new_position=position, + status="estimated from full travel", + ) async def _async_position_update_loop(self) -> None: """Update position every 200ms internally, report to HA every 1 second.""" + position_task = asyncio.current_task() try: ha_update_counter = 0 while True: @@ -492,15 +904,18 @@ async def _async_position_update_loop(self) -> None: self._attr_current_cover_position = self._target_position _LOGGER.info( "Device %s reached target position (%d%%)", - self._attr_name, + self._device_name, self._target_position, ) # If target is 0 or 100, let the device stop naturally at its limits. # For intermediate, send STOP and wait for STOP event to finalize & clear target. if self._target_position not in (0, 100): - await self._api.control_blind(self._device_enum, CMD_STOP) + await self._api.control_blind( + self._command_enum, + CMD_STOP, + device_id=self._command_device_id, + ) # Stop tracking loop - self._position_update_task = None # Leave opening/closing flags as-is until STOP to aid debugging self._move_start_time = None self._move_start_position = None @@ -515,13 +930,14 @@ async def _async_position_update_loop(self) -> None: self._attr_is_closing and self._attr_current_cover_position is not None and self._attr_current_cover_position <= 0 + and not self._waiting_for_full_travel_resync("closing") ): _LOGGER.info( "Device %s reached fully closed position (0%%)", - self._attr_name, + self._device_name, ) self._attr_current_cover_position = 0 - self._position_update_task = None + self._confirm_full_travel_resync("closing", 0) self._attr_is_opening = False self._attr_is_closing = False self._move_start_time = None @@ -532,13 +948,14 @@ async def _async_position_update_loop(self) -> None: self._attr_is_opening and self._attr_current_cover_position is not None and self._attr_current_cover_position >= 100 + and not self._waiting_for_full_travel_resync("opening") ): _LOGGER.info( "Device %s reached fully open position (100%%)", - self._attr_name, + self._device_name, ) self._attr_current_cover_position = 100 - self._position_update_task = None + self._confirm_full_travel_resync("opening", 100) self._attr_is_opening = False self._attr_is_closing = False self._move_start_time = None @@ -551,9 +968,14 @@ async def _async_position_update_loop(self) -> None: self.async_write_ha_state() ha_update_counter = 0 except asyncio.CancelledError: - _LOGGER.debug("Position tracking cancelled for device %s", self._attr_name) - self._position_update_task = None + _LOGGER.debug( + "Position tracking cancelled for device %s", self._device_name + ) raise + finally: + # A cancelled older loop must never clear its newer replacement. + if self._position_update_task is position_task: + self._position_update_task = None def _update_position(self) -> None: """Calculate and update the position based on travel time.""" @@ -580,8 +1002,17 @@ def _update_position(self) -> None: return # Clamp position between 0 and 100 + previous_position = self._attr_current_cover_position self._attr_current_cover_position = max(0, min(100, int(new_pos))) self._attr_is_closed = self._attr_current_cover_position == 0 + if self._attr_current_cover_position != previous_position: + self._record_position_update( + source=self._position_update_source, + direction="opening" if self._attr_is_opening else "closing", + previous_position=previous_position, + new_position=self._attr_current_cover_position, + status="estimated", + ) _LOGGER.debug( "Device %s position updated to %d%% (elapsed: %.2fs, travel_time: %.2fs)", @@ -593,7 +1024,14 @@ def _update_position(self) -> None: async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - _LOGGER.debug("Opening cover %s (enum=%s)", self._attr_name, self._device_enum) + action = CMD_DOWN if self._invert_direction else CMD_UP + _LOGGER.debug( + "Opening cover %s (command_id=%s enum=%s action=%s)", + self._device_name, + self._command_device_id, + self._command_enum, + action, + ) self._attr_is_opening = True self._attr_is_closing = False self._move_start_time = time.monotonic() @@ -601,35 +1039,96 @@ async def async_open_cover(self, **kwargs: Any) -> None: if self._attr_current_cover_position is None: self._attr_current_cover_position = 0 self._move_start_position = self._attr_current_cover_position + self._position_update_source = "Home Assistant open command" + self._position_source_kind = "HA command" + self._full_travel_resync_direction = ( + "opening" + if not self._position_confirmed_since_restart + and self._target_position is None + else None + ) + self._record_position_update( + source=self._position_update_source, + direction="opening", + previous_position=self._attr_current_cover_position, + new_position=self._attr_current_cover_position, + status="estimated", + ) + await self._async_cancel_position_tracking("new open command") self._start_position_tracking() self.async_write_ha_state() - await self._api.control_blind(self._device_enum, CMD_UP) + await self._api.control_blind( + self._command_enum, action, device_id=self._command_device_id + ) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - _LOGGER.debug("Closing cover %s (enum=%s)", self._attr_name, self._device_enum) + action = CMD_UP if self._invert_direction else CMD_DOWN + _LOGGER.debug( + "Closing cover %s (command_id=%s enum=%s action=%s)", + self._device_name, + self._command_device_id, + self._command_enum, + action, + ) self._attr_is_opening = False self._attr_is_closing = True self._move_start_time = time.monotonic() if self._attr_current_cover_position is None: self._attr_current_cover_position = 0 self._move_start_position = self._attr_current_cover_position + self._position_update_source = "Home Assistant close command" + self._position_source_kind = "HA command" + self._full_travel_resync_direction = ( + "closing" + if not self._position_confirmed_since_restart + and self._target_position is None + else None + ) + self._record_position_update( + source=self._position_update_source, + direction="closing", + previous_position=self._attr_current_cover_position, + new_position=self._attr_current_cover_position, + status="estimated", + ) + await self._async_cancel_position_tracking("new close command") self._start_position_tracking() self.async_write_ha_state() - await self._api.control_blind(self._device_enum, CMD_DOWN) + await self._api.control_blind( + self._command_enum, action, device_id=self._command_device_id + ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - _LOGGER.debug("Stopping cover %s (enum=%s)", self._attr_name, self._device_enum) - self._stop_position_tracking() + _LOGGER.debug( + "Stopping cover %s (command_id=%s enum=%s)", + self._device_name, + self._command_device_id, + self._command_enum, + ) + previous_position = self._attr_current_cover_position + self._position_source_kind = "HA command" + self._full_travel_resync_direction = None + self._position_update_source = "Home Assistant stop command" + await self._async_cancel_position_tracking("Home Assistant stop command") self._update_position() self._attr_is_opening = False self._attr_is_closing = False self._move_start_time = None self._move_start_position = None self._target_position = None + self._record_position_update( + source=self._position_update_source, + direction="stop", + previous_position=previous_position, + new_position=self._attr_current_cover_position, + status="estimated", + ) self.async_write_ha_state() - await self._api.control_blind(self._device_enum, CMD_STOP) + await self._api.control_blind( + self._command_enum, CMD_STOP, device_id=self._command_device_id + ) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -641,7 +1140,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: _LOGGER.info( "Setting cover %s position from %d%% to %d%%", - self._attr_name, + self._device_name, current_position, target_position, ) @@ -657,14 +1156,14 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: if target_position > current_position: _LOGGER.info( "Moving cover %s UP to reach target %d%%", - self._attr_name, + self._device_name, target_position, ) await self.async_open_cover() else: _LOGGER.info( "Moving cover %s DOWN to reach target %d%%", - self._attr_name, + self._device_name, target_position, ) await self.async_close_cover() diff --git a/custom_components/schellenberg_usb/identities.py b/custom_components/schellenberg_usb/identities.py new file mode 100644 index 0000000..99aa092 --- /dev/null +++ b/custom_components/schellenberg_usb/identities.py @@ -0,0 +1,169 @@ +"""Helpers for persisted Schellenberg status identities.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Mapping +from typing import Any + +type StatusIdentity = tuple[str, str] + +_STATUS_IDENTITY_PATTERN = re.compile( + r"^([0-9A-Fa-f]{6})\s*[/:-]\s*([0-9A-Fa-f]{1,2})$" +) + + +def normalize_status_identity( + device_id: object, device_enum: object +) -> StatusIdentity | None: + """Normalize one status identity, returning None when it is malformed.""" + normalized_id = str(device_id).strip().upper() + normalized_enum = str(device_enum).strip().upper() + if len(normalized_enum) <= 2: + normalized_enum = normalized_enum.zfill(2) + if ( + len(normalized_id) != 6 + or len(normalized_enum) != 2 + or any(character not in "0123456789ABCDEF" for character in normalized_id) + or any(character not in "0123456789ABCDEF" for character in normalized_enum) + ): + return None + return normalized_id, normalized_enum + + +def normalize_status_identities(value: object) -> tuple[StatusIdentity, ...]: + """Normalize persisted list or text representations, ignoring invalid items.""" + if value is None or value == "": + return () + if isinstance(value, str): + try: + return parse_status_identities_text(value) + except ValueError: + return () + if not isinstance(value, Iterable) or isinstance(value, (bytes, Mapping)): + return () + + identities: list[StatusIdentity] = [] + for item in value: + identity: StatusIdentity | None = None + if isinstance(item, Mapping): + identity = normalize_status_identity( + item.get("device_id", ""), + item.get("enum", item.get("device_enum", "")), + ) + elif isinstance(item, (tuple, list)) and len(item) == 2: + identity = normalize_status_identity(item[0], item[1]) + if identity is not None and identity not in identities: + identities.append(identity) + return tuple(identities) + + +def parse_status_identities_text(value: object) -> tuple[StatusIdentity, ...]: + """Parse comma/newline-separated DEVICE_ID/ENUM identities strictly.""" + text = str(value or "").strip() + if not text: + return () + identities: list[StatusIdentity] = [] + for token in re.split(r"[,;\n]+", text): + token = token.strip() + if not token: + continue + match = _STATUS_IDENTITY_PATTERN.fullmatch(token) + if match is None: + raise ValueError(f"invalid status identity: {token}") + identity = normalize_status_identity(match.group(1), match.group(2)) + if identity is None: + raise ValueError(f"invalid status identity: {token}") + if identity not in identities: + identities.append(identity) + return tuple(identities) + + +def serialize_status_identities( + identities: Iterable[StatusIdentity], +) -> list[dict[str, str]]: + """Return stable JSON-compatible status identity dictionaries.""" + return [ + {"device_id": device_id, "enum": device_enum} + for device_id, device_enum in identities + ] + + +def format_status_identities(value: object) -> str: + """Format persisted identities for a multiline config-flow text field.""" + return "\n".join( + f"{device_id}/{device_enum}" + for device_id, device_enum in normalize_status_identities(value) + ) + + +def summarize_status_discovery_frames( + frames: Iterable[Mapping[str, Any]], +) -> dict[str, Any]: + """Group one bounded remote-capture window and choose its tracking stream.""" + recognized = {"00", "01", "02"} + groups: dict[StatusIdentity, dict[str, Any]] = {} + for frame in frames: + identity = normalize_status_identity( + frame.get("device_id", ""), frame.get("enum", "") + ) + if identity is None: + continue + command = str(frame.get("command", "")).strip().upper() + if len(command) != 2 or any( + character not in "0123456789ABCDEF" for character in command + ): + continue + group = groups.setdefault( + identity, + { + "device_id": identity[0], + "enum": identity[1], + "commands": [], + "timestamps": [], + "frame_count": 0, + "recognized_frame_count": 0, + }, + ) + group["frame_count"] += 1 + if command in recognized: + group["recognized_frame_count"] += 1 + if command not in group["commands"]: + group["commands"].append(command) + timestamp = str(frame.get("time", "")).strip() + if timestamp: + group["timestamps"].append(timestamp) + + candidates = [ + group for group in groups.values() if recognized.intersection(group["commands"]) + ] + primary = ( + max( + candidates, + key=lambda group: ( + len(recognized.intersection(group["commands"])), + group["recognized_frame_count"], + ), + ) + if candidates + else None + ) + secondary = [group for group in groups.values() if group is not primary] + unknown_commands = [ + { + "device_id": group["device_id"], + "enum": group["enum"], + "commands": [ + command for command in group["commands"] if command not in recognized + ], + } + for group in groups.values() + if any(command not in recognized for command in group["commands"]) + ] + return { + "primary": primary, + "secondary": secondary, + "groups": list(groups.values()), + "unknown_commands": unknown_commands, + "position_tracking_available": primary is not None, + } diff --git a/custom_components/schellenberg_usb/manifest.json b/custom_components/schellenberg_usb/manifest.json index 0cd6518..0330d1d 100644 --- a/custom_components/schellenberg_usb/manifest.json +++ b/custom_components/schellenberg_usb/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "issue_tracker": "https://github.com/GimpArm/schellenberg_usb/issues", - "requirements": ["pyserial-asyncio==0.6"], + "requirements": ["pyserial-asyncio-fast==0.16"], "usb": [ { "vid": "16C0", diff --git a/custom_components/schellenberg_usb/options_flow_calibration.py b/custom_components/schellenberg_usb/options_flow_calibration.py index fcafb2e..95d945b 100644 --- a/custom_components/schellenberg_usb/options_flow_calibration.py +++ b/custom_components/schellenberg_usb/options_flow_calibration.py @@ -20,16 +20,30 @@ ) from homeassistant.helpers.storage import Store +from .blind_id import generate_blind_id, normalize_blind_id +from .api import SchellenbergUsbApi from .const import ( CALIBRATION_TIMEOUT, + CONF_BLIND_ID, CONF_CLOSE_TIME, + CONF_COMMAND_DEVICE_ID, + CONF_COMMAND_ENUM, + CONF_DEVICE_ENUM, CONF_DEVICE_ID, + CONF_INVERT_DIRECTION, + CONF_LAST_CALIBRATION, CONF_OPEN_TIME, + CONF_SECONDARY_STATUS_IDENTITIES, + CONF_STATUS_DEVICE_ID, + CONF_STATUS_ENUM, + CONF_STATUS_IDENTITY_SOURCE, EVENT_STARTED_MOVING_DOWN, EVENT_STARTED_MOVING_UP, EVENT_STOPPED, SIGNAL_CALIBRATION_COMPLETED, SIGNAL_DEVICE_EVENT, + STATUS_IDENTITY_SOURCE_CALIBRATION, + STATUS_IDENTITY_SOURCE_UNKNOWN, ) _LOGGER = logging.getLogger(__name__) @@ -55,9 +69,124 @@ def __init__(self, flow: OptionsFlow | ConfigSubentryFlow) -> None: self._open_time: float | None = None self._close_time: float | None = None self._create_subentry_after_calibration = False + self._pending_blind_id: str | None = None self._pending_device_id: str | None = None self._pending_device_enum: str | None = None self._pending_device_name: str | None = None + self._pending_status_device_id: str | None = None + self._pending_status_enum: str | None = None + self._pending_secondary_status_identities: list[dict[str, str]] = [] + self._pending_status_identity_source: str | None = None + self._calibration_discovery_result: dict[str, Any] | None = None + self._pending_invert_direction = False + + def _runtime_api(self) -> SchellenbergUsbApi | None: + """Return the loaded hub API when this flow has one.""" + entry = None + get_entry = getattr(self.flow, "_get_entry", None) + if callable(get_entry): + entry = get_entry() + if entry is None: + entry = getattr(self.flow, "config_entry", None) + api = getattr(entry, "runtime_data", None) + return api if isinstance(api, SchellenbergUsbApi) else None + + def _start_calibration_capture(self) -> None: + """Begin phase-labelled raw-frame capture for this calibration run.""" + api = self._runtime_api() + if api is None: + return + try: + api.start_status_frame_capture(phase="opening") + except RuntimeError: + # A stale capture should not break calibration; close it explicitly and + # start the calibration-owned window. + api.finish_status_frame_capture(end_reason="superseded_by_calibration") + api.start_status_frame_capture(phase="opening") + + def _set_calibration_capture_phase(self, phase: str) -> None: + """Label future frames with the current calibration leg.""" + api = self._runtime_api() + if api is not None: + api.set_status_frame_capture_phase(phase) + + def _finish_calibration_capture(self, end_reason: str) -> None: + """Finish capture and retain candidates for persistence and summary.""" + api = self._runtime_api() + if api is None: + return + self._calibration_discovery_result = api.finish_status_frame_capture( + end_reason=end_reason + ) + + def _apply_calibration_status_candidates(self) -> None: + """Apply captured identities to pending subentry fields without guessing.""" + result = self._calibration_discovery_result + if not result: + self._pending_status_device_id = None + self._pending_status_enum = None + self._pending_status_identity_source = STATUS_IDENTITY_SOURCE_UNKNOWN + return + primary = result.get("primary") + if primary is None: + self._pending_status_device_id = None + self._pending_status_enum = None + self._pending_status_identity_source = STATUS_IDENTITY_SOURCE_UNKNOWN + else: + self._pending_status_device_id = str(primary["device_id"]) + self._pending_status_enum = str(primary["enum"]) + self._pending_status_identity_source = STATUS_IDENTITY_SOURCE_CALIBRATION + self._pending_secondary_status_identities = [ + {"device_id": str(group["device_id"]), "enum": str(group["enum"])} + for group in result.get("secondary", []) + ] + + def _calibration_record(self) -> dict[str, Any] | None: + """Return JSON-compatible diagnostics for the completed calibration run.""" + if self._calibration_discovery_result is None: + return None + return { + **self._calibration_discovery_result, + "open_time": round(self._open_time, 2) if self._open_time else None, + "close_time": round(self._close_time, 2) if self._close_time else None, + } + + def _calibration_summary_placeholders(self) -> dict[str, str]: + """Build a user-facing summary of measured times and received streams.""" + result = self._calibration_discovery_result or {} + primary = result.get("primary") + secondary = result.get("secondary", []) + primary_text = ( + f"{primary['device_id']}/{primary['enum']}" + if primary is not None + else "Not discovered" + ) + primary_frames = ( + ", ".join(primary.get("commands", [])) if primary is not None else "None" + ) + secondary_text = ( + ", ".join( + f"{group['device_id']}/{group['enum']} " + f"({','.join(group.get('commands', []))})" + for group in secondary + ) + or "None" + ) + return { + "primary_status_identity": primary_text, + "primary_frames": primary_frames, + "secondary_status_identities": secondary_text, + "position_tracking": ( + "Available from received status frames" + if primary is not None + else ( + "Unavailable: HA commands can still estimate position, but " + "remote/status tracking was not discovered" + ) + ), + "calibration_end_reason": str(result.get("end_reason", "completed")), + "observed_frame_count": str(len(result.get("frames", []))), + } async def set_device_by_id(self, device_id: str) -> None: """Set the device to calibrate by its ID. @@ -209,11 +338,17 @@ async def async_step_calibration_open_instruction( ) # User clicked Next - wait for movement start and measure timing + self._start_calibration_capture() try: - # Wait for user to manually open the blinds - # This will trigger EVENT_STARTED_MOVING_UP from the device - start_ok = await self._wait_for_movement_start(EVENT_STARTED_MOVING_UP) + # Wait for the physical direction that corresponds to logical opening. + open_event = ( + EVENT_STARTED_MOVING_DOWN + if self._selected_device.get(CONF_INVERT_DIRECTION, False) + else EVENT_STARTED_MOVING_UP + ) + start_ok = await self._wait_for_movement_start(open_event) if not start_ok: + self._finish_calibration_capture("opening_start_timeout") errors["base"] = "calibration_start_timeout" return self.flow.async_show_form( step_id="calibration_open_instruction", @@ -231,6 +366,7 @@ async def async_step_calibration_open_instruction( # Wait for device to stop moving stop_ok = await self._wait_for_stop_event() if not stop_ok: + self._finish_calibration_capture("opening_stop_timeout") errors["base"] = "calibration_timeout" return self.flow.async_show_form( step_id="calibration_open_instruction", @@ -245,11 +381,13 @@ async def async_step_calibration_open_instruction( # Record the open time self._open_time = time.time() - self._calibration_start_time _LOGGER.debug("Calibration open_time: %s seconds", self._open_time) + self._set_calibration_capture_phase("idle_between_legs") # Move to close instruction step return await self.async_step_calibration_close_instruction() except Exception: # noqa: BLE001 + self._finish_calibration_capture("opening_error") errors["base"] = "unknown" return self.flow.async_show_form( step_id="calibration_open_instruction", @@ -282,11 +420,17 @@ async def async_step_calibration_close_instruction( ) # User clicked Next - wait for movement start and measure timing + self._set_calibration_capture_phase("closing") try: - # Wait for user to manually close the blinds - # This will trigger EVENT_STARTED_MOVING_DOWN from the device - start_ok = await self._wait_for_movement_start(EVENT_STARTED_MOVING_DOWN) + # Wait for the physical direction that corresponds to logical closing. + close_event = ( + EVENT_STARTED_MOVING_UP + if self._selected_device.get(CONF_INVERT_DIRECTION, False) + else EVENT_STARTED_MOVING_DOWN + ) + start_ok = await self._wait_for_movement_start(close_event) if not start_ok: + self._finish_calibration_capture("closing_start_timeout") errors["base"] = "calibration_start_timeout" return self.flow.async_show_form( step_id="calibration_close_instruction", @@ -304,6 +448,7 @@ async def async_step_calibration_close_instruction( # Wait for device to stop moving stop_ok = await self._wait_for_stop_event() if not stop_ok: + self._finish_calibration_capture("closing_stop_timeout") errors["base"] = "calibration_timeout" return self.flow.async_show_form( step_id="calibration_close_instruction", @@ -318,11 +463,14 @@ async def async_step_calibration_close_instruction( # Record the close time self._close_time = time.time() - self._calibration_start_time _LOGGER.debug("Calibration close_time: %s seconds", self._close_time) + self._finish_calibration_capture("completed") + self._apply_calibration_status_candidates() # Move to completion step return await self.async_step_calibration_complete() except Exception: # noqa: BLE001 + self._finish_calibration_capture("closing_error") errors["base"] = "unknown" return self.flow.async_show_form( step_id="calibration_close_instruction", @@ -357,12 +505,34 @@ async def async_step_calibration_complete( and self._pending_device_enum and self._pending_device_name ): + data: dict[str, Any] = { + CONF_BLIND_ID: self._pending_blind_id or generate_blind_id(), + CONF_DEVICE_ID: self._pending_device_id, + CONF_DEVICE_ENUM: self._pending_device_enum, + CONF_COMMAND_DEVICE_ID: self._pending_device_id, + CONF_COMMAND_ENUM: self._pending_device_enum, + CONF_SECONDARY_STATUS_IDENTITIES: list( + self._pending_secondary_status_identities + ), + CONF_OPEN_TIME: round(self._open_time, 2), + CONF_CLOSE_TIME: round(self._close_time, 2), + CONF_INVERT_DIRECTION: self._pending_invert_direction, + } + if ( + self._pending_status_device_id is not None + and self._pending_status_enum is not None + ): + data[CONF_STATUS_DEVICE_ID] = self._pending_status_device_id + data[CONF_STATUS_ENUM] = self._pending_status_enum + if self._pending_status_identity_source is not None: + data[CONF_STATUS_IDENTITY_SOURCE] = ( + self._pending_status_identity_source + ) + if calibration_record := self._calibration_record(): + data[CONF_LAST_CALIBRATION] = calibration_record return self.flow.async_create_entry( # type: ignore[attr-defined] title=self._pending_device_name, - data={ - "device_id": self._pending_device_id, - "device_enum": self._pending_device_enum, - }, + data=data, unique_id=self._pending_device_id, ) @@ -370,6 +540,37 @@ async def async_step_calibration_complete( if isinstance(self.flow, OptionsFlow): return self.flow.async_create_entry(title="", data={}) + if isinstance(self.flow, ConfigSubentryFlow): + data_updates: dict[str, Any] = { + CONF_OPEN_TIME: round(self._open_time, 2), + CONF_CLOSE_TIME: round(self._close_time, 2), + } + if calibration_record := self._calibration_record(): + data_updates[CONF_LAST_CALIBRATION] = calibration_record + if ( + self._pending_status_device_id is not None + and self._pending_status_enum is not None + and self._pending_status_identity_source + == STATUS_IDENTITY_SOURCE_CALIBRATION + ): + data_updates.update( + { + CONF_STATUS_DEVICE_ID: self._pending_status_device_id, + CONF_STATUS_ENUM: self._pending_status_enum, + CONF_STATUS_IDENTITY_SOURCE: ( + STATUS_IDENTITY_SOURCE_CALIBRATION + ), + CONF_SECONDARY_STATUS_IDENTITIES: list( + self._pending_secondary_status_identities + ), + } + ) + return self.flow.async_update_and_abort( + self.flow._get_entry(), + self.flow._get_reconfigure_subentry(), + data_updates=data_updates, + ) + # Fallback: abort with success if no creation path triggered return self.flow.async_abort(reason="reconfigure_successful") @@ -380,6 +581,7 @@ async def async_step_calibration_complete( "device_name": self._selected_device["name"], "open_time": f"{self._open_time:.2f}", "close_time": f"{self._close_time:.2f}", + **self._calibration_summary_placeholders(), }, last_step=True, ) @@ -493,7 +695,7 @@ async def _save_calibration_data(self, open_time: float, close_time: float) -> N async_dispatcher_send( self.flow.hass, SIGNAL_CALIBRATION_COMPLETED, - self._selected_device["id"], + self._selected_device.get("entity_id", self._selected_device["id"]), round(open_time, 2), round(close_time, 2), ) @@ -501,19 +703,40 @@ async def _save_calibration_data(self, open_time: float, close_time: float) -> N def enable_subentry_creation( self, *, + blind_id: str | None = None, device_id: str, device_enum: str, device_name: str, + status_device_id: str | None = None, + status_enum: str | None = None, + secondary_status_identities: list[dict[str, str]] | None = None, + status_identity_source: str | None = None, + invert_direction: bool = False, ) -> None: """Enable creating a subentry after calibration completes.""" self._create_subentry_after_calibration = True + self._pending_blind_id = normalize_blind_id(blind_id) or generate_blind_id() self._pending_device_id = device_id self._pending_device_enum = device_enum self._pending_device_name = device_name + self._pending_status_device_id = status_device_id + self._pending_status_enum = status_enum + self._pending_status_identity_source = status_identity_source + self._pending_secondary_status_identities = list( + secondary_status_identities or [] + ) + self._pending_invert_direction = invert_direction def disable_subentry_creation(self) -> None: """Disable subentry creation (used for reconfigure flows).""" self._create_subentry_after_calibration = False + self._pending_blind_id = None self._pending_device_id = None self._pending_device_enum = None self._pending_device_name = None + self._pending_status_device_id = None + self._pending_status_enum = None + self._pending_secondary_status_identities = [] + self._pending_status_identity_source = None + self._calibration_discovery_result = None + self._pending_invert_direction = False diff --git a/custom_components/schellenberg_usb/services.yaml b/custom_components/schellenberg_usb/services.yaml index c9b9b1b..d33d89b 100644 --- a/custom_components/schellenberg_usb/services.yaml +++ b/custom_components/schellenberg_usb/services.yaml @@ -3,3 +3,37 @@ pair: name: Pair device description: Activate pairing mode on the Schellenberg USB stick to pair a new blind motor. After calling this service, press the pairing button on your blind motor within 10 seconds. + +test_command: + name: Test blind command + description: Send a raw open, close, or stop command to a device enum without creating a cover. + fields: + device_id: + name: Device ID + description: Six-character device ID used for logging and diagnosis. + required: true + example: "F2B8D5" + selector: + text: + enum: + name: Enum + description: Two-character enum inserted into the outgoing serial payload. + required: true + example: "23" + selector: + text: + command: + name: Command + required: true + selector: + select: + options: + - open + - close + - stop + config_entry_id: + name: Config entry ID + description: Required only when more than one Schellenberg USB hub is loaded. + required: false + selector: + text: diff --git a/custom_components/schellenberg_usb/strings.json b/custom_components/schellenberg_usb/strings.json index 1e9f943..464d2fe 100644 --- a/custom_components/schellenberg_usb/strings.json +++ b/custom_components/schellenberg_usb/strings.json @@ -38,22 +38,157 @@ "blind": { "entry_type": "Blind", "initiate_flow": { - "user": "Pair device", - "reconfigure": "Calibrate" + "user": "Add blind", + "reconfigure": "Configure" }, "step": { "user": { - "title": "Pair a new device", - "description": "Press the pair button to activate pairing mode on your Schellenberg USB stick. Then press the pairing button on your blind motor within 2 minutes.", + "title": "Add a blind", + "menu_options": { + "pair_test": "Pair and test (recommended)", + "pair_device": "Pair and calibrate (legacy)", + "manual": "Add manually" + } + }, + "pair_device": { + "title": "Pair and calibrate", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter.", + "submit": "Pair" + }, + "pair_test": { + "title": "Pair and test", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter. A short Open/Stop test follows; confirm whether the motor actually moved.", "submit": "Pair" }, + "manual": { + "title": "Configure blind identities", + "description": "The command identity is used for outgoing control. The optional status identity is matched against received movement messages; leave it blank to use the command identity. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Name", + "device_id": "Command device ID", + "device_enum": "Command enum", + "status_device_id": "Primary status device ID (optional)", + "status_enum": "Primary status enum (optional)", + "open_time_seconds": "Open time", + "close_time_seconds": "Close time", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Additional status identities (optional)" + }, + "submit": "Continue" + }, + "manual_next": { + "title": "Manual blind ready", + "menu_options": { + "test_motor": "Test motor command", + "save_manual": "Save without testing" + } + }, "name_device": { "title": "Name your device", - "description": "Pairing successful! Device ID: {device_id}\n\nPlease provide a friendly name for this blind.", + "description": "A pairing candidate was detected and the USB stick teach command was transmitted for device ID {device_id}. The stick ACK cannot prove that the motor authorized it. Name the blind, then verify actual movement.", "data": { "device_name": "Device name" } }, + "test_motor": { + "title": "Test motor command", + "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", + "submit": "Test" + }, + "did_motor_move": { + "title": "Did the motor move?", + "description": "Tested command identity {device_id}/{device_enum}.", + "data": { + "motor_moved": "The motor moved" + }, + "submit": "Continue" + }, + "test_success": { + "title": "Command test successful", + "menu_options": { + "calibration_close": "Continue to calibration", + "manual_times": "Save with manual travel times" + } + }, + "manual_times": { + "title": "Enter travel times", + "description": "Enter the measured full opening and closing times.", + "data": { + "open_time_seconds": "Open time", + "close_time_seconds": "Close time" + }, + "submit": "Save" + }, + "reconfigure": { + "title": "Configure blind", + "menu_options": { + "edit": "Edit identities and travel times", + "test_existing": "Test motor command", + "developer_tools": "Developer tools", + "calibrate": "Calibrate" + } + }, + "developer_tools": { + "title": "Developer tools", + "description": "Selected blind: {selected_blind}\n\nLast matched frame (any identity):\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nIdentity role: {last_identity_role}\nInterpretation: {last_interpretation}\nPosition tracking: {last_position_tracking}\nTime: {last_time}\n\nLast primary tracking frame:\nDevice ID: {primary_last_device_id}\nEnum: {primary_last_enum}\nCommand: {primary_last_command}\nInterpretation: {primary_last_interpretation}\nTime: {primary_last_time}\n\nLast secondary frame:\nDevice ID: {secondary_last_device_id}\nEnum: {secondary_last_enum}\nCommand: {secondary_last_command}\nInterpretation: {secondary_last_interpretation}\nTime: {secondary_last_time}\n\nLast position update:\nSource: {position_source}\nDirection: {position_direction}\nPrevious position: {position_previous}\nNew position: {position_new}\nStatus: {position_status}\nTime: {position_time}\n\nCurrent position diagnostics:\nCurrent estimated position: {current_position}\nLast manual sync time: {last_manual_sync_time}\nConfidence: {position_confidence}\nConfirmed since restart: {position_confirmed_since_restart}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\nConfigured status identities:\nPrimary: {primary_status_device_id}/{primary_status_enum}\nSource: {status_identity_source}\nSecondary:\n{secondary_status_identities}\n\nLast calibration run:\nCompleted: {last_calibration_time}\nEnd reason: {calibration_end_reason}\nCandidate identities:\n{calibration_candidates}\nFrames observed:\n{calibration_frames}\n\nStick state:\nConnected: {stick_connected}\nMode: {stick_mode}\nReady: {stick_ready}\nBusy latched: {stick_busy}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.\n\nACK note: t1/t0 confirms only that the USB stick transmitter turned on/off. It does not confirm motor reception or movement.", + "menu_options": { + "test_open": "Test Open", + "test_close": "Test Close", + "test_stop": "Test Stop", + "discover_status": "Discover status from original remote", + "set_position_open": "Set position fully open", + "set_position_closed": "Set position fully closed", + "set_position_manual": "Set position manually", + "reset_stick": "Reset stick / reconnect serial", + "copy_diagnostics": "Copy diagnostics", + "teach_motor": "Teach motor / activate USB transmitter", + "send_raw_command": "Send raw RF payload" + } + }, + "discover_status": { + "title": "Discover status from original remote", + "description": "Selected blind: {selected_blind}\nCommand/transmit identity: {command_identity}\n\nAfter pressing Start listening, use the original Schellenberg remote for this shutter channel: press OPEN, STOP, CLOSE, STOP. Home Assistant listens for 45 seconds and groups every received device ID, enum, command, and timestamp.", + "submit": "Start listening" + }, + "confirm_status_discovery": { + "title": "Confirm discovered status identities", + "description": "Command/transmit identity: {command_identity}\nFrames observed: {frame_count}\n\nDetected primary status identity: {primary_identity}\nPrimary commands observed: {primary_commands}\nPrimary timestamps: {primary_timestamps}\n\nDetected secondary identities:\n{secondary_identities}\n\nUnknown commands observed:\n{unknown_commands}\n\nPosition tracking: {position_tracking}", + "submit": "Save detected identities" + }, "set_position_manual": { + "title": "Set position manually", + "description": "Set the current position for {selected_blind} without moving the motor. Current tracked position: {current_position}.", + "data": { + "position": "Position" + }, + "submit": "Confirm position" + }, + "reset_stick": { + "title": "Reset stick / reconnect serial" + }, + "copy_diagnostics": { + "title": "Copy diagnostics", + "description": "Select and copy the diagnostic text below. Press Submit to return.", + "data": { + "diagnostics": "Diagnostics" + }, + "submit": "Return" + }, + "edit": { + "title": "Edit blind settings", + "description": "Changing protocol identities does not change the Home Assistant entity unique ID. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Name", + "device_id": "Command device ID", + "device_enum": "Command enum", + "status_device_id": "Primary status device ID (optional)", + "status_enum": "Primary status enum (optional)", + "open_time_seconds": "Open time", + "close_time_seconds": "Close time", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Additional status identities (optional)" + }, + "submit": "Save" + }, "calibration_close": { "title": "Step 1: Close the blind", "description": "First, ensure {device_name} is fully closed (all the way down). Press 'Next' when ready.", @@ -61,31 +196,57 @@ }, "calibration_open_instruction": { "title": "Step 2: Open the blind", - "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving up message from the blinds.", + "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring.", "submit": "Start" }, "calibration_close_instruction": { "title": "Step 3: Close the blind", - "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving down message from the blinds.", + "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring.", "submit": "Start" }, "calibration_complete": { "title": "Calibration complete", - "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds\n\nThese times will be used to accurately track the blind position.", + "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds\n\nStatus discovery from calibration:\n- Primary status identity: {primary_status_identity}\n- Primary commands used: {primary_frames}\n- Secondary identities: {secondary_status_identities}\n- Frames observed: {observed_frame_count}\n- End reason: {calibration_end_reason}\n- Position tracking: {position_tracking}\n\nIf no receive/status identity was discovered, the blind can still be controlled and its position estimated from Home Assistant commands.", "submit": "Done" + }, + "teach_motor": { + "title": "Teach motor / activate USB transmitter", + "description": "Selected blind: {selected_blind}\nCommand identity: {command_device_id}/{command_enum}\n\nBefore submitting: select this motor/channel on an already paired physical remote, press the remote programming button until its channel LED blinks, then press Stop and wait for the motor to beep or rattle. Submit only then. Home Assistant sends teach command 60, waits for the stick ACK, sends finish command 40 on the same enum, and then performs a short Open/Stop test. Stick ACKs do not prove that the motor learned or moved. Do not repeat command 60 outside the motor programming sequence; it can also change rotation direction.", + "submit": "Send teach command and test" + }, + "send_raw_command": { + "title": "Send raw RF payload", + "description": "Selected blind: {selected_blind}\nConfigured enum: {command_enum}\n\nEnter exactly 11 characters: ss + two-digit enum + repeat digit + two-digit command + 0000. Example: ss109010000. Commands are 00 Stop, 01 Up/Open, 02 Down/Close, 40 Finish/Allow Pairing, and 60 Teach/Change Direction. t1/t0 only acknowledges the stick transmitter cycle.", + "data": { + "payload": "Raw RF payload" + }, + "submit": "Send raw payload" } }, "error": { - "pairing_timeout": "No device responded within 2 minutes. Please try again and ensure you press the pairing button on your blind motor.", + "required": "This field is required.", + "invalid_device_id": "Device ID must contain exactly 6 hexadecimal characters.", + "invalid_device_enum": "Enum must contain exactly 2 hexadecimal characters.", + "invalid_travel_time": "Travel time must be greater than zero.", + "already_configured": "This command device ID is already configured.", + "command_failed": "The serial command could not be queued. Check the USB connection and logs.", + "pairing_timeout": "No device responded within 2 minutes. Please try again.", "pairing_failed": "Pairing failed.", - "calibration_timeout": "Calibration timed out. The blind took more than 5 minutes to complete the movement. Please check the device and try again.", - "calibration_start_timeout": "Timeout waiting for the blind to start moving. Please ensure the device is properly connected and try again.", - "unknown": "An unexpected error occurred during calibration. Please try again." + "calibration_timeout": "Calibration timed out. Please check the device and try again.", + "calibration_start_timeout": "Timeout waiting for the blind to start moving.", + "unknown": "An unexpected error occurred.", + "invalid_raw_payload": "Enter exactly 11 characters: 'ss' followed by 9 hexadecimal characters.", + "transmit_failed": "The raw payload could not be transmitted. Check the stick state and logs.", + "invalid_status_identities": "Enter each additional status identity as DEVICE_ID/ENUM, separated by commas or new lines (for example F2B8D5/23).", + "status_identity_incomplete": "Enter both primary status fields or leave both blank.", + "status_discovery_unavailable": "The USB stick is not connected. Reconnect it and try again.", + "status_discovery_busy": "Another frame capture is already active. Wait for it to finish and try again." }, "abort": { "pairing_timeout": "No device responded within 2 minutes. Please try again.", "pairing_failed": "Pairing failed.", - "reconfigure_successful": "Calibration completed successfully" + "command_test_successful": "The motor command test succeeded.", + "reconfigure_successful": "Blind configuration updated successfully" } } }, @@ -127,6 +288,10 @@ "pair": { "name": "Pair device", "description": "Activate pairing mode on the Schellenberg USB stick to pair a new blind motor. After calling this service, press the pairing button on your blind motor within 10 seconds." + }, + "test_command": { + "name": "Test blind command", + "description": "Send open, close, or stop to a device enum without creating a cover." } } } diff --git a/custom_components/schellenberg_usb/translations/de.json b/custom_components/schellenberg_usb/translations/de.json index 8215d8c..36e0451 100644 --- a/custom_components/schellenberg_usb/translations/de.json +++ b/custom_components/schellenberg_usb/translations/de.json @@ -38,18 +38,42 @@ "blind": { "entry_type": "Rollladen", "initiate_flow": { - "user": "Gerät koppeln", + "user": "Rollladen hinzufügen", "reconfigure": "Kalibrieren" }, "step": { "user": { + "title": "Rollladen hinzufügen", + "menu_options": { + "pair_device": "Koppeln und kalibrieren", + "manual": "Manuell hinzufügen", + "pair_test": "Pair and test (recommended)" + } + }, + "pair_device": { "title": "Neues Gerät koppeln", - "description": "Drücken Sie die Kopplungstaste, um den Kopplungsmodus an Ihrem Schellenberg USB-Stick zu aktivieren. Drücken Sie dann innerhalb von 2 Minuten die Kopplungstaste an Ihrem Rollladenmotor.", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter.", "submit": "Koppeln" }, + "manual": { + "title": "Rollladen manuell hinzufügen", + "description": "Geben Sie die Geräte-ID und den Enum-Wert aus den Protokollen sowie die gemessenen vollständigen Laufzeiten ein. Die Kalibrierung kann später ausgeführt werden. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Name", + "device_id": "Geräte-ID", + "device_enum": "Enum", + "open_time_seconds": "Öffnungszeit", + "close_time_seconds": "Schließzeit", + "status_device_id": "Primäre Status-Geräte-ID (optional)", + "status_enum": "Primärer Status-Enum (optional)", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Zusätzliche Statusidentitäten (optional)" + }, + "submit": "Hinzufügen" + }, "name_device": { "title": "Gerät benennen", - "description": "Kopplung erfolgreich! Geräte-ID: {device_id}\n\nBitte geben Sie einen Namen für diesen Rollladen ein.", + "description": "A pairing candidate was detected and the USB stick teach command was transmitted for device ID {device_id}. The stick ACK cannot prove that the motor authorized it. Name the blind, then verify actual movement.", "data": { "device_name": "Gerätename" } @@ -71,21 +95,158 @@ }, "calibration_complete": { "title": "Kalibrierung abgeschlossen", - "description": "Kalibrierung für {device_name} abgeschlossen!\n\nGemessene Zeiten:\n- Öffnungszeit: {open_time} Sekunden\n- Schließzeit: {close_time} Sekunden\n\nDiese Zeiten werden verwendet, um die Rollladenposition genau zu verfolgen.", + "description": "Kalibrierung für {device_name} abgeschlossen!\n\nGemessene Zeiten:\n- Öffnungszeit: {open_time} Sekunden\n- Schließzeit: {close_time} Sekunden\n\nDiese Zeiten werden verwendet, um die Rollladenposition genau zu verfolgen.\n\nStatus discovery from calibration:\n- Primary status identity: {primary_status_identity}\n- Primary commands used: {primary_frames}\n- Secondary identities: {secondary_status_identities}\n- Frames observed: {observed_frame_count}\n- End reason: {calibration_end_reason}\n- Position tracking: {position_tracking}\n\nIf no receive/status identity was discovered, the blind can still be controlled and its position estimated from Home Assistant commands.", "submit": "Fertig" + }, + "pair_test": { + "title": "Pair and test", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter. A short Open/Stop test follows; confirm whether the motor actually moved.", + "submit": "Pair" + }, + "manual_next": { + "title": "Manual blind ready", + "menu_options": { + "test_motor": "Test motor command", + "save_manual": "Save without testing" + } + }, + "test_motor": { + "title": "Test motor command", + "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", + "submit": "Test" + }, + "did_motor_move": { + "title": "Did the motor move?", + "description": "Tested command identity {device_id}/{device_enum}.", + "data": { + "motor_moved": "The motor moved" + }, + "submit": "Continue" + }, + "test_success": { + "title": "Command test successful", + "menu_options": { + "calibration_close": "Continue to calibration", + "manual_times": "Save with manual travel times" + } + }, + "manual_times": { + "title": "Enter travel times", + "description": "Enter the measured full opening and closing times.", + "data": { + "open_time_seconds": "Open time", + "close_time_seconds": "Close time" + }, + "submit": "Save" + }, + "reconfigure": { + "title": "Configure blind", + "menu_options": { + "edit": "Edit identities and travel times", + "test_existing": "Test motor command", + "developer_tools": "Developer tools", + "calibrate": "Calibrate" + } + }, + "developer_tools": { + "title": "Developer tools", + "description": "Selected blind: {selected_blind}\n\nLast matched frame (any identity):\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nIdentity role: {last_identity_role}\nInterpretation: {last_interpretation}\nPosition tracking: {last_position_tracking}\nTime: {last_time}\n\nLast primary tracking frame:\nDevice ID: {primary_last_device_id}\nEnum: {primary_last_enum}\nCommand: {primary_last_command}\nInterpretation: {primary_last_interpretation}\nTime: {primary_last_time}\n\nLast secondary frame:\nDevice ID: {secondary_last_device_id}\nEnum: {secondary_last_enum}\nCommand: {secondary_last_command}\nInterpretation: {secondary_last_interpretation}\nTime: {secondary_last_time}\n\nLast position update:\nSource: {position_source}\nDirection: {position_direction}\nPrevious position: {position_previous}\nNew position: {position_new}\nStatus: {position_status}\nTime: {position_time}\n\nCurrent position diagnostics:\nCurrent estimated position: {current_position}\nLast manual sync time: {last_manual_sync_time}\nConfidence: {position_confidence}\nConfirmed since restart: {position_confirmed_since_restart}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\nConfigured status identities:\nPrimary: {primary_status_device_id}/{primary_status_enum}\nSource: {status_identity_source}\nSecondary:\n{secondary_status_identities}\n\nLast calibration run:\nCompleted: {last_calibration_time}\nEnd reason: {calibration_end_reason}\nCandidate identities:\n{calibration_candidates}\nFrames observed:\n{calibration_frames}\n\nStick state:\nConnected: {stick_connected}\nMode: {stick_mode}\nReady: {stick_ready}\nBusy latched: {stick_busy}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.\n\nACK note: t1/t0 confirms only that the USB stick transmitter turned on/off. It does not confirm motor reception or movement.", + "menu_options": { + "test_open": "Test Open", + "test_close": "Test Close", + "test_stop": "Test Stop", + "discover_status": "Status von Originalfernbedienung erkennen", + "set_position_open": "Position vollständig geöffnet setzen", + "set_position_closed": "Position vollständig geschlossen setzen", + "set_position_manual": "Position manuell setzen", + "reset_stick": "Reset stick / reconnect serial", + "copy_diagnostics": "Copy diagnostics", + "teach_motor": "Motor anlernen / USB-Sender aktivieren", + "send_raw_command": "Rohes Funktelegramm senden" + } + }, + "discover_status": { + "title": "Status von Originalfernbedienung erkennen", + "description": "Selected blind: {selected_blind}\nCommand/transmit identity: {command_identity}\n\nAfter pressing Start listening, use the original Schellenberg remote for this shutter channel: press OPEN, STOP, CLOSE, STOP. Home Assistant listens for 45 seconds and groups every received device ID, enum, command, and timestamp.", + "submit": "Start listening" + }, + "confirm_status_discovery": { + "title": "Erkannte Statusidentitäten bestätigen", + "description": "Command/transmit identity: {command_identity}\nFrames observed: {frame_count}\n\nDetected primary status identity: {primary_identity}\nPrimary commands observed: {primary_commands}\nPrimary timestamps: {primary_timestamps}\n\nDetected secondary identities:\n{secondary_identities}\n\nUnknown commands observed:\n{unknown_commands}\n\nPosition tracking: {position_tracking}", + "submit": "Save detected identities" + }, "set_position_manual": { + "title": "Position manuell setzen", + "description": "Die aktuelle Position für {selected_blind} festlegen, ohne den Motor zu bewegen. Aktuell erfasste Position: {current_position}.", + "data": { + "position": "Position" + }, + "submit": "Position bestätigen" + }, + "reset_stick": { + "title": "Reset stick / reconnect serial" + }, + "copy_diagnostics": { + "title": "Copy diagnostics", + "description": "Select and copy the diagnostic text below. Press Submit to return.", + "data": { + "diagnostics": "Diagnostics" + }, + "submit": "Return" + }, + "edit": { + "title": "Edit blind settings", + "description": "Changing protocol identities does not change the Home Assistant entity unique ID. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Name", + "device_id": "Command device ID", + "device_enum": "Command enum", + "status_device_id": "Primäre Status-Geräte-ID (optional)", + "status_enum": "Primärer Status-Enum (optional)", + "open_time_seconds": "Open time", + "close_time_seconds": "Close time", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Zusätzliche Statusidentitäten (optional)" + }, + "submit": "Save" + }, + "teach_motor": { + "title": "Motor anlernen / USB-Sender aktivieren", + "description": "Selected blind: {selected_blind}\nCommand identity: {command_device_id}/{command_enum}\n\nBefore submitting: select this motor/channel on an already paired physical remote, press the remote programming button until its channel LED blinks, then press Stop and wait for the motor to beep or rattle. Submit only then. Home Assistant sends teach command 60, waits for the stick ACK, sends finish command 40 on the same enum, and then performs a short Open/Stop test. Stick ACKs do not prove that the motor learned or moved. Do not repeat command 60 outside the motor programming sequence; it can also change rotation direction.", + "submit": "Send teach command and test" + }, + "send_raw_command": { + "title": "Rohes Funktelegramm senden", + "description": "Selected blind: {selected_blind}\nConfigured enum: {command_enum}\n\nEnter exactly 11 characters: ss + two-digit enum + repeat digit + two-digit command + 0000. Example: ss109010000. Commands are 00 Stop, 01 Up/Open, 02 Down/Close, 40 Finish/Allow Pairing, and 60 Teach/Change Direction. t1/t0 only acknowledges the stick transmitter cycle.", + "data": { + "payload": "Rohes Funktelegramm" + }, + "submit": "Send raw payload" } }, "error": { + "required": "Dieses Feld ist erforderlich.", + "invalid_device_id": "Die Geräte-ID muss genau 6 Hexadezimalzeichen enthalten.", + "invalid_device_enum": "Der Enum-Wert muss genau 2 Hexadezimalzeichen enthalten.", + "invalid_travel_time": "Die Laufzeit muss größer als null sein.", + "already_configured": "Dieses Gerät ist bereits konfiguriert.", "pairing_timeout": "Kein Gerät hat innerhalb von 2 Minuten geantwortet. Bitte versuchen Sie es erneut und stellen Sie sicher, dass Sie die Kopplungstaste an Ihrem Rollladenmotor drücken.", "pairing_failed": "Kopplung fehlgeschlagen.", "calibration_timeout": "Kalibrierung abgelaufen. Der Rollladen hat mehr als 5 Minuten für die Bewegung benötigt. Bitte überprüfen Sie das Gerät und versuchen Sie es erneut.", "calibration_start_timeout": "Zeitüberschreitung beim Warten auf die Bewegung des Rollladens. Bitte stellen Sie sicher, dass das Gerät ordnungsgemäß angeschlossen ist, und versuchen Sie es erneut.", - "unknown": "Ein unerwarteter Fehler ist während der Kalibrierung aufgetreten. Bitte versuchen Sie es erneut." + "unknown": "Ein unerwarteter Fehler ist während der Kalibrierung aufgetreten. Bitte versuchen Sie es erneut.", + "command_failed": "The serial command could not be queued. Check the USB connection and logs.", + "invalid_raw_payload": "Geben Sie genau 11 Zeichen ein: 'ss' gefolgt von 9 Hexadezimalzeichen.", + "transmit_failed": "Das rohe Funktelegramm konnte nicht gesendet werden. Prüfen Sie den Stick-Status und die Protokolle.", + "invalid_status_identities": "Geben Sie jede zusätzliche Statusidentität als GERÄTE_ID/ENUM ein, getrennt durch Kommas oder neue Zeilen (zum Beispiel F2B8D5/23).", + "status_identity_incomplete": "Enter both primary status fields or leave both blank.", + "status_discovery_unavailable": "The USB stick is not connected. Reconnect it and try again.", + "status_discovery_busy": "Another frame capture is already active. Wait for it to finish and try again." }, "abort": { "pairing_timeout": "Kein Gerät hat innerhalb von 2 Minuten geantwortet. Bitte versuchen Sie es erneut.", "pairing_failed": "Kopplung fehlgeschlagen.", - "reconfigure_successful": "Kalibrierung erfolgreich abgeschlossen" + "reconfigure_successful": "Kalibrierung erfolgreich abgeschlossen", + "command_test_successful": "The motor command test succeeded." } } }, @@ -127,6 +288,10 @@ "pair": { "name": "Gerät koppeln", "description": "Aktivieren Sie den Kopplungsmodus am Schellenberg USB-Stick, um einen neuen Rollladenmotor zu koppeln. Drücken Sie nach dem Aufrufen dieses Dienstes innerhalb von 10 Sekunden die Kopplungstaste an Ihrem Rollladenmotor." + }, + "test_command": { + "name": "Test blind command", + "description": "Send open, close, or stop to a device enum without creating a cover." } } } diff --git a/custom_components/schellenberg_usb/translations/en.json b/custom_components/schellenberg_usb/translations/en.json index 1e9f943..464d2fe 100644 --- a/custom_components/schellenberg_usb/translations/en.json +++ b/custom_components/schellenberg_usb/translations/en.json @@ -38,22 +38,157 @@ "blind": { "entry_type": "Blind", "initiate_flow": { - "user": "Pair device", - "reconfigure": "Calibrate" + "user": "Add blind", + "reconfigure": "Configure" }, "step": { "user": { - "title": "Pair a new device", - "description": "Press the pair button to activate pairing mode on your Schellenberg USB stick. Then press the pairing button on your blind motor within 2 minutes.", + "title": "Add a blind", + "menu_options": { + "pair_test": "Pair and test (recommended)", + "pair_device": "Pair and calibrate (legacy)", + "manual": "Add manually" + } + }, + "pair_device": { + "title": "Pair and calibrate", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter.", + "submit": "Pair" + }, + "pair_test": { + "title": "Pair and test", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter. A short Open/Stop test follows; confirm whether the motor actually moved.", "submit": "Pair" }, + "manual": { + "title": "Configure blind identities", + "description": "The command identity is used for outgoing control. The optional status identity is matched against received movement messages; leave it blank to use the command identity. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Name", + "device_id": "Command device ID", + "device_enum": "Command enum", + "status_device_id": "Primary status device ID (optional)", + "status_enum": "Primary status enum (optional)", + "open_time_seconds": "Open time", + "close_time_seconds": "Close time", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Additional status identities (optional)" + }, + "submit": "Continue" + }, + "manual_next": { + "title": "Manual blind ready", + "menu_options": { + "test_motor": "Test motor command", + "save_manual": "Save without testing" + } + }, "name_device": { "title": "Name your device", - "description": "Pairing successful! Device ID: {device_id}\n\nPlease provide a friendly name for this blind.", + "description": "A pairing candidate was detected and the USB stick teach command was transmitted for device ID {device_id}. The stick ACK cannot prove that the motor authorized it. Name the blind, then verify actual movement.", "data": { "device_name": "Device name" } }, + "test_motor": { + "title": "Test motor command", + "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", + "submit": "Test" + }, + "did_motor_move": { + "title": "Did the motor move?", + "description": "Tested command identity {device_id}/{device_enum}.", + "data": { + "motor_moved": "The motor moved" + }, + "submit": "Continue" + }, + "test_success": { + "title": "Command test successful", + "menu_options": { + "calibration_close": "Continue to calibration", + "manual_times": "Save with manual travel times" + } + }, + "manual_times": { + "title": "Enter travel times", + "description": "Enter the measured full opening and closing times.", + "data": { + "open_time_seconds": "Open time", + "close_time_seconds": "Close time" + }, + "submit": "Save" + }, + "reconfigure": { + "title": "Configure blind", + "menu_options": { + "edit": "Edit identities and travel times", + "test_existing": "Test motor command", + "developer_tools": "Developer tools", + "calibrate": "Calibrate" + } + }, + "developer_tools": { + "title": "Developer tools", + "description": "Selected blind: {selected_blind}\n\nLast matched frame (any identity):\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nIdentity role: {last_identity_role}\nInterpretation: {last_interpretation}\nPosition tracking: {last_position_tracking}\nTime: {last_time}\n\nLast primary tracking frame:\nDevice ID: {primary_last_device_id}\nEnum: {primary_last_enum}\nCommand: {primary_last_command}\nInterpretation: {primary_last_interpretation}\nTime: {primary_last_time}\n\nLast secondary frame:\nDevice ID: {secondary_last_device_id}\nEnum: {secondary_last_enum}\nCommand: {secondary_last_command}\nInterpretation: {secondary_last_interpretation}\nTime: {secondary_last_time}\n\nLast position update:\nSource: {position_source}\nDirection: {position_direction}\nPrevious position: {position_previous}\nNew position: {position_new}\nStatus: {position_status}\nTime: {position_time}\n\nCurrent position diagnostics:\nCurrent estimated position: {current_position}\nLast manual sync time: {last_manual_sync_time}\nConfidence: {position_confidence}\nConfirmed since restart: {position_confirmed_since_restart}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\nConfigured status identities:\nPrimary: {primary_status_device_id}/{primary_status_enum}\nSource: {status_identity_source}\nSecondary:\n{secondary_status_identities}\n\nLast calibration run:\nCompleted: {last_calibration_time}\nEnd reason: {calibration_end_reason}\nCandidate identities:\n{calibration_candidates}\nFrames observed:\n{calibration_frames}\n\nStick state:\nConnected: {stick_connected}\nMode: {stick_mode}\nReady: {stick_ready}\nBusy latched: {stick_busy}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.\n\nACK note: t1/t0 confirms only that the USB stick transmitter turned on/off. It does not confirm motor reception or movement.", + "menu_options": { + "test_open": "Test Open", + "test_close": "Test Close", + "test_stop": "Test Stop", + "discover_status": "Discover status from original remote", + "set_position_open": "Set position fully open", + "set_position_closed": "Set position fully closed", + "set_position_manual": "Set position manually", + "reset_stick": "Reset stick / reconnect serial", + "copy_diagnostics": "Copy diagnostics", + "teach_motor": "Teach motor / activate USB transmitter", + "send_raw_command": "Send raw RF payload" + } + }, + "discover_status": { + "title": "Discover status from original remote", + "description": "Selected blind: {selected_blind}\nCommand/transmit identity: {command_identity}\n\nAfter pressing Start listening, use the original Schellenberg remote for this shutter channel: press OPEN, STOP, CLOSE, STOP. Home Assistant listens for 45 seconds and groups every received device ID, enum, command, and timestamp.", + "submit": "Start listening" + }, + "confirm_status_discovery": { + "title": "Confirm discovered status identities", + "description": "Command/transmit identity: {command_identity}\nFrames observed: {frame_count}\n\nDetected primary status identity: {primary_identity}\nPrimary commands observed: {primary_commands}\nPrimary timestamps: {primary_timestamps}\n\nDetected secondary identities:\n{secondary_identities}\n\nUnknown commands observed:\n{unknown_commands}\n\nPosition tracking: {position_tracking}", + "submit": "Save detected identities" + }, "set_position_manual": { + "title": "Set position manually", + "description": "Set the current position for {selected_blind} without moving the motor. Current tracked position: {current_position}.", + "data": { + "position": "Position" + }, + "submit": "Confirm position" + }, + "reset_stick": { + "title": "Reset stick / reconnect serial" + }, + "copy_diagnostics": { + "title": "Copy diagnostics", + "description": "Select and copy the diagnostic text below. Press Submit to return.", + "data": { + "diagnostics": "Diagnostics" + }, + "submit": "Return" + }, + "edit": { + "title": "Edit blind settings", + "description": "Changing protocol identities does not change the Home Assistant entity unique ID. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Name", + "device_id": "Command device ID", + "device_enum": "Command enum", + "status_device_id": "Primary status device ID (optional)", + "status_enum": "Primary status enum (optional)", + "open_time_seconds": "Open time", + "close_time_seconds": "Close time", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Additional status identities (optional)" + }, + "submit": "Save" + }, "calibration_close": { "title": "Step 1: Close the blind", "description": "First, ensure {device_name} is fully closed (all the way down). Press 'Next' when ready.", @@ -61,31 +196,57 @@ }, "calibration_open_instruction": { "title": "Step 2: Open the blind", - "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving up message from the blinds.", + "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring.", "submit": "Start" }, "calibration_close_instruction": { "title": "Step 3: Close the blind", - "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving down message from the blinds.", + "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring.", "submit": "Start" }, "calibration_complete": { "title": "Calibration complete", - "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds\n\nThese times will be used to accurately track the blind position.", + "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds\n\nStatus discovery from calibration:\n- Primary status identity: {primary_status_identity}\n- Primary commands used: {primary_frames}\n- Secondary identities: {secondary_status_identities}\n- Frames observed: {observed_frame_count}\n- End reason: {calibration_end_reason}\n- Position tracking: {position_tracking}\n\nIf no receive/status identity was discovered, the blind can still be controlled and its position estimated from Home Assistant commands.", "submit": "Done" + }, + "teach_motor": { + "title": "Teach motor / activate USB transmitter", + "description": "Selected blind: {selected_blind}\nCommand identity: {command_device_id}/{command_enum}\n\nBefore submitting: select this motor/channel on an already paired physical remote, press the remote programming button until its channel LED blinks, then press Stop and wait for the motor to beep or rattle. Submit only then. Home Assistant sends teach command 60, waits for the stick ACK, sends finish command 40 on the same enum, and then performs a short Open/Stop test. Stick ACKs do not prove that the motor learned or moved. Do not repeat command 60 outside the motor programming sequence; it can also change rotation direction.", + "submit": "Send teach command and test" + }, + "send_raw_command": { + "title": "Send raw RF payload", + "description": "Selected blind: {selected_blind}\nConfigured enum: {command_enum}\n\nEnter exactly 11 characters: ss + two-digit enum + repeat digit + two-digit command + 0000. Example: ss109010000. Commands are 00 Stop, 01 Up/Open, 02 Down/Close, 40 Finish/Allow Pairing, and 60 Teach/Change Direction. t1/t0 only acknowledges the stick transmitter cycle.", + "data": { + "payload": "Raw RF payload" + }, + "submit": "Send raw payload" } }, "error": { - "pairing_timeout": "No device responded within 2 minutes. Please try again and ensure you press the pairing button on your blind motor.", + "required": "This field is required.", + "invalid_device_id": "Device ID must contain exactly 6 hexadecimal characters.", + "invalid_device_enum": "Enum must contain exactly 2 hexadecimal characters.", + "invalid_travel_time": "Travel time must be greater than zero.", + "already_configured": "This command device ID is already configured.", + "command_failed": "The serial command could not be queued. Check the USB connection and logs.", + "pairing_timeout": "No device responded within 2 minutes. Please try again.", "pairing_failed": "Pairing failed.", - "calibration_timeout": "Calibration timed out. The blind took more than 5 minutes to complete the movement. Please check the device and try again.", - "calibration_start_timeout": "Timeout waiting for the blind to start moving. Please ensure the device is properly connected and try again.", - "unknown": "An unexpected error occurred during calibration. Please try again." + "calibration_timeout": "Calibration timed out. Please check the device and try again.", + "calibration_start_timeout": "Timeout waiting for the blind to start moving.", + "unknown": "An unexpected error occurred.", + "invalid_raw_payload": "Enter exactly 11 characters: 'ss' followed by 9 hexadecimal characters.", + "transmit_failed": "The raw payload could not be transmitted. Check the stick state and logs.", + "invalid_status_identities": "Enter each additional status identity as DEVICE_ID/ENUM, separated by commas or new lines (for example F2B8D5/23).", + "status_identity_incomplete": "Enter both primary status fields or leave both blank.", + "status_discovery_unavailable": "The USB stick is not connected. Reconnect it and try again.", + "status_discovery_busy": "Another frame capture is already active. Wait for it to finish and try again." }, "abort": { "pairing_timeout": "No device responded within 2 minutes. Please try again.", "pairing_failed": "Pairing failed.", - "reconfigure_successful": "Calibration completed successfully" + "command_test_successful": "The motor command test succeeded.", + "reconfigure_successful": "Blind configuration updated successfully" } } }, @@ -127,6 +288,10 @@ "pair": { "name": "Pair device", "description": "Activate pairing mode on the Schellenberg USB stick to pair a new blind motor. After calling this service, press the pairing button on your blind motor within 10 seconds." + }, + "test_command": { + "name": "Test blind command", + "description": "Send open, close, or stop to a device enum without creating a cover." } } } diff --git a/custom_components/schellenberg_usb/translations/es.json b/custom_components/schellenberg_usb/translations/es.json index 15dc230..a089bf6 100644 --- a/custom_components/schellenberg_usb/translations/es.json +++ b/custom_components/schellenberg_usb/translations/es.json @@ -38,18 +38,42 @@ "blind": { "entry_type": "Persiana", "initiate_flow": { - "user": "Emparejar dispositivo", + "user": "Añadir persiana", "reconfigure": "Calibrar" }, "step": { "user": { + "title": "Añadir una persiana", + "menu_options": { + "pair_device": "Emparejar y calibrar", + "manual": "Añadir manualmente", + "pair_test": "Pair and test (recommended)" + } + }, + "pair_device": { "title": "Emparejar un nuevo dispositivo", - "description": "Presione el botón de emparejamiento para activar el modo de emparejamiento en su memoria USB Schellenberg. Luego presione el botón de emparejamiento en su motor de persiana en los próximos 2 minutos.", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter.", "submit": "Emparejar" }, + "manual": { + "title": "Añadir una persiana manualmente", + "description": "Introduzca el ID del dispositivo y el enum que aparecen en los registros, además de los tiempos de recorrido completo medidos. La calibración se puede ejecutar más adelante. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Nombre", + "device_id": "ID del dispositivo", + "device_enum": "Enum", + "open_time_seconds": "Tiempo de apertura", + "close_time_seconds": "Tiempo de cierre", + "status_device_id": "ID de estado principal (opcional)", + "status_enum": "Enum de estado principal (opcional)", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Identidades de estado adicionales (opcional)" + }, + "submit": "Añadir" + }, "name_device": { "title": "Nombre su dispositivo", - "description": "¡Emparejamiento exitoso! ID del dispositivo: {device_id}\n\nPor favor, proporcione un nombre para esta persiana.", + "description": "A pairing candidate was detected and the USB stick teach command was transmitted for device ID {device_id}. The stick ACK cannot prove that the motor authorized it. Name the blind, then verify actual movement.", "data": { "device_name": "Nombre del dispositivo" } @@ -71,21 +95,158 @@ }, "calibration_complete": { "title": "Calibración completada", - "description": "¡Calibración de {device_name} completada!\n\nTiempos medidos:\n- Tiempo de apertura: {open_time} segundos\n- Tiempo de cierre: {close_time} segundos\n\nEstos tiempos se utilizarán para rastrear con precisión la posición de la persiana.", + "description": "¡Calibración de {device_name} completada!\n\nTiempos medidos:\n- Tiempo de apertura: {open_time} segundos\n- Tiempo de cierre: {close_time} segundos\n\nEstos tiempos se utilizarán para rastrear con precisión la posición de la persiana.\n\nStatus discovery from calibration:\n- Primary status identity: {primary_status_identity}\n- Primary commands used: {primary_frames}\n- Secondary identities: {secondary_status_identities}\n- Frames observed: {observed_frame_count}\n- End reason: {calibration_end_reason}\n- Position tracking: {position_tracking}\n\nIf no receive/status identity was discovered, the blind can still be controlled and its position estimated from Home Assistant commands.", "submit": "Hecho" + }, + "pair_test": { + "title": "Pair and test", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter. A short Open/Stop test follows; confirm whether the motor actually moved.", + "submit": "Pair" + }, + "manual_next": { + "title": "Manual blind ready", + "menu_options": { + "test_motor": "Test motor command", + "save_manual": "Save without testing" + } + }, + "test_motor": { + "title": "Test motor command", + "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", + "submit": "Test" + }, + "did_motor_move": { + "title": "Did the motor move?", + "description": "Tested command identity {device_id}/{device_enum}.", + "data": { + "motor_moved": "The motor moved" + }, + "submit": "Continue" + }, + "test_success": { + "title": "Command test successful", + "menu_options": { + "calibration_close": "Continue to calibration", + "manual_times": "Save with manual travel times" + } + }, + "manual_times": { + "title": "Enter travel times", + "description": "Enter the measured full opening and closing times.", + "data": { + "open_time_seconds": "Open time", + "close_time_seconds": "Close time" + }, + "submit": "Save" + }, + "reconfigure": { + "title": "Configure blind", + "menu_options": { + "edit": "Edit identities and travel times", + "test_existing": "Test motor command", + "developer_tools": "Developer tools", + "calibrate": "Calibrate" + } + }, + "developer_tools": { + "title": "Developer tools", + "description": "Selected blind: {selected_blind}\n\nLast matched frame (any identity):\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nIdentity role: {last_identity_role}\nInterpretation: {last_interpretation}\nPosition tracking: {last_position_tracking}\nTime: {last_time}\n\nLast primary tracking frame:\nDevice ID: {primary_last_device_id}\nEnum: {primary_last_enum}\nCommand: {primary_last_command}\nInterpretation: {primary_last_interpretation}\nTime: {primary_last_time}\n\nLast secondary frame:\nDevice ID: {secondary_last_device_id}\nEnum: {secondary_last_enum}\nCommand: {secondary_last_command}\nInterpretation: {secondary_last_interpretation}\nTime: {secondary_last_time}\n\nLast position update:\nSource: {position_source}\nDirection: {position_direction}\nPrevious position: {position_previous}\nNew position: {position_new}\nStatus: {position_status}\nTime: {position_time}\n\nCurrent position diagnostics:\nCurrent estimated position: {current_position}\nLast manual sync time: {last_manual_sync_time}\nConfidence: {position_confidence}\nConfirmed since restart: {position_confirmed_since_restart}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\nConfigured status identities:\nPrimary: {primary_status_device_id}/{primary_status_enum}\nSource: {status_identity_source}\nSecondary:\n{secondary_status_identities}\n\nLast calibration run:\nCompleted: {last_calibration_time}\nEnd reason: {calibration_end_reason}\nCandidate identities:\n{calibration_candidates}\nFrames observed:\n{calibration_frames}\n\nStick state:\nConnected: {stick_connected}\nMode: {stick_mode}\nReady: {stick_ready}\nBusy latched: {stick_busy}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.\n\nACK note: t1/t0 confirms only that the USB stick transmitter turned on/off. It does not confirm motor reception or movement.", + "menu_options": { + "test_open": "Test Open", + "test_close": "Test Close", + "test_stop": "Test Stop", + "discover_status": "Detectar estado desde el mando original", + "set_position_open": "Establecer posición totalmente abierta", + "set_position_closed": "Establecer posición totalmente cerrada", + "set_position_manual": "Establecer posición manualmente", + "reset_stick": "Reset stick / reconnect serial", + "copy_diagnostics": "Copy diagnostics", + "teach_motor": "Programar motor / activar transmisor USB", + "send_raw_command": "Enviar trama RF sin procesar" + } + }, + "discover_status": { + "title": "Detectar estado desde el mando original", + "description": "Selected blind: {selected_blind}\nCommand/transmit identity: {command_identity}\n\nAfter pressing Start listening, use the original Schellenberg remote for this shutter channel: press OPEN, STOP, CLOSE, STOP. Home Assistant listens for 45 seconds and groups every received device ID, enum, command, and timestamp.", + "submit": "Start listening" + }, + "confirm_status_discovery": { + "title": "Confirmar identidades de estado detectadas", + "description": "Command/transmit identity: {command_identity}\nFrames observed: {frame_count}\n\nDetected primary status identity: {primary_identity}\nPrimary commands observed: {primary_commands}\nPrimary timestamps: {primary_timestamps}\n\nDetected secondary identities:\n{secondary_identities}\n\nUnknown commands observed:\n{unknown_commands}\n\nPosition tracking: {position_tracking}", + "submit": "Save detected identities" + }, "set_position_manual": { + "title": "Establecer posición manualmente", + "description": "Establezca la posición actual de {selected_blind} sin mover el motor. Posición registrada actualmente: {current_position}.", + "data": { + "position": "Posición" + }, + "submit": "Confirmar posición" + }, + "reset_stick": { + "title": "Reset stick / reconnect serial" + }, + "copy_diagnostics": { + "title": "Copy diagnostics", + "description": "Select and copy the diagnostic text below. Press Submit to return.", + "data": { + "diagnostics": "Diagnostics" + }, + "submit": "Return" + }, + "edit": { + "title": "Edit blind settings", + "description": "Changing protocol identities does not change the Home Assistant entity unique ID. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Name", + "device_id": "Command device ID", + "device_enum": "Command enum", + "status_device_id": "ID de estado principal (opcional)", + "status_enum": "Enum de estado principal (opcional)", + "open_time_seconds": "Open time", + "close_time_seconds": "Close time", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Identidades de estado adicionales (opcional)" + }, + "submit": "Save" + }, + "teach_motor": { + "title": "Programar motor / activar transmisor USB", + "description": "Selected blind: {selected_blind}\nCommand identity: {command_device_id}/{command_enum}\n\nBefore submitting: select this motor/channel on an already paired physical remote, press the remote programming button until its channel LED blinks, then press Stop and wait for the motor to beep or rattle. Submit only then. Home Assistant sends teach command 60, waits for the stick ACK, sends finish command 40 on the same enum, and then performs a short Open/Stop test. Stick ACKs do not prove that the motor learned or moved. Do not repeat command 60 outside the motor programming sequence; it can also change rotation direction.", + "submit": "Send teach command and test" + }, + "send_raw_command": { + "title": "Enviar trama RF sin procesar", + "description": "Selected blind: {selected_blind}\nConfigured enum: {command_enum}\n\nEnter exactly 11 characters: ss + two-digit enum + repeat digit + two-digit command + 0000. Example: ss109010000. Commands are 00 Stop, 01 Up/Open, 02 Down/Close, 40 Finish/Allow Pairing, and 60 Teach/Change Direction. t1/t0 only acknowledges the stick transmitter cycle.", + "data": { + "payload": "Trama RF sin procesar" + }, + "submit": "Send raw payload" } }, "error": { + "required": "Este campo es obligatorio.", + "invalid_device_id": "El ID del dispositivo debe contener exactamente 6 caracteres hexadecimales.", + "invalid_device_enum": "El enum debe contener exactamente 2 caracteres hexadecimales.", + "invalid_travel_time": "El tiempo de recorrido debe ser mayor que cero.", + "already_configured": "Este dispositivo ya está configurado.", "pairing_timeout": "Ningún dispositivo respondió en 2 minutos. Por favor, inténtelo de nuevo y asegúrese de presionar el botón de emparejamiento en su motor de persiana.", "pairing_failed": "Error en el emparejamiento.", "calibration_timeout": "Tiempo de calibración agotado. La persiana tardó más de 5 minutos en completar el movimiento. Por favor, verifique el dispositivo e inténtelo de nuevo.", "calibration_start_timeout": "Tiempo de espera agotado para que la persiana comience a moverse. Por favor, asegúrese de que el dispositivo esté correctamente conectado e inténtelo de nuevo.", - "unknown": "Se produjo un error inesperado durante la calibración. Por favor, inténtelo de nuevo." + "unknown": "Se produjo un error inesperado durante la calibración. Por favor, inténtelo de nuevo.", + "command_failed": "The serial command could not be queued. Check the USB connection and logs.", + "invalid_raw_payload": "Introduzca exactamente 11 caracteres: 'ss' seguido de 9 caracteres hexadecimales.", + "transmit_failed": "No se pudo transmitir la trama sin procesar. Compruebe el estado del dispositivo USB y los registros.", + "invalid_status_identities": "Introduzca cada identidad de estado adicional como ID_DISPOSITIVO/ENUM, separada por comas o líneas nuevas (por ejemplo F2B8D5/23).", + "status_identity_incomplete": "Enter both primary status fields or leave both blank.", + "status_discovery_unavailable": "The USB stick is not connected. Reconnect it and try again.", + "status_discovery_busy": "Another frame capture is already active. Wait for it to finish and try again." }, "abort": { "pairing_timeout": "Ningún dispositivo respondió en 2 minutos. Por favor, inténtelo de nuevo.", "pairing_failed": "Error en el emparejamiento.", - "reconfigure_successful": "Calibración completada con éxito" + "reconfigure_successful": "Calibración completada con éxito", + "command_test_successful": "The motor command test succeeded." } } }, @@ -127,6 +288,10 @@ "pair": { "name": "Emparejar dispositivo", "description": "Active el modo de emparejamiento en la memoria USB Schellenberg para emparejar un nuevo motor de persiana. Después de llamar a este servicio, presione el botón de emparejamiento en su motor de persiana en los próximos 10 segundos." + }, + "test_command": { + "name": "Test blind command", + "description": "Send open, close, or stop to a device enum without creating a cover." } } } diff --git a/custom_components/schellenberg_usb/translations/fr.json b/custom_components/schellenberg_usb/translations/fr.json index b724368..c4f02ae 100644 --- a/custom_components/schellenberg_usb/translations/fr.json +++ b/custom_components/schellenberg_usb/translations/fr.json @@ -38,18 +38,42 @@ "blind": { "entry_type": "Volet", "initiate_flow": { - "user": "Appairer l'appareil", + "user": "Ajouter un volet", "reconfigure": "Calibrer" }, "step": { "user": { + "title": "Ajouter un volet", + "menu_options": { + "pair_device": "Appairer et calibrer", + "manual": "Ajouter manuellement", + "pair_test": "Pair and test (recommended)" + } + }, + "pair_device": { "title": "Appairer un nouvel appareil", - "description": "Appuyez sur le bouton d'appairage pour activer le mode d'appairage sur votre clé USB Schellenberg. Ensuite, appuyez sur le bouton d'appairage de votre moteur de volet dans les 2 minutes.", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter.", "submit": "Appairer" }, + "manual": { + "title": "Ajouter un volet manuellement", + "description": "Saisissez l'ID de l'appareil et l'énumération affichés dans les journaux, ainsi que les durées de course complètes mesurées. Le calibrage pourra être effectué ultérieurement. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Nom", + "device_id": "ID de l'appareil", + "device_enum": "Énumération", + "open_time_seconds": "Durée d'ouverture", + "close_time_seconds": "Durée de fermeture", + "status_device_id": "ID d’état principale (facultatif)", + "status_enum": "Énumération d’état principale (facultatif)", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Identités d’état supplémentaires (facultatif)" + }, + "submit": "Ajouter" + }, "name_device": { "title": "Nommer votre appareil", - "description": "Appairage réussi ! ID de l'appareil : {device_id}\n\nVeuillez donner un nom à ce volet.", + "description": "A pairing candidate was detected and the USB stick teach command was transmitted for device ID {device_id}. The stick ACK cannot prove that the motor authorized it. Name the blind, then verify actual movement.", "data": { "device_name": "Nom de l'appareil" } @@ -71,21 +95,158 @@ }, "calibration_complete": { "title": "Calibration terminée", - "description": "Calibration de {device_name} terminée !\n\nTemps mesurés :\n- Temps d'ouverture : {open_time} secondes\n- Temps de fermeture : {close_time} secondes\n\nCes temps seront utilisés pour suivre précisément la position du volet.", + "description": "Calibration de {device_name} terminée !\n\nTemps mesurés :\n- Temps d'ouverture : {open_time} secondes\n- Temps de fermeture : {close_time} secondes\n\nCes temps seront utilisés pour suivre précisément la position du volet.\n\nStatus discovery from calibration:\n- Primary status identity: {primary_status_identity}\n- Primary commands used: {primary_frames}\n- Secondary identities: {secondary_status_identities}\n- Frames observed: {observed_frame_count}\n- End reason: {calibration_end_reason}\n- Position tracking: {position_tracking}\n\nIf no receive/status identity was discovered, the blind can still be controlled and its position estimated from Home Assistant commands.", "submit": "Terminé" + }, + "pair_test": { + "title": "Pair and test", + "description": "After pressing Pair, use an already paired physical remote within 2 minutes: select the motor/channel, press its programming button until the channel LED blinks, then press Stop and wait for the motor to beep or rattle. Home Assistant will detect the frame and send teach command 60 followed by finish command 40 on the same enum. A USB stick ACK does not prove the motor learned the transmitter. A short Open/Stop test follows; confirm whether the motor actually moved.", + "submit": "Pair" + }, + "manual_next": { + "title": "Manual blind ready", + "menu_options": { + "test_motor": "Test motor command", + "save_manual": "Save without testing" + } + }, + "test_motor": { + "title": "Test motor command", + "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", + "submit": "Test" + }, + "did_motor_move": { + "title": "Did the motor move?", + "description": "Tested command identity {device_id}/{device_enum}.", + "data": { + "motor_moved": "The motor moved" + }, + "submit": "Continue" + }, + "test_success": { + "title": "Command test successful", + "menu_options": { + "calibration_close": "Continue to calibration", + "manual_times": "Save with manual travel times" + } + }, + "manual_times": { + "title": "Enter travel times", + "description": "Enter the measured full opening and closing times.", + "data": { + "open_time_seconds": "Open time", + "close_time_seconds": "Close time" + }, + "submit": "Save" + }, + "reconfigure": { + "title": "Configure blind", + "menu_options": { + "edit": "Edit identities and travel times", + "test_existing": "Test motor command", + "developer_tools": "Developer tools", + "calibrate": "Calibrate" + } + }, + "developer_tools": { + "title": "Developer tools", + "description": "Selected blind: {selected_blind}\n\nLast matched frame (any identity):\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nIdentity role: {last_identity_role}\nInterpretation: {last_interpretation}\nPosition tracking: {last_position_tracking}\nTime: {last_time}\n\nLast primary tracking frame:\nDevice ID: {primary_last_device_id}\nEnum: {primary_last_enum}\nCommand: {primary_last_command}\nInterpretation: {primary_last_interpretation}\nTime: {primary_last_time}\n\nLast secondary frame:\nDevice ID: {secondary_last_device_id}\nEnum: {secondary_last_enum}\nCommand: {secondary_last_command}\nInterpretation: {secondary_last_interpretation}\nTime: {secondary_last_time}\n\nLast position update:\nSource: {position_source}\nDirection: {position_direction}\nPrevious position: {position_previous}\nNew position: {position_new}\nStatus: {position_status}\nTime: {position_time}\n\nCurrent position diagnostics:\nCurrent estimated position: {current_position}\nLast manual sync time: {last_manual_sync_time}\nConfidence: {position_confidence}\nConfirmed since restart: {position_confirmed_since_restart}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\nConfigured status identities:\nPrimary: {primary_status_device_id}/{primary_status_enum}\nSource: {status_identity_source}\nSecondary:\n{secondary_status_identities}\n\nLast calibration run:\nCompleted: {last_calibration_time}\nEnd reason: {calibration_end_reason}\nCandidate identities:\n{calibration_candidates}\nFrames observed:\n{calibration_frames}\n\nStick state:\nConnected: {stick_connected}\nMode: {stick_mode}\nReady: {stick_ready}\nBusy latched: {stick_busy}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.\n\nACK note: t1/t0 confirms only that the USB stick transmitter turned on/off. It does not confirm motor reception or movement.", + "menu_options": { + "test_open": "Test Open", + "test_close": "Test Close", + "test_stop": "Test Stop", + "discover_status": "Détecter le statut depuis la télécommande d'origine", + "set_position_open": "Définir la position complètement ouverte", + "set_position_closed": "Définir la position complètement fermée", + "set_position_manual": "Définir la position manuellement", + "reset_stick": "Reset stick / reconnect serial", + "copy_diagnostics": "Copy diagnostics", + "teach_motor": "Appairer le moteur / activer l’émetteur USB", + "send_raw_command": "Envoyer une trame radio brute" + } + }, + "discover_status": { + "title": "Détecter le statut depuis la télécommande d'origine", + "description": "Selected blind: {selected_blind}\nCommand/transmit identity: {command_identity}\n\nAfter pressing Start listening, use the original Schellenberg remote for this shutter channel: press OPEN, STOP, CLOSE, STOP. Home Assistant listens for 45 seconds and groups every received device ID, enum, command, and timestamp.", + "submit": "Start listening" + }, + "confirm_status_discovery": { + "title": "Confirmer les identités de statut détectées", + "description": "Command/transmit identity: {command_identity}\nFrames observed: {frame_count}\n\nDetected primary status identity: {primary_identity}\nPrimary commands observed: {primary_commands}\nPrimary timestamps: {primary_timestamps}\n\nDetected secondary identities:\n{secondary_identities}\n\nUnknown commands observed:\n{unknown_commands}\n\nPosition tracking: {position_tracking}", + "submit": "Save detected identities" + }, "set_position_manual": { + "title": "Définir la position manuellement", + "description": "Définissez la position actuelle de {selected_blind} sans déplacer le moteur. Position actuellement suivie : {current_position}.", + "data": { + "position": "Position" + }, + "submit": "Confirmer la position" + }, + "reset_stick": { + "title": "Reset stick / reconnect serial" + }, + "copy_diagnostics": { + "title": "Copy diagnostics", + "description": "Select and copy the diagnostic text below. Press Submit to return.", + "data": { + "diagnostics": "Diagnostics" + }, + "submit": "Return" + }, + "edit": { + "title": "Edit blind settings", + "description": "Changing protocol identities does not change the Home Assistant entity unique ID. The primary status identity drives position tracking. Enter additional identities as DEVICE_ID/ENUM, one per line or comma-separated. Secondary identities are matched for diagnostics but do not change position. Leave both primary status fields blank when unknown; the transmit identity will not be used as status unless it was actually observed or you enter it manually.", + "data": { + "device_name": "Name", + "device_id": "Command device ID", + "device_enum": "Command enum", + "status_device_id": "ID d’état principale (facultatif)", + "status_enum": "Énumération d’état principale (facultatif)", + "open_time_seconds": "Open time", + "close_time_seconds": "Close time", + "invert_direction": "Invert open/close direction", + "secondary_status_identities": "Identités d’état supplémentaires (facultatif)" + }, + "submit": "Save" + }, + "teach_motor": { + "title": "Appairer le moteur / activer l’émetteur USB", + "description": "Selected blind: {selected_blind}\nCommand identity: {command_device_id}/{command_enum}\n\nBefore submitting: select this motor/channel on an already paired physical remote, press the remote programming button until its channel LED blinks, then press Stop and wait for the motor to beep or rattle. Submit only then. Home Assistant sends teach command 60, waits for the stick ACK, sends finish command 40 on the same enum, and then performs a short Open/Stop test. Stick ACKs do not prove that the motor learned or moved. Do not repeat command 60 outside the motor programming sequence; it can also change rotation direction.", + "submit": "Send teach command and test" + }, + "send_raw_command": { + "title": "Envoyer une trame radio brute", + "description": "Selected blind: {selected_blind}\nConfigured enum: {command_enum}\n\nEnter exactly 11 characters: ss + two-digit enum + repeat digit + two-digit command + 0000. Example: ss109010000. Commands are 00 Stop, 01 Up/Open, 02 Down/Close, 40 Finish/Allow Pairing, and 60 Teach/Change Direction. t1/t0 only acknowledges the stick transmitter cycle.", + "data": { + "payload": "Trame radio brute" + }, + "submit": "Send raw payload" } }, "error": { + "required": "Ce champ est obligatoire.", + "invalid_device_id": "L'ID de l'appareil doit contenir exactement 6 caractères hexadécimaux.", + "invalid_device_enum": "L'énumération doit contenir exactement 2 caractères hexadécimaux.", + "invalid_travel_time": "La durée de course doit être supérieure à zéro.", + "already_configured": "Cet appareil est déjà configuré.", "pairing_timeout": "Aucun appareil n'a répondu dans les 2 minutes. Veuillez réessayer et vous assurer d'appuyer sur le bouton d'appairage de votre moteur de volet.", "pairing_failed": "Échec de l'appairage.", "calibration_timeout": "Délai de calibration dépassé. Le volet a mis plus de 5 minutes pour terminer le mouvement. Veuillez vérifier l'appareil et réessayer.", "calibration_start_timeout": "Délai d'attente dépassé pour le début du mouvement du volet. Veuillez vous assurer que l'appareil est correctement connecté et réessayer.", - "unknown": "Une erreur inattendue s'est produite pendant la calibration. Veuillez réessayer." + "unknown": "Une erreur inattendue s'est produite pendant la calibration. Veuillez réessayer.", + "command_failed": "The serial command could not be queued. Check the USB connection and logs.", + "invalid_raw_payload": "Saisissez exactement 11 caractères : 'ss' suivi de 9 caractères hexadécimaux.", + "transmit_failed": "La trame brute n’a pas pu être transmise. Vérifiez l’état de la clé et les journaux.", + "invalid_status_identities": "Saisissez chaque identité d’état supplémentaire sous la forme ID_APPAREIL/ENUM, séparée par une virgule ou une nouvelle ligne (par exemple F2B8D5/23).", + "status_identity_incomplete": "Enter both primary status fields or leave both blank.", + "status_discovery_unavailable": "The USB stick is not connected. Reconnect it and try again.", + "status_discovery_busy": "Another frame capture is already active. Wait for it to finish and try again." }, "abort": { "pairing_timeout": "Aucun appareil n'a répondu dans les 2 minutes. Veuillez réessayer.", "pairing_failed": "Échec de l'appairage.", - "reconfigure_successful": "Calibration terminée avec succès" + "reconfigure_successful": "Calibration terminée avec succès", + "command_test_successful": "The motor command test succeeded." } } }, @@ -127,6 +288,10 @@ "pair": { "name": "Appairer l'appareil", "description": "Activez le mode d'appairage sur la clé USB Schellenberg pour appairer un nouveau moteur de volet. Après avoir appelé ce service, appuyez sur le bouton d'appairage de votre moteur de volet dans les 10 secondes." + }, + "test_command": { + "name": "Test blind command", + "description": "Send open, close, or stop to a device enum without creating a cover." } } } diff --git a/manual-device-entry.patch b/manual-device-entry.patch new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 3447bbb..25799c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" readme = "README.md" requires-python = ">=3.13.2" dependencies = [ - "pyserial-asyncio==0.6", + "pyserial-asyncio-fast==0.16", ] [dependency-groups] diff --git a/schellenberg-usb-command-diagnostics.patch b/schellenberg-usb-command-diagnostics.patch new file mode 100644 index 0000000..abd377f --- /dev/null +++ b/schellenberg-usb-command-diagnostics.patch @@ -0,0 +1,2851 @@ +diff --git a/README.md b/README.md +index 3f1d42e..6cc6c4f 100644 +--- a/README.md ++++ b/README.md +@@ -15,7 +15,9 @@ Home Assistant component that interfaces with the [Schellenberg Usb Funk-Stick]( + ## Features + + * Supports blind movement Up, Down, and Stop +-* After calibation, position tracking is possible. ++* Tracks estimated position from measured or manually supplied travel times ++* Supports manual command/status identities when calibration cannot create a blind ++* Can test a paired command before calibration and edit a blind after creation + + ## Installation + +@@ -28,9 +30,20 @@ Home Assistant component that interfaces with the [Schellenberg Usb Funk-Stick]( + Make sure you have HACS installed. If you don't, run `wget -O - https://get.hacs.xyz | bash -` in HA. + Choose Integrations under HACS. Click the '+' button on the bottom of the page, search for "schellenberg usb", choose it, and click install in HACS. + ++ + #### Option 2: Manual + Clone this repository or download the source code as a zip file and add/merge the `custom_components/` folder with its contents in your configuration directory. + ++To test a local checkout on Home Assistant OS, use the Samba Share or Studio Code ++Server add-on and copy the repository's `custom_components/schellenberg_usb` ++directory to `/config/custom_components/schellenberg_usb`. The resulting directory ++must contain `manifest.json` directly. Replace the integration files, restart Home ++Assistant (a configuration reload is not sufficient), then check **Settings > System ++> Logs** for `schellenberg_usb` messages. ++ ++Back up an existing copy first. HACS may overwrite these test files during an update, ++so restore or reinstall the released version after testing if necessary. ++ + + ### Step 2: Restart HA + In order for the newly added integration to be loaded, HA needs to be restarted. +@@ -49,11 +62,44 @@ Select it, and the schellenberg usb integration is ready for use. + 2. Find the **Schellenberg USB** integration and click on it + 3. Click the **+** button or select **Add blind** from the menu + 4. Choose one of the setup methods: +- - **Pair and calibrate** keeps the existing guided setup. Put the blind motor +- into pairing mode, provide a friendly name, and complete calibration. +- - **Add manually** creates the cover immediately from its name, six-character +- device ID, two-character enum, and measured open/close travel times. The ID +- and enum can be copied from the integration's received-message log entries. ++ - **Pair and test (recommended)** pairs the motor, sends Open for 0.75 seconds ++ followed by Stop, and asks whether it moved. A successful test can continue to ++ calibration or save with manual travel times. A failed test opens the detected ++ values for editing. ++ - **Pair and calibrate (legacy)** keeps the original guided setup unchanged. ++ - **Add manually** creates a cover from a name, command ID/enum, optional separate ++ status ID/enum, and measured open/close travel times. Calibration is optional. ++ ++ ++The command enum selects the paired slot in the USB stick and is inserted into the ++outgoing serial packet. The command device ID is retained for identification and ++diagnostic logging. The status ID and enum are matched exactly against incoming ++movement messages. ++ ++To edit an existing blind, open its configuration action from the Schellenberg USB ++integration and choose **Edit identities and travel times**. The same menu also ++offers a short motor command test and recalibration. Editing protocol values keeps ++the existing Home Assistant entity unique ID. ++Choose **Developer tools** in the same blind configuration menu to see the last ++frame matching its status identity, the current transmit target, direct Open, ++Close, and Stop actions, and a copyable diagnostics snapshot. ++ ++ ++### Diagnostic command service ++ ++Before creating or changing a blind, use **Developer Tools > Actions** and run ++`schellenberg_usb.test_command`: ++ ++```yaml ++device_id: F2B8D5 ++enum: "23" ++command: open ++``` ++ ++Valid commands are `open`, `close`, and `stop`. Open and close are direct commands, ++so send `stop` yourself when performing a short test. If more than one Schellenberg ++USB hub is loaded, also supply its `config_entry_id`. Sending logs the command, ++device ID, enum, raw serial payload, and whether it was queued successfully. + + ### Step 5: Calibrate your blinds + +diff --git a/custom_components/schellenberg_usb/__init__.py b/custom_components/schellenberg_usb/__init__.py +index e223568..0549a63 100644 +--- a/custom_components/schellenberg_usb/__init__.py ++++ b/custom_components/schellenberg_usb/__init__.py +@@ -7,15 +7,24 @@ from types import MappingProxyType + + import voluptuous as vol + from homeassistant.config_entries import ConfigSubentry +-from homeassistant.core import HomeAssistant ++from homeassistant.core import HomeAssistant, ServiceCall ++from homeassistant.exceptions import ServiceValidationError + from homeassistant.helpers import config_validation as cv + from homeassistant.helpers import device_registry as dr + + from .api import SchellenbergUsbApi + from .const import ( ++ CMD_DOWN, ++ CMD_STOP, ++ CMD_UP, ++ CONF_COMMAND, ++ CONF_CONFIG_ENTRY_ID, ++ CONF_DEVICE_ID, ++ CONF_ENUM, + CONF_SERIAL_PORT, + DOMAIN, + PLATFORMS, ++ SERVICE_TEST_COMMAND, + SUBENTRY_TYPE_HUB, + SchellenbergConfigEntry, + ) +@@ -31,6 +40,89 @@ CONFIG_SCHEMA = vol.Schema( + _SETUP_CALLBACKS: dict[str, dict] = {} + + ++def _validate_device_id(value: str) -> str: ++ """Validate and normalize a six-character protocol device ID.""" ++ normalized = cv.string(value).strip().upper() ++ if len(normalized) != 6 or any( ++ character not in "0123456789ABCDEF" for character in normalized ++ ): ++ raise vol.Invalid("device ID must be six hexadecimal characters") ++ return normalized ++ ++ ++def _validate_device_enum(value: str) -> str: ++ """Validate and normalize a two-character protocol enum.""" ++ normalized = cv.string(value).strip().upper() ++ if len(normalized) != 2 or any( ++ character not in "0123456789ABCDEF" for character in normalized ++ ): ++ raise vol.Invalid("enum must be two hexadecimal characters") ++ return normalized ++ ++ ++TEST_COMMAND_SCHEMA = vol.Schema( ++ { ++ vol.Required(CONF_DEVICE_ID): _validate_device_id, ++ vol.Required(CONF_ENUM): _validate_device_enum, ++ vol.Required(CONF_COMMAND): vol.In({"open", "close", "stop"}), ++ vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, ++ } ++) ++ ++ ++async def async_setup(hass: HomeAssistant, config: dict) -> bool: ++ """Set up integration-level diagnostic services.""" ++ ++ async def _handle_test_command(call: ServiceCall) -> None: ++ requested_entry_id = call.data.get(CONF_CONFIG_ENTRY_ID) ++ loaded_entries: list[tuple[SchellenbergConfigEntry, SchellenbergUsbApi]] = [] ++ for candidate in hass.config_entries.async_entries(DOMAIN): ++ api = getattr(candidate, "runtime_data", None) ++ if isinstance(api, SchellenbergUsbApi): ++ loaded_entries.append((candidate, api)) ++ ++ if requested_entry_id: ++ api = next( ++ ( ++ candidate_api ++ for candidate, candidate_api in loaded_entries ++ if candidate.entry_id == requested_entry_id ++ ), ++ None, ++ ) ++ if api is None: ++ raise ServiceValidationError( ++ f"No loaded Schellenberg USB entry {requested_entry_id}" ++ ) ++ elif len(loaded_entries) == 1: ++ api = loaded_entries[0][1] ++ else: ++ raise ServiceValidationError( ++ "Exactly one Schellenberg USB hub must be loaded, or config_entry_id " ++ "must be supplied" ++ ) ++ ++ action = { ++ "open": CMD_UP, ++ "close": CMD_DOWN, ++ "stop": CMD_STOP, ++ }[call.data[CONF_COMMAND]] ++ if not await api.control_blind( ++ call.data[CONF_ENUM], ++ action, ++ device_id=call.data[CONF_DEVICE_ID], ++ ): ++ raise ServiceValidationError("The serial command could not be queued") ++ ++ hass.services.async_register( ++ DOMAIN, ++ SERVICE_TEST_COMMAND, ++ _handle_test_command, ++ schema=TEST_COMMAND_SCHEMA, ++ ) ++ return True ++ ++ + async def async_setup_entry( + hass: HomeAssistant, entry: SchellenbergConfigEntry + ) -> bool: +@@ -107,14 +199,17 @@ async def async_setup_entry( + # Forward setup to the hub's platforms (cover, sensor, switch) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + +- # Add listener to reload entry when subentries are added ++ # Reload when subentries are added, removed, renamed, or edited. + async def _on_entry_updated( + hass_instance: HomeAssistant, updated_entry: SchellenbergConfigEntry + ) -> None: +- """Handle updates to the hub entry (including subentry additions).""" +- current_subentries = set(updated_entry.subentries.keys()) ++ """Handle changes to the hub's blind subentries.""" ++ current_subentries = { ++ subentry_id: (subentry.title, dict(subentry.data)) ++ for subentry_id, subentry in updated_entry.subentries.items() ++ } + known_subentries = _SETUP_CALLBACKS.get(entry.entry_id, {}).get( +- "subentry_ids", set() ++ "subentries", {} + ) + + _LOGGER.debug( +@@ -125,13 +220,9 @@ async def async_setup_entry( + + if current_subentries != known_subentries: + _LOGGER.info( +- "Subentries changed, reloading entry. Old: %s, New: %s", +- known_subentries, +- current_subentries, ++ "Subentry configuration changed; reloading entry %s", entry.entry_id + ) +- # Update tracked subentries before reloading +- _SETUP_CALLBACKS[entry.entry_id]["subentry_ids"] = current_subentries +- # Reload the entire entry to re-setup all platforms with new subentries ++ _SETUP_CALLBACKS[entry.entry_id]["subentries"] = current_subentries + await hass_instance.config_entries.async_reload(entry.entry_id) + + entry.add_update_listener(_on_entry_updated) +@@ -139,7 +230,10 @@ async def async_setup_entry( + # Track known subentries + if entry.entry_id not in _SETUP_CALLBACKS: + _SETUP_CALLBACKS[entry.entry_id] = {} +- _SETUP_CALLBACKS[entry.entry_id]["subentry_ids"] = set(entry.subentries.keys()) ++ _SETUP_CALLBACKS[entry.entry_id]["subentries"] = { ++ subentry_id: (subentry.title, dict(subentry.data)) ++ for subentry_id, subentry in entry.subentries.items() ++ } + + return True + +diff --git a/custom_components/schellenberg_usb/api.py b/custom_components/schellenberg_usb/api.py +index 7aceb85..f760b18 100644 +--- a/custom_components/schellenberg_usb/api.py ++++ b/custom_components/schellenberg_usb/api.py +@@ -5,10 +5,12 @@ from __future__ import annotations + import asyncio + import logging + from collections.abc import Callable ++from typing import Any + + import serial_asyncio + from homeassistant.core import HomeAssistant, callback + from homeassistant.helpers.dispatcher import async_dispatcher_send ++from homeassistant.util import dt as dt_util + + from .const import ( + CMD_ALLOW_PAIRING, +@@ -40,6 +42,10 @@ from .const import ( + CMD_TRANSMIT, + CMD_UP, + CMD_VERIFY, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + PAIRING_DEVICE_ENUM_START, + PAIRING_TIMEOUT, + SIGNAL_DEVICE_EVENT, +@@ -62,6 +68,9 @@ class SchellenbergUsbApi: + self._registered_devices: dict[ + str, str + ] = {} # Dict[device_id, device_enum] for registered entities ++ self._registered_entity_keys: dict[tuple[str, str], str] = {} ++ # Exact (status_device_id, status_enum) -> entity name for event matching. ++ self._last_received_messages: dict[tuple[str, str], dict[str, str]] = {} + self._is_connecting = False + self._pairing_future: asyncio.Future[str] | None = None + self._stop_pairing_task: asyncio.Task[None] | None = ( +@@ -266,10 +275,27 @@ class SchellenbergUsbApi: + _LOGGER.debug( + "Parsed: enum=%s, id=%s, cmd=%s", device_enum, device_id, command + ) ++ normalized_device_id = device_id.upper() ++ normalized_device_enum = device_enum.upper() ++ self._last_received_messages[ ++ (normalized_device_id, normalized_device_enum) ++ ] = { ++ "device_id": normalized_device_id, ++ "enum": normalized_device_enum, ++ "command": command.upper(), ++ "time": dt_util.now().strftime("%H:%M:%S"), ++ } + + # If we're in pairing mode and this is a new device + if self._pairing_future and not self._pairing_future.done(): +- if device_id not in self._registered_devices: ++ known_status_id = any( ++ key[0] == device_id.upper() ++ for key in self._registered_entity_keys ++ ) ++ if ( ++ device_id not in self._registered_devices ++ and not known_status_id ++ ): + _LOGGER.info("Pairing successful! New device ID: %s", device_id) + self._pairing_future.set_result(device_id) + # Stop pairing mode after a 2 second delay to ensure device has fully paired +@@ -282,45 +308,60 @@ class SchellenbergUsbApi: + # Don't send dispatcher signal here - let the caller handle persistence + return + +- # If this is the first time we see this device (auto-discovery mode) +- if device_id not in self._registered_devices: ++ entity_name = self._registered_entity_keys.get( ++ (normalized_device_id, normalized_device_enum) ++ ) ++ if entity_name is None: + _LOGGER.warning( +- "Received message for device %s (enum=%s, cmd=%s) but no " +- "corresponding entity found. The device may need to be added " +- "to Home Assistant", ++ "Received device_id=%s enum=%s cmd=%s matched=False; no " ++ "cover has this status identity", + device_id, + device_enum, + command, + ) + else: +- # The entity will handle the event via the dispatcher + _LOGGER.debug( +- "Forwarding event to device %s (enum=%s): command=%s", ++ "Received device_id=%s enum=%s cmd=%s matched=True entity=%s", + device_id, + device_enum, + command, ++ entity_name, + ) + +- # Forward the event to the correct entity (if it exists) ++ # Retain the ID-only signal for calibration before an entity exists. + async_dispatcher_send( +- self.hass, f"{SIGNAL_DEVICE_EVENT}_{device_id}", command ++ self.hass, ++ f"{SIGNAL_DEVICE_EVENT}_{normalized_device_id}", ++ command, + ) ++ if entity_name is not None: ++ async_dispatcher_send( ++ self.hass, ++ f"{SIGNAL_DEVICE_EVENT}_{normalized_device_id}_" ++ f"{normalized_device_enum}", ++ command, ++ ) + except (IndexError, ValueError) as err: + _LOGGER.debug("Failed to parse message %s: %s", message, err) + +- async def send_command(self, command: str) -> None: +- """Send a command to the USB stick.""" ++ async def send_command(self, command: str) -> bool: ++ """Queue a raw command on the serial transport.""" + if self._transport is None or self._transport.is_closing(): + _LOGGER.warning("Serial port not connected. Command dropped: %s", command) +- return ++ return False + + # Store command for potential retry on "stick busy" error + self._pending_retry_command = command + + full_command = f"{command}\r\n".encode("ascii") + _LOGGER.debug("Sending to serial device: %s", full_command.strip()) +- self._transport.write(full_command) ++ try: ++ self._transport.write(full_command) ++ except (OSError, RuntimeError): ++ _LOGGER.exception("Serial transmit failed for payload=%s", command) ++ return False + _LOGGER.debug("Command sent to serial device: %s", full_command.strip()) ++ return True + + async def _retry_command_after_delay(self) -> None: + """Retry sending the pending command after a 100ms delay.""" +@@ -411,23 +452,42 @@ class SchellenbergUsbApi: + except OSError as err: + _LOGGER.debug("Error stopping pairing mode (communication error): %s", err) + +- async def control_blind(self, device_enum: str, action: str) -> None: ++ async def control_blind( ++ self, ++ device_enum: str, ++ action: str, ++ *, ++ device_id: str | None = None, ++ ) -> bool: + """Send a control command to a specific blind. + + Args: + device_enum: The device enumerator (hex string like "10") + action: Command (CMD_UP, CMD_DOWN, CMD_STOP) ++ device_id: Optional command device ID for diagnostic logging + + """ + if action not in (CMD_UP, CMD_DOWN, CMD_STOP): + _LOGGER.error("Invalid blind action: %s", action) +- return ++ return False + + # Format: ssXX9AAZZZ + # XX = device enum, 9 = number of messages, AA = command, ZZZ = padding +- command = f"{CMD_TRANSMIT}{device_enum}9{action}0000" +- _LOGGER.debug("Sending blind control: %s", command) +- await self.send_command(command) ++ raw_payload = f"{CMD_TRANSMIT}{device_enum.upper()}9{action}0000" ++ action_name = {CMD_UP: "open", CMD_DOWN: "close", CMD_STOP: "stop"}[action] ++ _LOGGER.info( ++ "Sending blind command=%s device_id=%s enum=%s payload=%s", ++ action_name, ++ device_id or "unknown", ++ device_enum, ++ raw_payload, ++ ) ++ sent = await self.send_command(raw_payload) ++ if sent: ++ _LOGGER.info("Serial transmit queued successfully: %s", raw_payload) ++ else: ++ _LOGGER.error("Serial transmit failed: %s", raw_payload) ++ return sent + + def initialize_next_device_enum(self) -> str: + """Get the next available device enum based on registered devices. +@@ -470,20 +530,46 @@ class SchellenbergUsbApi: + ) + return result + +- def register_existing_devices(self, devices: list[dict]) -> None: ++ def register_existing_devices(self, devices: list[dict[str, Any]]) -> None: + """Register existing devices from storage. + + Args: + devices: List of device dicts with 'id' and 'enum' keys + """ + for device in devices: +- device_id = device.get("id") +- device_enum = device.get("enum") +- if device_id and device_enum: +- self._registered_devices[device_id] = device_enum +- _LOGGER.debug( +- "Registered existing device %s with enum %s", device_id, device_enum +- ) ++ command_device_id = ( ++ device.get(CONF_COMMAND_DEVICE_ID) ++ or device.get("id") ++ or device.get(CONF_STATUS_DEVICE_ID) ++ ) ++ command_enum = ( ++ device.get(CONF_COMMAND_ENUM) ++ or device.get("enum") ++ or device.get(CONF_STATUS_ENUM) ++ ) ++ status_device_id = ( ++ device.get(CONF_STATUS_DEVICE_ID) ++ or device.get("id") ++ or command_device_id ++ ) ++ status_enum = ( ++ device.get(CONF_STATUS_ENUM) or device.get("enum") or command_enum ++ ) ++ entity_name = str(device.get("name") or status_device_id) ++ if command_device_id and command_enum: ++ self._registered_devices[str(command_device_id)] = str(command_enum) ++ if status_device_id and status_enum: ++ self._registered_entity_keys[ ++ (str(status_device_id).upper(), str(status_enum).upper()) ++ ] = entity_name ++ _LOGGER.debug( ++ "Registered existing entity=%s command=%s/%s status=%s/%s", ++ entity_name, ++ command_device_id, ++ command_enum, ++ status_device_id, ++ status_enum, ++ ) + + def remove_known_device(self, device_id: str) -> None: + """Remove a device from the registered entities. +@@ -491,14 +577,48 @@ class SchellenbergUsbApi: + After removal, messages from this device will be treated as unknown. + """ + self._registered_devices.pop(device_id, None) ++ normalized_id = device_id.upper() ++ self._registered_entity_keys = { ++ key: name ++ for key, name in self._registered_entity_keys.items() ++ if key[0] != normalized_id ++ } + _LOGGER.debug("Removed device %s from registered entities", device_id) + +- def register_entity(self, device_id: str, device_enum: str) -> None: +- """Register that an entity exists for this device ID with its enum.""" +- self._registered_devices[device_id] = device_enum ++ def register_entity( ++ self, ++ status_device_id: str, ++ status_enum: str, ++ entity_name: str | None = None, ++ *, ++ command_device_id: str | None = None, ++ command_enum: str | None = None, ++ ) -> None: ++ """Register command and exact incoming status identities for an entity.""" ++ command_id = command_device_id or status_device_id ++ command_slot = command_enum or status_enum ++ self._registered_devices[command_id] = command_slot ++ self._registered_entity_keys[ ++ (status_device_id.upper(), status_enum.upper()) ++ ] = entity_name or status_device_id + _LOGGER.debug( +- "Registered entity for device %s with enum %s", device_id, device_enum ++ "Registered entity=%s command=%s/%s status=%s/%s", ++ entity_name or status_device_id, ++ command_id, ++ command_slot, ++ status_device_id, ++ status_enum, ++ ) ++ ++ @callback ++ def get_last_received( ++ self, status_device_id: str, status_enum: str ++ ) -> dict[str, str] | None: ++ """Return a copy of the latest frame for an exact status identity.""" ++ message = self._last_received_messages.get( ++ (status_device_id.upper(), status_enum.upper()) + ) ++ return dict(message) if message is not None else None + + async def verify_device(self) -> bool: + """Verify this is a Schellenberg USB stick by sending !? command. +diff --git a/custom_components/schellenberg_usb/config_flow.py b/custom_components/schellenberg_usb/config_flow.py +index b9ee5ae..7d671af 100644 +--- a/custom_components/schellenberg_usb/config_flow.py ++++ b/custom_components/schellenberg_usb/config_flow.py +@@ -2,6 +2,7 @@ + + from __future__ import annotations + ++import asyncio + import logging + from typing import Any, Awaitable, cast + +@@ -18,16 +19,25 @@ from homeassistant.helpers import selector + from homeassistant.helpers.service_info.usb import UsbServiceInfo + + from .const import ( ++ CMD_DOWN, ++ CMD_STOP, ++ CMD_UP, + CONF_CLOSE_TIME, + CONF_CLOSE_TIME_SECONDS, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, + CONF_DEVICE_ENUM, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, ++ CONF_INVERT_DIRECTION, + CONF_OPEN_TIME, + CONF_OPEN_TIME_SECONDS, + CONF_SERIAL_PORT, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + DOMAIN, + SUBENTRY_TYPE_BLIND, ++ TEST_COMMAND_DELAY, + ) + from .options_flow import SchellenbergOptionsFlowHandler + from .options_flow_calibration import CalibrationFlowHandler +@@ -206,6 +216,13 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + self._pending_device_id: str | None = None + self._pending_device_enum: str | None = None + self._pending_device_name: str | None = None ++ self._pending_status_device_id: str | None = None ++ self._pending_status_enum: str | None = None ++ self._pending_open_time: float | None = None ++ self._pending_close_time: float | None = None ++ self._pending_invert_direction = False ++ self._pairing_workflow = "legacy" ++ self._developer_notice = "No test command sent in this session." + + def _get_calibration_handler(self) -> CalibrationFlowHandler: + """Return (and lazily create) the calibration flow handler.""" +@@ -239,19 +256,31 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + ) -> SubentryFlowResult: + """Choose between pairing/calibration and manual setup.""" + return self.async_show_menu( +- step_id="user", menu_options=["pair_device", "manual"] ++ step_id="user", menu_options=["pair_test", "pair_device", "manual"] + ) + + async def async_step_pair_device( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: +- """Handle pairing initialization.""" ++ """Run the original pair-then-calibrate workflow.""" ++ self._pairing_workflow = "legacy" ++ return await self._async_pair_device("pair_device", user_input) ++ ++ async def async_step_pair_test( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Pair a blind and verify outgoing control before calibration.""" ++ self._pairing_workflow = "hybrid" ++ return await self._async_pair_device("pair_test", user_input) ++ ++ async def _async_pair_device( ++ self, step_id: str, user_input: dict[str, Any] | None ++ ) -> SubentryFlowResult: ++ """Pair a device for either supported pairing workflow.""" + _LOGGER.debug("Pairing step user input: %s", user_input) + if user_input is None: + _LOGGER.info("Showing pairing form") +- return self.async_show_form( +- step_id="pair_device", data_schema=vol.Schema({}) +- ) ++ return self.async_show_form(step_id=step_id, data_schema=vol.Schema({})) + + # Get the hub entry (parent config entry) + hub_entry = self._get_entry() +@@ -268,32 +297,42 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + device_id, device_enum = pairing_result + self._pending_device_id = device_id + self._pending_device_enum = device_enum ++ self._pending_status_device_id = device_id ++ self._pending_status_enum = device_enum + self._pending_device_name = None + return await self.async_step_name_device() + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: +- """Create a blind subentry from manually supplied protocol values.""" ++ """Collect manual command/status identities and travel times.""" + errors: dict[str, str] = {} + + if user_input is not None: + device_name = str(user_input[CONF_DEVICE_NAME]).strip() +- device_id = str(user_input[CONF_DEVICE_ID]).strip().upper() +- device_enum = str(user_input[CONF_DEVICE_ENUM]).strip().upper() ++ command_device_id = str(user_input[CONF_DEVICE_ID]).strip().upper() ++ command_enum = str(user_input[CONF_DEVICE_ENUM]).strip().upper() ++ status_device_id = ( ++ str(user_input.get(CONF_STATUS_DEVICE_ID, "")).strip().upper() ++ or command_device_id ++ ) ++ status_enum = ( ++ str(user_input.get(CONF_STATUS_ENUM, "")).strip().upper() ++ or command_enum ++ ) + open_time = float(user_input[CONF_OPEN_TIME_SECONDS]) + close_time = float(user_input[CONF_CLOSE_TIME_SECONDS]) + + if not device_name: + errors[CONF_DEVICE_NAME] = "required" +- if len(device_id) != 6 or any( +- character not in "0123456789ABCDEF" for character in device_id +- ): ++ if not self._is_hex_value(command_device_id, 6): + errors[CONF_DEVICE_ID] = "invalid_device_id" +- if len(device_enum) != 2 or any( +- character not in "0123456789ABCDEF" for character in device_enum +- ): ++ if not self._is_hex_value(command_enum, 2): + errors[CONF_DEVICE_ENUM] = "invalid_device_enum" ++ if not self._is_hex_value(status_device_id, 6): ++ errors[CONF_STATUS_DEVICE_ID] = "invalid_device_id" ++ if not self._is_hex_value(status_enum, 2): ++ errors[CONF_STATUS_ENUM] = "invalid_device_enum" + if open_time <= 0: + errors[CONF_OPEN_TIME_SECONDS] = "invalid_travel_time" + if close_time <= 0: +@@ -301,30 +340,247 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + + hub_entry = self._get_entry() + if any( +- str(subentry.data.get(CONF_DEVICE_ID, "")).upper() == device_id ++ str( ++ subentry.data.get(CONF_COMMAND_DEVICE_ID) ++ or subentry.data.get(CONF_DEVICE_ID, "") ++ ).upper() ++ == command_device_id + for subentry in hub_entry.subentries.values() + ): + errors[CONF_DEVICE_ID] = "already_configured" + + if not errors: +- return self.async_create_entry( +- title=device_name, +- data={ +- CONF_DEVICE_ID: device_id, +- CONF_DEVICE_ENUM: device_enum, +- CONF_OPEN_TIME: open_time, +- CONF_CLOSE_TIME: close_time, +- }, +- unique_id=device_id, ++ self._pending_device_name = device_name ++ self._pending_device_id = command_device_id ++ self._pending_device_enum = command_enum ++ self._pending_status_device_id = status_device_id ++ self._pending_status_enum = status_enum ++ self._pending_open_time = open_time ++ self._pending_close_time = close_time ++ self._pending_invert_direction = bool( ++ user_input.get(CONF_INVERT_DIRECTION, False) + ) ++ self._pairing_workflow = "manual" ++ return await self.async_step_manual_next() + + return self.async_show_form( + step_id="manual", ++ data_schema=self._manual_schema(), ++ errors=errors, ++ ) ++ ++ @staticmethod ++ def _is_hex_value(value: str, length: int) -> bool: ++ """Return whether value is an exact-length hexadecimal string.""" ++ return len(value) == length and all( ++ character in "0123456789ABCDEF" for character in value ++ ) ++ ++ def _manual_schema(self) -> vol.Schema: ++ """Build the manual form with pending values as defaults.""" ++ open_time_key = ( ++ vol.Required( ++ CONF_OPEN_TIME_SECONDS, ++ default=self._pending_open_time, ++ ) ++ if self._pending_open_time is not None ++ else vol.Required(CONF_OPEN_TIME_SECONDS) ++ ) ++ close_time_key = ( ++ vol.Required( ++ CONF_CLOSE_TIME_SECONDS, ++ default=self._pending_close_time, ++ ) ++ if self._pending_close_time is not None ++ else vol.Required(CONF_CLOSE_TIME_SECONDS) ++ ) ++ return vol.Schema( ++ { ++ vol.Required( ++ CONF_DEVICE_NAME, ++ default=self._pending_device_name or "", ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_DEVICE_ID, ++ default=self._pending_device_id or "", ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_DEVICE_ENUM, ++ default=self._pending_device_enum or "", ++ ): selector.TextSelector(), ++ vol.Optional( ++ CONF_STATUS_DEVICE_ID, ++ default=self._pending_status_device_id or "", ++ ): selector.TextSelector(), ++ vol.Optional( ++ CONF_STATUS_ENUM, ++ default=self._pending_status_enum or "", ++ ): selector.TextSelector(), ++ open_time_key: selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ close_time_key: selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ vol.Optional( ++ CONF_INVERT_DIRECTION, ++ default=self._pending_invert_direction, ++ ): selector.BooleanSelector(), ++ } ++ ) ++ ++ def _pending_data(self) -> dict[str, Any]: ++ """Return config-subentry data for the pending blind.""" ++ assert self._pending_device_id is not None ++ assert self._pending_device_enum is not None ++ assert self._pending_status_device_id is not None ++ assert self._pending_status_enum is not None ++ assert self._pending_open_time is not None ++ assert self._pending_close_time is not None ++ return { ++ # Legacy keys stay populated for backward compatibility. ++ CONF_DEVICE_ID: self._pending_device_id, ++ CONF_DEVICE_ENUM: self._pending_device_enum, ++ CONF_COMMAND_DEVICE_ID: self._pending_device_id, ++ CONF_COMMAND_ENUM: self._pending_device_enum, ++ CONF_STATUS_DEVICE_ID: self._pending_status_device_id, ++ CONF_STATUS_ENUM: self._pending_status_enum, ++ CONF_OPEN_TIME: self._pending_open_time, ++ CONF_CLOSE_TIME: self._pending_close_time, ++ CONF_INVERT_DIRECTION: self._pending_invert_direction, ++ } ++ ++ async def async_step_manual_next( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Offer command testing before saving a manually configured blind.""" ++ return self.async_show_menu( ++ step_id="manual_next", menu_options=["test_motor", "save_manual"] ++ ) ++ ++ async def async_step_save_manual( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Save the pending blind without calibration.""" ++ if ( ++ not self._pending_device_name ++ or not self._pending_device_id ++ or self._pending_open_time is None ++ or self._pending_close_time is None ++ ): ++ return self.async_abort(reason="device_not_found") ++ return self.async_create_entry( ++ title=self._pending_device_name, ++ data=self._pending_data(), ++ unique_id=self._pending_device_id, ++ ) ++ ++ async def async_step_test_motor( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Send a short logical-open command followed by stop.""" ++ if not self._pending_device_id or not self._pending_device_enum: ++ return self.async_abort(reason="device_not_found") ++ ++ placeholders = { ++ "device_id": self._pending_device_id, ++ "device_enum": self._pending_device_enum, ++ } ++ if user_input is None: ++ return self.async_show_form( ++ step_id="test_motor", ++ data_schema=vol.Schema({}), ++ description_placeholders=placeholders, ++ ) ++ ++ api = self._get_entry().runtime_data ++ action = CMD_DOWN if self._pending_invert_direction else CMD_UP ++ if not await api.control_blind( ++ self._pending_device_enum, ++ action, ++ device_id=self._pending_device_id, ++ ): ++ return self.async_show_form( ++ step_id="test_motor", ++ data_schema=vol.Schema({}), ++ description_placeholders=placeholders, ++ errors={"base": "command_failed"}, ++ ) ++ try: ++ await asyncio.sleep(TEST_COMMAND_DELAY) ++ finally: ++ stopped = await api.control_blind( ++ self._pending_device_enum, ++ CMD_STOP, ++ device_id=self._pending_device_id, ++ ) ++ if not stopped: ++ return self.async_show_form( ++ step_id="test_motor", ++ data_schema=vol.Schema({}), ++ description_placeholders=placeholders, ++ errors={"base": "command_failed"}, ++ ) ++ return await self.async_step_did_motor_move() ++ ++ async def async_step_did_motor_move( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Ask whether the short command moved the motor.""" ++ if user_input is None: ++ return self.async_show_form( ++ step_id="did_motor_move", ++ data_schema=vol.Schema( ++ { ++ vol.Required("motor_moved", default=True): ( ++ selector.BooleanSelector() ++ ) ++ } ++ ), ++ description_placeholders={ ++ "device_id": self._pending_device_id or "unknown", ++ "device_enum": self._pending_device_enum or "unknown", ++ }, ++ ) ++ ++ if not user_input["motor_moved"]: ++ if self._pairing_workflow == "existing": ++ return await self.async_step_edit() ++ # Reuse the manual form with the detected/current values prefilled. ++ return await self.async_step_manual() ++ ++ if self._pairing_workflow == "existing": ++ return self.async_abort(reason="command_test_successful") ++ if self._pairing_workflow == "hybrid": ++ return self.async_show_menu( ++ step_id="test_success", ++ menu_options=["calibration_close", "manual_times"], ++ ) ++ return await self.async_step_save_manual() ++ ++ async def async_step_manual_times( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Collect travel times after a successful paired command test.""" ++ if user_input is not None: ++ self._pending_open_time = float(user_input[CONF_OPEN_TIME_SECONDS]) ++ self._pending_close_time = float(user_input[CONF_CLOSE_TIME_SECONDS]) ++ return await self.async_step_save_manual() ++ ++ return self.async_show_form( ++ step_id="manual_times", + data_schema=vol.Schema( + { +- vol.Required(CONF_DEVICE_NAME): selector.TextSelector(), +- vol.Required(CONF_DEVICE_ID): selector.TextSelector(), +- vol.Required(CONF_DEVICE_ENUM): selector.TextSelector(), + vol.Required(CONF_OPEN_TIME_SECONDS): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0.1, +@@ -343,7 +599,6 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + ), + } + ), +- errors=errors, + ) + + async def async_step_name_device( +@@ -370,7 +625,7 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + }, + ) + +- # User provided a name – begin calibration prior to creating subentry ++ # User provided a name; configure calibration state for either workflow. + if not device_id or not device_enum: + return self.async_abort(reason="pairing_failed") + +@@ -382,16 +637,23 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + # Provide minimal device to handler + handler.set_selected_device( + { +- "id": device_id, ++ "id": self._pending_status_device_id or device_id, ++ "entity_id": device_id, + "name": device_name, +- "enum": device_enum, ++ "enum": self._pending_status_enum or device_enum, + } + ) + handler.enable_subentry_creation( + device_id=device_id, + device_enum=device_enum, + device_name=device_name, ++ status_device_id=self._pending_status_device_id or device_id, ++ status_enum=self._pending_status_enum or device_enum, ++ invert_direction=self._pending_invert_direction, + ) ++ if self._pairing_workflow == "hybrid": ++ return await self.async_step_test_motor() ++ + _LOGGER.debug( + "Starting calibration for paired device %s (%s) before creating subentry", + device_id, +@@ -404,30 +666,358 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: +- """Configure a blind: run calibration for the single device under this subentry. ++ """Choose whether to edit settings or recalibrate a blind.""" ++ return self.async_show_menu( ++ step_id="reconfigure", ++ menu_options=["edit", "test_existing", "developer_tools", "calibrate"], ++ ) + +- We bypass storage lookup and set the calibration handler's selected device +- directly from the subentry data to avoid device_not_found errors before +- calibration has ever run. +- """ ++ def _developer_details(self) -> dict[str, Any]: ++ """Return normalized protocol details for the selected blind.""" ++ subentry = self._get_reconfigure_subentry() ++ data = subentry.data ++ command_device_id = str( ++ data.get(CONF_COMMAND_DEVICE_ID) or data.get(CONF_DEVICE_ID, "") ++ ).upper() ++ command_enum = str( ++ data.get(CONF_COMMAND_ENUM) or data.get(CONF_DEVICE_ENUM, "") ++ ).upper() ++ return { ++ "name": subentry.title, ++ "command_device_id": command_device_id, ++ "command_enum": command_enum, ++ "status_device_id": str( ++ data.get(CONF_STATUS_DEVICE_ID) or command_device_id ++ ).upper(), ++ "status_enum": str(data.get(CONF_STATUS_ENUM) or command_enum).upper(), ++ "invert_direction": bool(data.get(CONF_INVERT_DIRECTION, False)), ++ "open_time": float(data.get(CONF_OPEN_TIME, 60.0)), ++ "close_time": float(data.get(CONF_CLOSE_TIME, 60.0)), ++ } ++ ++ def _developer_snapshot( ++ self, ++ ) -> tuple[dict[str, Any], dict[str, str]]: ++ """Return current blind details and its latest received frame.""" ++ details = self._developer_details() ++ api = self._get_entry().runtime_data ++ last_received = api.get_last_received( ++ details["status_device_id"], details["status_enum"] ++ ) or { ++ "device_id": "No matching frame received", ++ "enum": "--", ++ "command": "--", ++ "time": "--", ++ } ++ return details, last_received ++ ++ async def async_step_developer_tools( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Show live protocol diagnostics and direct test actions.""" ++ details, last_received = self._developer_snapshot() ++ return self.async_show_menu( ++ step_id="developer_tools", ++ menu_options=[ ++ "test_open", ++ "test_close", ++ "test_stop", ++ "copy_diagnostics", ++ ], ++ description_placeholders={ ++ "selected_blind": str(details["name"]), ++ "last_device_id": last_received["device_id"], ++ "last_enum": last_received["enum"], ++ "last_command": last_received["command"], ++ "last_time": last_received["time"], ++ "command_device_id": details["command_device_id"], ++ "command_enum": details["command_enum"], ++ "result": self._developer_notice, ++ }, ++ ) ++ ++ async def _async_developer_command(self, command: str) -> SubentryFlowResult: ++ """Send one logical command from the developer tools menu.""" ++ details = self._developer_details() ++ invert_direction = details["invert_direction"] ++ action = { ++ "open": CMD_DOWN if invert_direction else CMD_UP, ++ "close": CMD_UP if invert_direction else CMD_DOWN, ++ "stop": CMD_STOP, ++ }[command] ++ sent = await self._get_entry().runtime_data.control_blind( ++ details["command_enum"], ++ action, ++ device_id=details["command_device_id"], ++ ) ++ self._developer_notice = ( ++ f"{command.title()} command queued successfully." ++ if sent ++ else f"{command.title()} command failed; check the integration logs." ++ ) ++ return await self.async_step_developer_tools() ++ ++ async def async_step_test_open( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Send a direct logical-open test command.""" ++ return await self._async_developer_command("open") ++ ++ async def async_step_test_close( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Send a direct logical-close test command.""" ++ return await self._async_developer_command("close") ++ ++ async def async_step_test_stop( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Send a direct stop test command.""" ++ return await self._async_developer_command("stop") ++ ++ async def async_step_copy_diagnostics( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Show a copyable text snapshot for troubleshooting.""" ++ if user_input is not None: ++ return await self.async_step_developer_tools() ++ ++ details, last_received = self._developer_snapshot() ++ diagnostics = "\n".join( ++ ( ++ "Schellenberg USB blind diagnostics", ++ f"Selected blind: {details['name']}", ++ "", ++ "Last received:", ++ f"Device ID: {last_received['device_id']}", ++ f"Enum: {last_received['enum']}", ++ f"Command: {last_received['command']}", ++ f"Time: {last_received['time']}", ++ "", ++ "Current transmit target:", ++ f"Device ID: {details['command_device_id']}", ++ f"Enum: {details['command_enum']}", ++ "", ++ "Configured status identity:", ++ f"Device ID: {details['status_device_id']}", ++ f"Enum: {details['status_enum']}", ++ f"Open time: {details['open_time']:.2f} seconds", ++ f"Close time: {details['close_time']:.2f} seconds", ++ f"Invert direction: {details['invert_direction']}", ++ ) ++ ) ++ return self.async_show_form( ++ step_id="copy_diagnostics", ++ data_schema=vol.Schema( ++ { ++ vol.Required("diagnostics", default=diagnostics): ( ++ selector.TextSelector( ++ selector.TextSelectorConfig(multiline=True) ++ ) ++ ) ++ } ++ ), ++ ) ++ ++ async def async_step_test_existing( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Load an existing blind into the short command test.""" ++ subentry = self._get_reconfigure_subentry() ++ data = subentry.data ++ self._pending_device_name = subentry.title ++ self._pending_device_id = str( ++ data.get(CONF_COMMAND_DEVICE_ID) or data.get(CONF_DEVICE_ID, "") ++ ) ++ self._pending_device_enum = str( ++ data.get(CONF_COMMAND_ENUM) or data.get(CONF_DEVICE_ENUM, "") ++ ) ++ self._pending_status_device_id = str( ++ data.get(CONF_STATUS_DEVICE_ID) or self._pending_device_id ++ ) ++ self._pending_status_enum = str( ++ data.get(CONF_STATUS_ENUM) or self._pending_device_enum ++ ) ++ self._pending_open_time = float(data.get(CONF_OPEN_TIME, 60.0)) ++ self._pending_close_time = float(data.get(CONF_CLOSE_TIME, 60.0)) ++ self._pending_invert_direction = bool(data.get(CONF_INVERT_DIRECTION, False)) ++ self._pairing_workflow = "existing" ++ return await self.async_step_test_motor(user_input) ++ ++ async def async_step_edit( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Edit a blind while preserving its subentry and entity unique IDs.""" ++ entry = self._get_entry() ++ subentry = self._get_reconfigure_subentry() ++ current_data = dict(subentry.data) ++ command_device_id = str( ++ current_data.get(CONF_COMMAND_DEVICE_ID) ++ or current_data.get(CONF_DEVICE_ID, "") ++ ) ++ command_enum = str( ++ current_data.get(CONF_COMMAND_ENUM) ++ or current_data.get(CONF_DEVICE_ENUM, "") ++ ) ++ status_device_id = str( ++ current_data.get(CONF_STATUS_DEVICE_ID) or command_device_id ++ ) ++ status_enum = str(current_data.get(CONF_STATUS_ENUM) or command_enum) ++ errors: dict[str, str] = {} ++ ++ if user_input is not None: ++ device_name = str(user_input[CONF_DEVICE_NAME]).strip() ++ command_device_id = str(user_input[CONF_DEVICE_ID]).strip().upper() ++ command_enum = str(user_input[CONF_DEVICE_ENUM]).strip().upper() ++ status_device_id = ( ++ str(user_input.get(CONF_STATUS_DEVICE_ID, "")).strip().upper() ++ or command_device_id ++ ) ++ status_enum = ( ++ str(user_input.get(CONF_STATUS_ENUM, "")).strip().upper() ++ or command_enum ++ ) ++ open_time = float(user_input[CONF_OPEN_TIME_SECONDS]) ++ close_time = float(user_input[CONF_CLOSE_TIME_SECONDS]) ++ ++ if not device_name: ++ errors[CONF_DEVICE_NAME] = "required" ++ if not self._is_hex_value(command_device_id, 6): ++ errors[CONF_DEVICE_ID] = "invalid_device_id" ++ if not self._is_hex_value(command_enum, 2): ++ errors[CONF_DEVICE_ENUM] = "invalid_device_enum" ++ if not self._is_hex_value(status_device_id, 6): ++ errors[CONF_STATUS_DEVICE_ID] = "invalid_device_id" ++ if not self._is_hex_value(status_enum, 2): ++ errors[CONF_STATUS_ENUM] = "invalid_device_enum" ++ if open_time <= 0: ++ errors[CONF_OPEN_TIME_SECONDS] = "invalid_travel_time" ++ if close_time <= 0: ++ errors[CONF_CLOSE_TIME_SECONDS] = "invalid_travel_time" ++ ++ if any( ++ candidate.subentry_id != subentry.subentry_id ++ and str( ++ candidate.data.get(CONF_COMMAND_DEVICE_ID) ++ or candidate.data.get(CONF_DEVICE_ID, "") ++ ).upper() ++ == command_device_id ++ for candidate in entry.subentries.values() ++ ): ++ errors[CONF_DEVICE_ID] = "already_configured" ++ ++ if not errors: ++ current_data.update( ++ { ++ CONF_DEVICE_ID: command_device_id, ++ CONF_DEVICE_ENUM: command_enum, ++ CONF_COMMAND_DEVICE_ID: command_device_id, ++ CONF_COMMAND_ENUM: command_enum, ++ CONF_STATUS_DEVICE_ID: status_device_id, ++ CONF_STATUS_ENUM: status_enum, ++ CONF_OPEN_TIME: open_time, ++ CONF_CLOSE_TIME: close_time, ++ CONF_INVERT_DIRECTION: bool( ++ user_input.get(CONF_INVERT_DIRECTION, False) ++ ), ++ } ++ ) ++ return self.async_update_and_abort( ++ entry, ++ subentry, ++ title=device_name, ++ data=current_data, ++ ) ++ ++ return self.async_show_form( ++ step_id="edit", ++ data_schema=vol.Schema( ++ { ++ vol.Required( ++ CONF_DEVICE_NAME, ++ default=subentry.title, ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_DEVICE_ID, ++ default=command_device_id, ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_DEVICE_ENUM, ++ default=command_enum, ++ ): selector.TextSelector(), ++ vol.Optional( ++ CONF_STATUS_DEVICE_ID, ++ default=status_device_id, ++ ): selector.TextSelector(), ++ vol.Optional( ++ CONF_STATUS_ENUM, ++ default=status_enum, ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_OPEN_TIME_SECONDS, ++ default=current_data.get(CONF_OPEN_TIME, 60.0), ++ ): selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ vol.Required( ++ CONF_CLOSE_TIME_SECONDS, ++ default=current_data.get(CONF_CLOSE_TIME, 60.0), ++ ): selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ vol.Optional( ++ CONF_INVERT_DIRECTION, ++ default=current_data.get(CONF_INVERT_DIRECTION, False), ++ ): selector.BooleanSelector(), ++ } ++ ), ++ errors=errors, ++ ) ++ ++ async def async_step_calibrate( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Run calibration for the selected blind subentry.""" + handler = self._get_calibration_handler() + handler.disable_subentry_creation() + + subentry = self._get_reconfigure_subentry() +- device_id = subentry.data.get("device_id") +- device_enum = subentry.data.get("device_enum") +- if not device_id: ++ command_device_id = subentry.data.get( ++ CONF_COMMAND_DEVICE_ID, subentry.data.get(CONF_DEVICE_ID) ++ ) ++ command_enum = subentry.data.get( ++ CONF_COMMAND_ENUM, subentry.data.get(CONF_DEVICE_ENUM) ++ ) ++ status_device_id = subentry.data.get(CONF_STATUS_DEVICE_ID, command_device_id) ++ status_enum = subentry.data.get(CONF_STATUS_ENUM, command_enum) ++ if not command_device_id or not status_device_id: + return self.async_abort(reason="device_not_found") + +- # Build a minimal device record; calibration handler will enrich after timing +- device_name = subentry.title or f"Blind {device_id}" ++ stable_id = ( ++ subentry.unique_id ++ if isinstance(subentry.unique_id, str) and subentry.unique_id ++ else command_device_id ++ ) ++ device_name = subentry.title or f"Blind {stable_id}" + handler.set_selected_device( + { +- "id": device_id, ++ "id": status_device_id, ++ "entity_id": stable_id, + "name": device_name, + CONF_OPEN_TIME: subentry.data.get(CONF_OPEN_TIME), + CONF_CLOSE_TIME: subentry.data.get(CONF_CLOSE_TIME), +- "enum": device_enum, ++ CONF_INVERT_DIRECTION: subentry.data.get(CONF_INVERT_DIRECTION, False), ++ "enum": status_enum, + } + ) + +diff --git a/custom_components/schellenberg_usb/const.py b/custom_components/schellenberg_usb/const.py +index 39182e7..73d151c 100644 +--- a/custom_components/schellenberg_usb/const.py ++++ b/custom_components/schellenberg_usb/const.py +@@ -27,6 +27,14 @@ SUBENTRY_TYPE_BLIND = "blind" + CONF_SERIAL_PORT = "serial_port" + CONF_DEVICE_NAME = "device_name" + CONF_DEVICE_ENUM = "device_enum" ++CONF_COMMAND_DEVICE_ID = "command_device_id" ++CONF_COMMAND_ENUM = "command_enum" ++CONF_STATUS_DEVICE_ID = "status_device_id" ++CONF_STATUS_ENUM = "status_enum" ++CONF_INVERT_DIRECTION = "invert_direction" ++CONF_COMMAND = "command" ++CONF_ENUM = "enum" ++CONF_CONFIG_ENTRY_ID = "config_entry_id" + CONF_OPEN_TIME_SECONDS = "open_time_seconds" + CONF_CLOSE_TIME_SECONDS = "close_time_seconds" + +@@ -94,6 +102,10 @@ SIGNAL_PAIRING_TIMEOUT = f"{DOMAIN}_pairing_timeout" + SIGNAL_STICK_STATUS_UPDATED = f"{DOMAIN}_stick_status_updated" + SIGNAL_CALIBRATION_COMPLETED = f"{DOMAIN}_calibration_completed" + ++# Services ++SERVICE_TEST_COMMAND = "test_command" ++TEST_COMMAND_DELAY = 0.75 ++ + # Device verification + VERIFY_TIMEOUT = 5 # seconds to wait for verification response + # Expected response format: RFTU_V20 F:20180510_DFBD B:1 +diff --git a/custom_components/schellenberg_usb/cover.py b/custom_components/schellenberg_usb/cover.py +index 23ac1ec..ef702e9 100644 +--- a/custom_components/schellenberg_usb/cover.py ++++ b/custom_components/schellenberg_usb/cover.py +@@ -25,8 +25,15 @@ from .const import ( + CMD_STOP, + CMD_UP, + CONF_CLOSE_TIME, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, ++ CONF_DEVICE_ENUM, ++ CONF_DEVICE_ID, ++ CONF_INVERT_DIRECTION, + CONF_OPEN_TIME, + CONF_SERIAL_PORT, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + DOMAIN, + EVENT_STARTED_MOVING_DOWN, + EVENT_STARTED_MOVING_UP, +@@ -78,25 +85,57 @@ async def async_setup_entry( + # Skip LED subentry; handled by switch platform + if subentry.subentry_type == SUBENTRY_TYPE_LED: + continue +- device_id = subentry.data.get("device_id") +- device_enum = subentry.data.get("device_enum") ++ legacy_device_id = subentry.data.get(CONF_DEVICE_ID) ++ legacy_device_enum = subentry.data.get(CONF_DEVICE_ENUM) ++ command_device_id = ( ++ subentry.data.get(CONF_COMMAND_DEVICE_ID) or legacy_device_id ++ ) ++ command_enum = subentry.data.get(CONF_COMMAND_ENUM) or legacy_device_enum ++ status_device_id = ( ++ subentry.data.get(CONF_STATUS_DEVICE_ID) ++ or legacy_device_id ++ or command_device_id ++ ) ++ status_enum = ( ++ subentry.data.get(CONF_STATUS_ENUM) ++ or legacy_device_enum ++ or command_enum ++ ) ++ subentry_unique_id = getattr(subentry, "unique_id", None) ++ stable_device_id = ( ++ subentry_unique_id ++ if isinstance(subentry_unique_id, str) and subentry_unique_id ++ else legacy_device_id or command_device_id ++ ) + device_name = subentry.title + +- if not device_id or not device_enum: ++ if not all( ++ ( ++ stable_device_id, ++ command_device_id, ++ command_enum, ++ status_device_id, ++ status_enum, ++ ) ++ ): + # This subentry lacks motor identification info; it's likely a non-motor type + # or pairing is incomplete. Downgrade to debug to avoid user confusion. + _LOGGER.debug( +- "Skipping subentry %s (type=%s) missing device_id/device_enum", ++ "Skipping subentry %s (type=%s) with incomplete command/status identity", + subentry.subentry_id, + getattr(subentry, "subentry_type", "unknown"), + ) + continue + ++ stable_device_id = str(stable_device_id) ++ command_device_id = str(command_device_id) ++ command_enum = str(command_enum) ++ status_device_id = str(status_device_id) ++ status_enum = str(status_enum) ++ + # Check if entity already exists to avoid duplicates. +- # NOTE: The cover entity sets its unique_id to f"schellenberg_{device_id}" in the entity class. +- # We must use the same pattern here; previously this used f"{device_id}_cover" which never matched +- # and caused a new entity to be created on every reload, losing the restored position (defaulting to 0). +- entity_unique_id = f"schellenberg_{device_id}" ++ # Keep the original subentry unique ID stable when editable protocol IDs change. ++ entity_unique_id = f"schellenberg_{stable_device_id}" + existing_entity_id = entity_registry.async_get_entity_id( + "cover", DOMAIN, entity_unique_id + ) +@@ -124,29 +163,38 @@ async def async_setup_entry( + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id=subentry.subentry_id, +- identifiers={(DOMAIN, device_id)}, ++ identifiers={(DOMAIN, stable_device_id)}, + name=device_name, + manufacturer="Schellenberg", +- model=f"USB Stick Motor ({device_id}/{device_enum})", ++ model=( ++ f"USB Stick Motor (command {command_device_id}/{command_enum}, " ++ f"status {status_device_id}/{status_enum})" ++ ), + ) + _LOGGER.debug( + "Created/updated device %s for paired device %s", + device.id, +- device_id, ++ stable_device_id, + ) + + # Create cover entity linked to this device + # Create and add the new cover entity attached to the subentry +- _LOGGER.debug("Creating cover entity for device %s", device_id) ++ _LOGGER.debug("Creating cover entity for device %s", stable_device_id) + async_add_entities( + [ + SchellenbergCover( + api=api, +- device_id=device_id, +- device_enum=device_enum, ++ device_id=stable_device_id, ++ device_enum=command_enum, + device_name=device_name, + device_data=subentry.data, + config_entry_id=entry.entry_id, ++ command_device_id=command_device_id, ++ status_device_id=status_device_id, ++ status_enum=status_enum, ++ invert_direction=bool( ++ subentry.data.get(CONF_INVERT_DIRECTION, False) ++ ), + ) + ], + config_subentry_id=subentry.subentry_id, +@@ -178,6 +226,10 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + device_name: str, + device_data: Mapping[str, Any] | None = None, + config_entry_id: str | None = None, ++ command_device_id: str | None = None, ++ status_device_id: str | None = None, ++ status_enum: str | None = None, ++ invert_direction: bool = False, + ) -> None: + """Initialize the Schellenberg cover entity. + +@@ -188,11 +240,21 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + device_name: Friendly name for the device + device_data: Device data dict containing calibration times + config_entry_id: The config entry ID for linking to device ++ command_device_id: Protocol ID associated with outgoing commands ++ status_device_id: Protocol ID expected in incoming status messages ++ status_enum: Enum expected in incoming status messages ++ invert_direction: Swap physical up/down commands for logical open/close + + """ + self._api = api + self._device_id = device_id +- self._device_enum = device_enum ++ self._command_device_id = command_device_id or device_id ++ self._command_enum = device_enum ++ self._status_device_id = status_device_id or self._command_device_id ++ self._status_enum = status_enum or device_enum ++ self._invert_direction = invert_direction ++ # Backward-compatible alias retained for diagnostics. ++ self._device_enum = self._command_enum + self._config_entry_id = config_entry_id + + # Entity attributes +@@ -262,7 +324,13 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + await super().async_added_to_hass() + + # Register this entity with the API so it knows we're listening +- self._api.register_entity(self._device_id, self._device_enum) ++ self._api.register_entity( ++ self._status_device_id, ++ self._status_enum, ++ self._attr_name, ++ command_device_id=self._command_device_id, ++ command_enum=self._command_enum, ++ ) + + # Restore the last known state + last_state = await self.async_get_last_state() +@@ -322,7 +390,7 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + self.async_on_remove( + async_dispatcher_connect( + self.hass, +- f"{SIGNAL_DEVICE_EVENT}_{self._device_id}", ++ f"{SIGNAL_DEVICE_EVENT}_{self._status_device_id}_{self._status_enum}", + self._handle_event, + ) + ) +@@ -394,21 +462,19 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + event, + ) + +- if event == EVENT_STARTED_MOVING_UP: +- _LOGGER.info("Device %s started moving UP", self._attr_name) +- self._attr_is_opening = True +- self._attr_is_closing = False +- self._move_start_time = time.monotonic() +- self._move_start_position = self._attr_current_cover_position +- # Start real-time position tracking +- self._start_position_tracking() +- elif event == EVENT_STARTED_MOVING_DOWN: +- _LOGGER.info("Device %s started moving DOWN", self._attr_name) +- self._attr_is_opening = False +- self._attr_is_closing = True ++ if event in (EVENT_STARTED_MOVING_UP, EVENT_STARTED_MOVING_DOWN): ++ physical_up = event == EVENT_STARTED_MOVING_UP ++ logical_opening = physical_up != self._invert_direction ++ _LOGGER.info( ++ "Device %s physical_direction=%s logical_direction=%s", ++ self._attr_name, ++ "up" if physical_up else "down", ++ "opening" if logical_opening else "closing", ++ ) ++ self._attr_is_opening = logical_opening ++ self._attr_is_closing = not logical_opening + self._move_start_time = time.monotonic() + self._move_start_position = self._attr_current_cover_position +- # Start real-time position tracking + self._start_position_tracking() + elif event == EVENT_STOPPED: + _LOGGER.info( +@@ -498,7 +564,11 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + # If target is 0 or 100, let the device stop naturally at its limits. + # For intermediate, send STOP and wait for STOP event to finalize & clear target. + if self._target_position not in (0, 100): +- await self._api.control_blind(self._device_enum, CMD_STOP) ++ await self._api.control_blind( ++ self._command_enum, ++ CMD_STOP, ++ device_id=self._command_device_id, ++ ) + # Stop tracking loop + self._position_update_task = None + # Leave opening/closing flags as-is until STOP to aid debugging +@@ -593,7 +663,14 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" +- _LOGGER.debug("Opening cover %s (enum=%s)", self._attr_name, self._device_enum) ++ action = CMD_DOWN if self._invert_direction else CMD_UP ++ _LOGGER.debug( ++ "Opening cover %s (command_id=%s enum=%s action=%s)", ++ self._attr_name, ++ self._command_device_id, ++ self._command_enum, ++ action, ++ ) + self._attr_is_opening = True + self._attr_is_closing = False + self._move_start_time = time.monotonic() +@@ -603,11 +680,20 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + self._move_start_position = self._attr_current_cover_position + self._start_position_tracking() + self.async_write_ha_state() +- await self._api.control_blind(self._device_enum, CMD_UP) ++ await self._api.control_blind( ++ self._command_enum, action, device_id=self._command_device_id ++ ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" +- _LOGGER.debug("Closing cover %s (enum=%s)", self._attr_name, self._device_enum) ++ action = CMD_UP if self._invert_direction else CMD_DOWN ++ _LOGGER.debug( ++ "Closing cover %s (command_id=%s enum=%s action=%s)", ++ self._attr_name, ++ self._command_device_id, ++ self._command_enum, ++ action, ++ ) + self._attr_is_opening = False + self._attr_is_closing = True + self._move_start_time = time.monotonic() +@@ -616,11 +702,18 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + self._move_start_position = self._attr_current_cover_position + self._start_position_tracking() + self.async_write_ha_state() +- await self._api.control_blind(self._device_enum, CMD_DOWN) ++ await self._api.control_blind( ++ self._command_enum, action, device_id=self._command_device_id ++ ) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" +- _LOGGER.debug("Stopping cover %s (enum=%s)", self._attr_name, self._device_enum) ++ _LOGGER.debug( ++ "Stopping cover %s (command_id=%s enum=%s)", ++ self._attr_name, ++ self._command_device_id, ++ self._command_enum, ++ ) + self._stop_position_tracking() + self._update_position() + self._attr_is_opening = False +@@ -629,7 +722,9 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + self._move_start_position = None + self._target_position = None + self.async_write_ha_state() +- await self._api.control_blind(self._device_enum, CMD_STOP) ++ await self._api.control_blind( ++ self._command_enum, CMD_STOP, device_id=self._command_device_id ++ ) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" +diff --git a/custom_components/schellenberg_usb/options_flow_calibration.py b/custom_components/schellenberg_usb/options_flow_calibration.py +index 7894fd6..68f5681 100644 +--- a/custom_components/schellenberg_usb/options_flow_calibration.py ++++ b/custom_components/schellenberg_usb/options_flow_calibration.py +@@ -23,8 +23,14 @@ from homeassistant.helpers.storage import Store + from .const import ( + CALIBRATION_TIMEOUT, + CONF_CLOSE_TIME, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, ++ CONF_DEVICE_ENUM, + CONF_DEVICE_ID, ++ CONF_INVERT_DIRECTION, + CONF_OPEN_TIME, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + EVENT_STARTED_MOVING_DOWN, + EVENT_STARTED_MOVING_UP, + EVENT_STOPPED, +@@ -58,6 +64,9 @@ class CalibrationFlowHandler: + self._pending_device_id: str | None = None + self._pending_device_enum: str | None = None + self._pending_device_name: str | None = None ++ self._pending_status_device_id: str | None = None ++ self._pending_status_enum: str | None = None ++ self._pending_invert_direction = False + + async def set_device_by_id(self, device_id: str) -> None: + """Set the device to calibrate by its ID. +@@ -210,9 +219,13 @@ class CalibrationFlowHandler: + + # User clicked Next - wait for movement start and measure timing + try: +- # Wait for user to manually open the blinds +- # This will trigger EVENT_STARTED_MOVING_UP from the device +- start_ok = await self._wait_for_movement_start(EVENT_STARTED_MOVING_UP) ++ # Wait for the physical direction that corresponds to logical opening. ++ open_event = ( ++ EVENT_STARTED_MOVING_DOWN ++ if self._selected_device.get(CONF_INVERT_DIRECTION, False) ++ else EVENT_STARTED_MOVING_UP ++ ) ++ start_ok = await self._wait_for_movement_start(open_event) + if not start_ok: + errors["base"] = "calibration_start_timeout" + return self.flow.async_show_form( +@@ -283,9 +296,13 @@ class CalibrationFlowHandler: + + # User clicked Next - wait for movement start and measure timing + try: +- # Wait for user to manually close the blinds +- # This will trigger EVENT_STARTED_MOVING_DOWN from the device +- start_ok = await self._wait_for_movement_start(EVENT_STARTED_MOVING_DOWN) ++ # Wait for the physical direction that corresponds to logical closing. ++ close_event = ( ++ EVENT_STARTED_MOVING_UP ++ if self._selected_device.get(CONF_INVERT_DIRECTION, False) ++ else EVENT_STARTED_MOVING_DOWN ++ ) ++ start_ok = await self._wait_for_movement_start(close_event) + if not start_ok: + errors["base"] = "calibration_start_timeout" + return self.flow.async_show_form( +@@ -360,10 +377,15 @@ class CalibrationFlowHandler: + return self.flow.async_create_entry( # type: ignore[attr-defined] + title=self._pending_device_name, + data={ +- "device_id": self._pending_device_id, +- "device_enum": self._pending_device_enum, ++ CONF_DEVICE_ID: self._pending_device_id, ++ CONF_DEVICE_ENUM: self._pending_device_enum, ++ CONF_COMMAND_DEVICE_ID: self._pending_device_id, ++ CONF_COMMAND_ENUM: self._pending_device_enum, ++ CONF_STATUS_DEVICE_ID: self._pending_status_device_id, ++ CONF_STATUS_ENUM: self._pending_status_enum, + CONF_OPEN_TIME: round(self._open_time, 2), + CONF_CLOSE_TIME: round(self._close_time, 2), ++ CONF_INVERT_DIRECTION: self._pending_invert_direction, + }, + unique_id=self._pending_device_id, + ) +@@ -505,7 +527,7 @@ class CalibrationFlowHandler: + async_dispatcher_send( + self.flow.hass, + SIGNAL_CALIBRATION_COMPLETED, +- self._selected_device["id"], ++ self._selected_device.get("entity_id", self._selected_device["id"]), + round(open_time, 2), + round(close_time, 2), + ) +@@ -516,12 +538,18 @@ class CalibrationFlowHandler: + device_id: str, + device_enum: str, + device_name: str, ++ status_device_id: str | None = None, ++ status_enum: str | None = None, ++ invert_direction: bool = False, + ) -> None: + """Enable creating a subentry after calibration completes.""" + self._create_subentry_after_calibration = True + self._pending_device_id = device_id + self._pending_device_enum = device_enum + self._pending_device_name = device_name ++ self._pending_status_device_id = status_device_id or device_id ++ self._pending_status_enum = status_enum or device_enum ++ self._pending_invert_direction = invert_direction + + def disable_subentry_creation(self) -> None: + """Disable subentry creation (used for reconfigure flows).""" +@@ -529,3 +557,6 @@ class CalibrationFlowHandler: + self._pending_device_id = None + self._pending_device_enum = None + self._pending_device_name = None ++ self._pending_status_device_id = None ++ self._pending_status_enum = None ++ self._pending_invert_direction = False +diff --git a/custom_components/schellenberg_usb/services.yaml b/custom_components/schellenberg_usb/services.yaml +index c9b9b1b..d33d89b 100644 +--- a/custom_components/schellenberg_usb/services.yaml ++++ b/custom_components/schellenberg_usb/services.yaml +@@ -3,3 +3,37 @@ + pair: + name: Pair device + description: Activate pairing mode on the Schellenberg USB stick to pair a new blind motor. After calling this service, press the pairing button on your blind motor within 10 seconds. ++ ++test_command: ++ name: Test blind command ++ description: Send a raw open, close, or stop command to a device enum without creating a cover. ++ fields: ++ device_id: ++ name: Device ID ++ description: Six-character device ID used for logging and diagnosis. ++ required: true ++ example: "F2B8D5" ++ selector: ++ text: ++ enum: ++ name: Enum ++ description: Two-character enum inserted into the outgoing serial payload. ++ required: true ++ example: "23" ++ selector: ++ text: ++ command: ++ name: Command ++ required: true ++ selector: ++ select: ++ options: ++ - open ++ - close ++ - stop ++ config_entry_id: ++ name: Config entry ID ++ description: Required only when more than one Schellenberg USB hub is loaded. ++ required: false ++ selector: ++ text: +diff --git a/custom_components/schellenberg_usb/strings.json b/custom_components/schellenberg_usb/strings.json +index c1e24c2..7e89c2c 100644 +--- a/custom_components/schellenberg_usb/strings.json ++++ b/custom_components/schellenberg_usb/strings.json +@@ -39,32 +39,48 @@ + "entry_type": "Blind", + "initiate_flow": { + "user": "Add blind", +- "reconfigure": "Calibrate" ++ "reconfigure": "Configure" + }, + "step": { + "user": { + "title": "Add a blind", + "menu_options": { +- "pair_device": "Pair and calibrate", ++ "pair_test": "Pair and test (recommended)", ++ "pair_device": "Pair and calibrate (legacy)", + "manual": "Add manually" + } + }, + "pair_device": { +- "title": "Pair a new device", +- "description": "Press the pair button to activate pairing mode on your Schellenberg USB stick. Then press the pairing button on your blind motor within 2 minutes.", ++ "title": "Pair and calibrate", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes.", ++ "submit": "Pair" ++ }, ++ "pair_test": { ++ "title": "Pair and test", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes. After pairing, a short motor command will be tested before calibration.", + "submit": "Pair" + }, + "manual": { +- "title": "Add a blind manually", +- "description": "Enter the device ID and enum shown in the logs, plus the measured full travel times. Calibration can be run later if needed.", ++ "title": "Configure blind identities", ++ "description": "The command identity is used for outgoing control. The optional status identity is matched against received movement messages; leave it blank to use the command identity.", + "data": { + "device_name": "Name", +- "device_id": "Device ID", +- "device_enum": "Enum", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", + "open_time_seconds": "Open time", +- "close_time_seconds": "Close time" ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" + }, +- "submit": "Add" ++ "submit": "Continue" ++ }, ++ "manual_next": { ++ "title": "Manual blind ready", ++ "menu_options": { ++ "test_motor": "Test motor command", ++ "save_manual": "Save without testing" ++ } + }, + "name_device": { + "title": "Name your device", +@@ -73,6 +89,77 @@ + "device_name": "Device name" + } + }, ++ "test_motor": { ++ "title": "Test motor command", ++ "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", ++ "submit": "Test" ++ }, ++ "did_motor_move": { ++ "title": "Did the motor move?", ++ "description": "Tested command identity {device_id}/{device_enum}.", ++ "data": { ++ "motor_moved": "The motor moved" ++ }, ++ "submit": "Continue" ++ }, ++ "test_success": { ++ "title": "Command test successful", ++ "menu_options": { ++ "calibration_close": "Continue to calibration", ++ "manual_times": "Save with manual travel times" ++ } ++ }, ++ "manual_times": { ++ "title": "Enter travel times", ++ "description": "Enter the measured full opening and closing times.", ++ "data": { ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time" ++ }, ++ "submit": "Save" ++ }, ++ "reconfigure": { ++ "title": "Configure blind", ++ "menu_options": { ++ "edit": "Edit identities and travel times", ++ "test_existing": "Test motor command", ++ "developer_tools": "Developer tools", ++ "calibrate": "Calibrate" ++ } ++ }, ++ "developer_tools": { ++ "title": "Developer tools", ++ "description": "Selected blind: {selected_blind}\n\nLast received:\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nTime: {last_time}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.", ++ "menu_options": { ++ "test_open": "Test Open", ++ "test_close": "Test Close", ++ "test_stop": "Test Stop", ++ "copy_diagnostics": "Copy diagnostics" ++ } ++ }, ++ "copy_diagnostics": { ++ "title": "Copy diagnostics", ++ "description": "Select and copy the diagnostic text below. Press Submit to return.", ++ "data": { ++ "diagnostics": "Diagnostics" ++ }, ++ "submit": "Return" ++ }, ++ "edit": { ++ "title": "Edit blind settings", ++ "description": "Changing protocol identities does not change the Home Assistant entity unique ID.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" ++ }, ++ "submit": "Save" ++ }, + "calibration_close": { + "title": "Step 1: Close the blind", + "description": "First, ensure {device_name} is fully closed (all the way down). Press 'Next' when ready.", +@@ -80,17 +167,17 @@ + }, + "calibration_open_instruction": { + "title": "Step 2: Open the blind", +- "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving up message from the blinds.", ++ "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring.", + "submit": "Start" + }, + "calibration_close_instruction": { + "title": "Step 3: Close the blind", +- "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving down message from the blinds.", ++ "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring.", + "submit": "Start" + }, + "calibration_complete": { + "title": "Calibration complete", +- "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds\n\nThese times will be used to accurately track the blind position.", ++ "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds", + "submit": "Done" + } + }, +@@ -99,17 +186,19 @@ + "invalid_device_id": "Device ID must contain exactly 6 hexadecimal characters.", + "invalid_device_enum": "Enum must contain exactly 2 hexadecimal characters.", + "invalid_travel_time": "Travel time must be greater than zero.", +- "already_configured": "This device is already configured.", +- "pairing_timeout": "No device responded within 2 minutes. Please try again and ensure you press the pairing button on your blind motor.", ++ "already_configured": "This command device ID is already configured.", ++ "command_failed": "The serial command could not be queued. Check the USB connection and logs.", ++ "pairing_timeout": "No device responded within 2 minutes. Please try again.", + "pairing_failed": "Pairing failed.", +- "calibration_timeout": "Calibration timed out. The blind took more than 5 minutes to complete the movement. Please check the device and try again.", +- "calibration_start_timeout": "Timeout waiting for the blind to start moving. Please ensure the device is properly connected and try again.", +- "unknown": "An unexpected error occurred during calibration. Please try again." ++ "calibration_timeout": "Calibration timed out. Please check the device and try again.", ++ "calibration_start_timeout": "Timeout waiting for the blind to start moving.", ++ "unknown": "An unexpected error occurred." + }, + "abort": { + "pairing_timeout": "No device responded within 2 minutes. Please try again.", + "pairing_failed": "Pairing failed.", +- "reconfigure_successful": "Calibration completed successfully" ++ "command_test_successful": "The motor command test succeeded.", ++ "reconfigure_successful": "Blind configuration updated successfully" + } + } + }, +@@ -151,6 +240,10 @@ + "pair": { + "name": "Pair device", + "description": "Activate pairing mode on the Schellenberg USB stick to pair a new blind motor. After calling this service, press the pairing button on your blind motor within 10 seconds." ++ }, ++ "test_command": { ++ "name": "Test blind command", ++ "description": "Send open, close, or stop to a device enum without creating a cover." + } + } + } +diff --git a/custom_components/schellenberg_usb/translations/en.json b/custom_components/schellenberg_usb/translations/en.json +index c1e24c2..7e89c2c 100644 +--- a/custom_components/schellenberg_usb/translations/en.json ++++ b/custom_components/schellenberg_usb/translations/en.json +@@ -39,32 +39,48 @@ + "entry_type": "Blind", + "initiate_flow": { + "user": "Add blind", +- "reconfigure": "Calibrate" ++ "reconfigure": "Configure" + }, + "step": { + "user": { + "title": "Add a blind", + "menu_options": { +- "pair_device": "Pair and calibrate", ++ "pair_test": "Pair and test (recommended)", ++ "pair_device": "Pair and calibrate (legacy)", + "manual": "Add manually" + } + }, + "pair_device": { +- "title": "Pair a new device", +- "description": "Press the pair button to activate pairing mode on your Schellenberg USB stick. Then press the pairing button on your blind motor within 2 minutes.", ++ "title": "Pair and calibrate", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes.", ++ "submit": "Pair" ++ }, ++ "pair_test": { ++ "title": "Pair and test", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes. After pairing, a short motor command will be tested before calibration.", + "submit": "Pair" + }, + "manual": { +- "title": "Add a blind manually", +- "description": "Enter the device ID and enum shown in the logs, plus the measured full travel times. Calibration can be run later if needed.", ++ "title": "Configure blind identities", ++ "description": "The command identity is used for outgoing control. The optional status identity is matched against received movement messages; leave it blank to use the command identity.", + "data": { + "device_name": "Name", +- "device_id": "Device ID", +- "device_enum": "Enum", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", + "open_time_seconds": "Open time", +- "close_time_seconds": "Close time" ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" + }, +- "submit": "Add" ++ "submit": "Continue" ++ }, ++ "manual_next": { ++ "title": "Manual blind ready", ++ "menu_options": { ++ "test_motor": "Test motor command", ++ "save_manual": "Save without testing" ++ } + }, + "name_device": { + "title": "Name your device", +@@ -73,6 +89,77 @@ + "device_name": "Device name" + } + }, ++ "test_motor": { ++ "title": "Test motor command", ++ "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", ++ "submit": "Test" ++ }, ++ "did_motor_move": { ++ "title": "Did the motor move?", ++ "description": "Tested command identity {device_id}/{device_enum}.", ++ "data": { ++ "motor_moved": "The motor moved" ++ }, ++ "submit": "Continue" ++ }, ++ "test_success": { ++ "title": "Command test successful", ++ "menu_options": { ++ "calibration_close": "Continue to calibration", ++ "manual_times": "Save with manual travel times" ++ } ++ }, ++ "manual_times": { ++ "title": "Enter travel times", ++ "description": "Enter the measured full opening and closing times.", ++ "data": { ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time" ++ }, ++ "submit": "Save" ++ }, ++ "reconfigure": { ++ "title": "Configure blind", ++ "menu_options": { ++ "edit": "Edit identities and travel times", ++ "test_existing": "Test motor command", ++ "developer_tools": "Developer tools", ++ "calibrate": "Calibrate" ++ } ++ }, ++ "developer_tools": { ++ "title": "Developer tools", ++ "description": "Selected blind: {selected_blind}\n\nLast received:\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nTime: {last_time}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.", ++ "menu_options": { ++ "test_open": "Test Open", ++ "test_close": "Test Close", ++ "test_stop": "Test Stop", ++ "copy_diagnostics": "Copy diagnostics" ++ } ++ }, ++ "copy_diagnostics": { ++ "title": "Copy diagnostics", ++ "description": "Select and copy the diagnostic text below. Press Submit to return.", ++ "data": { ++ "diagnostics": "Diagnostics" ++ }, ++ "submit": "Return" ++ }, ++ "edit": { ++ "title": "Edit blind settings", ++ "description": "Changing protocol identities does not change the Home Assistant entity unique ID.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" ++ }, ++ "submit": "Save" ++ }, + "calibration_close": { + "title": "Step 1: Close the blind", + "description": "First, ensure {device_name} is fully closed (all the way down). Press 'Next' when ready.", +@@ -80,17 +167,17 @@ + }, + "calibration_open_instruction": { + "title": "Step 2: Open the blind", +- "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving up message from the blinds.", ++ "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring.", + "submit": "Start" + }, + "calibration_close_instruction": { + "title": "Step 3: Close the blind", +- "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving down message from the blinds.", ++ "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring.", + "submit": "Start" + }, + "calibration_complete": { + "title": "Calibration complete", +- "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds\n\nThese times will be used to accurately track the blind position.", ++ "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds", + "submit": "Done" + } + }, +@@ -99,17 +186,19 @@ + "invalid_device_id": "Device ID must contain exactly 6 hexadecimal characters.", + "invalid_device_enum": "Enum must contain exactly 2 hexadecimal characters.", + "invalid_travel_time": "Travel time must be greater than zero.", +- "already_configured": "This device is already configured.", +- "pairing_timeout": "No device responded within 2 minutes. Please try again and ensure you press the pairing button on your blind motor.", ++ "already_configured": "This command device ID is already configured.", ++ "command_failed": "The serial command could not be queued. Check the USB connection and logs.", ++ "pairing_timeout": "No device responded within 2 minutes. Please try again.", + "pairing_failed": "Pairing failed.", +- "calibration_timeout": "Calibration timed out. The blind took more than 5 minutes to complete the movement. Please check the device and try again.", +- "calibration_start_timeout": "Timeout waiting for the blind to start moving. Please ensure the device is properly connected and try again.", +- "unknown": "An unexpected error occurred during calibration. Please try again." ++ "calibration_timeout": "Calibration timed out. Please check the device and try again.", ++ "calibration_start_timeout": "Timeout waiting for the blind to start moving.", ++ "unknown": "An unexpected error occurred." + }, + "abort": { + "pairing_timeout": "No device responded within 2 minutes. Please try again.", + "pairing_failed": "Pairing failed.", +- "reconfigure_successful": "Calibration completed successfully" ++ "command_test_successful": "The motor command test succeeded.", ++ "reconfigure_successful": "Blind configuration updated successfully" + } + } + }, +@@ -151,6 +240,10 @@ + "pair": { + "name": "Pair device", + "description": "Activate pairing mode on the Schellenberg USB stick to pair a new blind motor. After calling this service, press the pairing button on your blind motor within 10 seconds." ++ }, ++ "test_command": { ++ "name": "Test blind command", ++ "description": "Send open, close, or stop to a device enum without creating a cover." + } + } + } +diff --git a/tests/test_api_messages.py b/tests/test_api_messages.py +index 082492e..064d067 100644 +--- a/tests/test_api_messages.py ++++ b/tests/test_api_messages.py +@@ -147,11 +147,49 @@ async def test_handle_message_device_event_registered_device( + # Format: ssXXYYYYYYZZZZCCPPRR where XX=enum, YYYYYY=device_id, CC=command + api._handle_message("ss10ABC123ZZZZ01PP00") + +- # Should dispatch event to device +- mock_send.assert_called_once() +- call_args = mock_send.call_args[0] +- assert "schellenberg_usb_device_event_ABC123" in call_args[1] +- assert call_args[2] == "01" # command ++ # Calibration receives the ID-only signal and the cover receives an exact signal. ++ assert mock_send.call_count == 2 ++ assert mock_send.call_args_list[0].args == ( ++ hass, ++ "schellenberg_usb_device_event_ABC123", ++ "01", ++ ) ++ assert mock_send.call_args_list[1].args == ( ++ hass, ++ "schellenberg_usb_device_event_ABC123_10", ++ "01", ++ ) ++ ++ ++@pytest.mark.asyncio ++async def test_handle_message_requires_exact_status_pair( ++ hass: HomeAssistant, ++) -> None: ++ """Test a matching ID with another enum does not reach the cover.""" ++ api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") ++ api.register_entity( ++ "3720B8", ++ "08", ++ "Sitting room", ++ command_device_id="F2B8D5", ++ command_enum="23", ++ ) ++ ++ with patch( ++ "custom_components.schellenberg_usb.api.async_dispatcher_send" ++ ) as mock_send: ++ api._handle_message("ss133720B8ZZZZ01PP00") ++ ++ mock_send.assert_called_once_with( ++ hass, ++ "schellenberg_usb_device_event_3720B8", ++ "01", ++ ) ++ last_received = api.get_last_received("3720B8", "13") ++ assert last_received is not None ++ assert last_received["device_id"] == "3720B8" ++ assert last_received["enum"] == "13" ++ assert last_received["command"] == "01" + + + @pytest.mark.asyncio +diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py +index abc86f9..3e39f78 100644 +--- a/tests/test_config_flow.py ++++ b/tests/test_config_flow.py +@@ -3,7 +3,7 @@ + from __future__ import annotations + + from types import MappingProxyType +-from unittest.mock import AsyncMock, MagicMock, patch ++from unittest.mock import AsyncMock, MagicMock, call, patch + + import pytest + from homeassistant.config_entries import ConfigSubentryFlow, SOURCE_USER +@@ -13,13 +13,21 @@ from custom_components.schellenberg_usb.config_flow import ( + SchellenbergPairingSubentryFlow, + ) + from custom_components.schellenberg_usb.const import ( ++ CMD_DOWN, ++ CMD_STOP, ++ CMD_UP, + CONF_CLOSE_TIME, + CONF_CLOSE_TIME_SECONDS, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, + CONF_DEVICE_ENUM, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, ++ CONF_INVERT_DIRECTION, + CONF_OPEN_TIME, + CONF_OPEN_TIME_SECONDS, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + ) + from custom_components.schellenberg_usb.options_flow_calibration import ( + CalibrationFlowHandler, +@@ -35,67 +43,94 @@ def _create_flow() -> SchellenbergPairingSubentryFlow: + + @pytest.mark.asyncio + async def test_blind_subentry_flow_shows_setup_method_menu() -> None: +- """Test that users can choose pairing or manual setup.""" +- flow = _create_flow() +- +- result = await flow.async_step_user() ++ """Test that legacy, hybrid, and manual setup remain available.""" ++ result = await _create_flow().async_step_user() + + assert result["type"] is FlowResultType.MENU +- assert result["step_id"] == "user" +- assert result["menu_options"] == ["pair_device", "manual"] ++ assert result["menu_options"] == ["pair_test", "pair_device", "manual"] + + + @pytest.mark.asyncio +-async def test_pairing_path_is_unchanged() -> None: +- """Test that selecting pairing still starts the existing pairing flow.""" ++async def test_legacy_pairing_path_is_unchanged() -> None: ++ """Test selecting legacy pairing still reaches naming and calibration.""" + flow = _create_flow() + api = MagicMock() + api.pair_device_and_wait = AsyncMock(return_value=("3720B8", "08")) + hub_entry = MagicMock(runtime_data=api) + + form = await flow.async_step_pair_device() +- assert form["type"] is FlowResultType.FORM + assert form["step_id"] == "pair_device" + + with patch.object(flow, "_get_entry", return_value=hub_entry): + result = await flow.async_step_pair_device({}) + +- assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "name_device" +- assert result["description_placeholders"] == {"device_id": "3720B8"} ++ assert flow._pairing_workflow == "legacy" + + + @pytest.mark.asyncio +-async def test_manual_setup_creates_normalized_blind_subentry() -> None: +- """Test creating a calibrated blind without running calibration.""" ++async def test_hybrid_pairing_reaches_command_test() -> None: ++ """Test hybrid pairing names the device before command testing.""" ++ flow = _create_flow() ++ api = MagicMock() ++ api.pair_device_and_wait = AsyncMock(return_value=("F2B8D5", "23")) ++ hub_entry = MagicMock(runtime_data=api) ++ ++ with patch.object(flow, "_get_entry", return_value=hub_entry): ++ await flow.async_step_pair_test({}) ++ result = await flow.async_step_name_device({CONF_DEVICE_NAME: "Sitting room"}) ++ ++ assert result["type"] is FlowResultType.FORM ++ assert result["step_id"] == "test_motor" ++ assert result["description_placeholders"] == { ++ "device_id": "F2B8D5", ++ "device_enum": "23", ++ } ++ ++ ++@pytest.mark.asyncio ++async def test_manual_setup_stores_separate_command_and_status_identity() -> None: ++ """Test manual setup stores split identities while retaining legacy keys.""" + flow = _create_flow() + hub_entry = MagicMock(subentries=MappingProxyType({})) + + with patch.object(flow, "_get_entry", return_value=hub_entry): + result = await flow.async_step_manual( + { +- CONF_DEVICE_NAME: "Living room", +- CONF_DEVICE_ID: "3720b8", +- CONF_DEVICE_ENUM: "08", +- CONF_OPEN_TIME_SECONDS: 24.5, +- CONF_CLOSE_TIME_SECONDS: 22, ++ CONF_DEVICE_NAME: "Sitting room", ++ CONF_DEVICE_ID: "f2b8d5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720b8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME_SECONDS: 25.06, ++ CONF_CLOSE_TIME_SECONDS: 23.05, ++ CONF_INVERT_DIRECTION: False, + } + ) + ++ assert result["type"] is FlowResultType.MENU ++ assert result["step_id"] == "manual_next" ++ ++ result = await flow.async_step_save_manual() + assert result["type"] is FlowResultType.CREATE_ENTRY +- assert result["title"] == "Living room" ++ assert result["title"] == "Sitting room" + assert result["data"] == { +- CONF_DEVICE_ID: "3720B8", +- CONF_DEVICE_ENUM: "08", +- CONF_OPEN_TIME: 24.5, +- CONF_CLOSE_TIME: 22.0, ++ CONF_DEVICE_ID: "F2B8D5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_COMMAND_DEVICE_ID: "F2B8D5", ++ CONF_COMMAND_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720B8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME: 25.06, ++ CONF_CLOSE_TIME: 23.05, ++ CONF_INVERT_DIRECTION: False, + } +- assert result["unique_id"] == "3720B8" ++ assert result["unique_id"] == "F2B8D5" + + + @pytest.mark.asyncio + async def test_manual_setup_validates_protocol_values() -> None: +- """Test validation of IDs and positive travel times.""" ++ """Test validation of command/status identities and travel times.""" + flow = _create_flow() + hub_entry = MagicMock(subentries=MappingProxyType({})) + +@@ -105,6 +140,8 @@ async def test_manual_setup_validates_protocol_values() -> None: + CONF_DEVICE_NAME: " ", + CONF_DEVICE_ID: "not-hex", + CONF_DEVICE_ENUM: "123", ++ CONF_STATUS_DEVICE_ID: "also-bad", ++ CONF_STATUS_ENUM: "x", + CONF_OPEN_TIME_SECONDS: 0, + CONF_CLOSE_TIME_SECONDS: -1, + } +@@ -115,71 +152,186 @@ async def test_manual_setup_validates_protocol_values() -> None: + CONF_DEVICE_NAME: "required", + CONF_DEVICE_ID: "invalid_device_id", + CONF_DEVICE_ENUM: "invalid_device_enum", ++ CONF_STATUS_DEVICE_ID: "invalid_device_id", ++ CONF_STATUS_ENUM: "invalid_device_enum", + CONF_OPEN_TIME_SECONDS: "invalid_travel_time", + CONF_CLOSE_TIME_SECONDS: "invalid_travel_time", + } + + + @pytest.mark.asyncio +-async def test_manual_setup_rejects_existing_device_id() -> None: +- """Test that manual setup cannot duplicate a blind subentry.""" ++async def test_short_motor_command_sends_open_then_stop() -> None: ++ """Test the hybrid command sequence and its diagnostic context.""" + flow = _create_flow() +- existing_subentry = MagicMock(data={CONF_DEVICE_ID: "3720B8"}) +- hub_entry = MagicMock(subentries=MappingProxyType({"existing": existing_subentry})) ++ flow._pending_device_id = "F2B8D5" ++ flow._pending_device_enum = "23" ++ api = MagicMock() ++ api.control_blind = AsyncMock(return_value=True) ++ hub_entry = MagicMock(runtime_data=api) + +- with patch.object(flow, "_get_entry", return_value=hub_entry): +- result = await flow.async_step_manual( ++ with ( ++ patch.object(flow, "_get_entry", return_value=hub_entry), ++ patch("custom_components.schellenberg_usb.config_flow.asyncio.sleep") as sleep, ++ ): ++ result = await flow.async_step_test_motor({}) ++ ++ assert result["step_id"] == "did_motor_move" ++ api.control_blind.assert_has_awaits( ++ [ ++ call("23", CMD_UP, device_id="F2B8D5"), ++ call("23", CMD_STOP, device_id="F2B8D5"), ++ ] ++ ) ++ sleep.assert_awaited_once_with(0.75) ++ ++ ++@pytest.mark.asyncio ++async def test_edit_updates_protocol_data_without_unique_id_change() -> None: ++ """Test editing updates subentry data without replacing the subentry.""" ++ flow = _create_flow() ++ subentry = MagicMock( ++ subentry_id="sub1", ++ title="Sitting room", ++ unique_id="F2B8D5", ++ data={ ++ CONF_DEVICE_ID: "F2B8D5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_OPEN_TIME: 25.06, ++ CONF_CLOSE_TIME: 23.05, ++ }, ++ ) ++ entry = MagicMock(subentries=MappingProxyType({"sub1": subentry})) ++ expected_result = {"type": FlowResultType.ABORT} ++ ++ with ( ++ patch.object(flow, "_get_entry", return_value=entry), ++ patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), ++ patch.object( ++ flow, "async_update_and_abort", return_value=expected_result ++ ) as update, ++ ): ++ result = await flow.async_step_edit( + { +- CONF_DEVICE_NAME: "Duplicate", +- CONF_DEVICE_ID: "3720b8", +- CONF_DEVICE_ENUM: "08", +- CONF_OPEN_TIME_SECONDS: 24, +- CONF_CLOSE_TIME_SECONDS: 22, ++ CONF_DEVICE_NAME: "Door", ++ CONF_DEVICE_ID: "f2b8d5", ++ CONF_DEVICE_ENUM: "13", ++ CONF_STATUS_DEVICE_ID: "3720b8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME_SECONDS: 25.06, ++ CONF_CLOSE_TIME_SECONDS: 23.05, ++ CONF_INVERT_DIRECTION: True, + } + ) + +- assert result["type"] is FlowResultType.FORM +- assert result["errors"] == {CONF_DEVICE_ID: "already_configured"} ++ assert result is expected_result ++ _, kwargs = update.call_args ++ assert kwargs["title"] == "Door" ++ assert kwargs["data"][CONF_COMMAND_DEVICE_ID] == "F2B8D5" ++ assert kwargs["data"][CONF_COMMAND_ENUM] == "13" ++ assert kwargs["data"][CONF_STATUS_DEVICE_ID] == "3720B8" ++ assert kwargs["data"][CONF_STATUS_ENUM] == "08" ++ assert kwargs["data"][CONF_INVERT_DIRECTION] is True ++ assert "unique_id" not in kwargs ++ ++ ++@pytest.mark.asyncio ++async def test_developer_tools_show_last_frame_and_send_selected_target() -> None: ++ """Test the native diagnostic view and its direct command actions.""" ++ flow = _create_flow() ++ subentry = MagicMock( ++ title="Sitting room door", ++ data={ ++ CONF_COMMAND_DEVICE_ID: "F2B8D5", ++ CONF_COMMAND_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720B8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME: 25.06, ++ CONF_CLOSE_TIME: 23.05, ++ CONF_INVERT_DIRECTION: True, ++ }, ++ ) ++ api = MagicMock() ++ api.get_last_received.return_value = { ++ "device_id": "3720B8", ++ "enum": "08", ++ "command": "01", ++ "time": "17:32:14", ++ } ++ api.control_blind = AsyncMock(return_value=True) ++ entry = MagicMock(runtime_data=api) ++ ++ with ( ++ patch.object(flow, "_get_entry", return_value=entry), ++ patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), ++ ): ++ result = await flow.async_step_developer_tools() ++ command_result = await flow.async_step_test_open() ++ copy_result = await flow.async_step_copy_diagnostics() ++ ++ placeholders = result["description_placeholders"] ++ assert placeholders is not None ++ assert placeholders["selected_blind"] == "Sitting room door" ++ assert placeholders["last_device_id"] == "3720B8" ++ assert placeholders["command_device_id"] == "F2B8D5" ++ assert placeholders["command_enum"] == "23" ++ api.control_blind.assert_awaited_once_with("23", CMD_DOWN, device_id="F2B8D5") ++ command_placeholders = command_result["description_placeholders"] ++ assert command_placeholders is not None ++ assert "queued successfully" in command_placeholders["result"] ++ schema = copy_result["data_schema"] ++ assert schema is not None ++ diagnostics = schema({})["diagnostics"] ++ assert "Selected blind: Sitting room door" in diagnostics ++ assert "Device ID: 3720B8" in diagnostics ++ assert "Device ID: F2B8D5" in diagnostics + + + @pytest.mark.asyncio +-async def test_pairing_persists_calibrated_travel_times() -> None: +- """Test that paired subentries retain measured times across restarts.""" ++async def test_pairing_persists_calibrated_protocol_data() -> None: ++ """Test paired subentries retain identities and measured times.""" + flow = MagicMock(spec=ConfigSubentryFlow) + handler = CalibrationFlowHandler(flow) + handler.set_selected_device( + { + "id": "3720B8", +- "name": "Living room", ++ "entity_id": "F2B8D5", ++ "name": "Sitting room", + "enum": "08", + } + ) + handler.enable_subentry_creation( +- device_id="3720B8", +- device_enum="08", +- device_name="Living room", ++ device_id="F2B8D5", ++ device_enum="23", ++ device_name="Sitting room", ++ status_device_id="3720B8", ++ status_enum="08", + ) +- handler._open_time = 24.567 +- handler._close_time = 22.345 ++ handler._open_time = 25.064 ++ handler._close_time = 23.054 + + with patch.object(handler, "_save_calibration_data", new=AsyncMock()): + await handler.async_step_calibration_complete({}) + + flow.async_create_entry.assert_called_once_with( +- title="Living room", ++ title="Sitting room", + data={ +- CONF_DEVICE_ID: "3720B8", +- CONF_DEVICE_ENUM: "08", +- CONF_OPEN_TIME: 24.57, +- CONF_CLOSE_TIME: 22.34, ++ CONF_DEVICE_ID: "F2B8D5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_COMMAND_DEVICE_ID: "F2B8D5", ++ CONF_COMMAND_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720B8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME: 25.06, ++ CONF_CLOSE_TIME: 23.05, ++ CONF_INVERT_DIRECTION: False, + }, +- unique_id="3720B8", ++ unique_id="F2B8D5", + ) + + + @pytest.mark.asyncio + async def test_reconfigure_persists_calibrated_travel_times() -> None: +- """Test that recalibration updates the existing blind subentry.""" ++ """Test recalibration updates the existing blind subentry.""" + flow = MagicMock(spec=ConfigSubentryFlow) + entry = MagicMock() + subentry = MagicMock() +@@ -189,11 +341,7 @@ async def test_reconfigure_persists_calibrated_travel_times() -> None: + flow.async_update_and_abort.return_value = expected_result + handler = CalibrationFlowHandler(flow) + handler.set_selected_device( +- { +- "id": "3720B8", +- "name": "Living room", +- "enum": "08", +- } ++ {"id": "3720B8", "entity_id": "F2B8D5", "name": "Door", "enum": "08"} + ) + handler._open_time = 25.678 + handler._close_time = 23.456 +diff --git a/tests/test_cover.py b/tests/test_cover.py +index 5bfd954..99d3f96 100644 +--- a/tests/test_cover.py ++++ b/tests/test_cover.py +@@ -235,7 +235,42 @@ async def test_cover_async_open_cover( + + assert cover._attr_is_opening is True + assert cover._attr_is_closing is False +- _async_mock(mock_api.control_blind).assert_called_once_with("01", "01") ++ _async_mock(mock_api.control_blind).assert_called_once_with( ++ "01", "01", device_id="ABC123" ++ ) ++ ++ ++@pytest.mark.asyncio ++async def test_cover_uses_split_identity_and_inverted_direction( ++ hass: HomeAssistant, ++ mock_api: SchellenbergUsbApi, ++) -> None: ++ """Test commands use command identity while status direction is inverted.""" ++ cover = SchellenbergCover( ++ api=mock_api, ++ device_id="STABLE1", ++ device_enum="23", ++ device_name="Sitting room", ++ command_device_id="F2B8D5", ++ status_device_id="3720B8", ++ status_enum="08", ++ invert_direction=True, ++ ) ++ cover.hass = hass ++ cover._attr_current_cover_position = 0 ++ ++ with ( ++ patch.object(cover, "_start_position_tracking"), ++ patch.object(cover, "async_write_ha_state"), ++ ): ++ await cover.async_open_cover() ++ cover._handle_event(EVENT_STARTED_MOVING_DOWN) ++ ++ _async_mock(mock_api.control_blind).assert_awaited_once_with( ++ "23", "02", device_id="F2B8D5" ++ ) ++ assert cover._attr_is_opening is True ++ assert cover._attr_is_closing is False + + + @pytest.mark.asyncio +@@ -259,7 +294,9 @@ async def test_cover_async_close_cover( + + assert cover._attr_is_opening is False + assert cover._attr_is_closing is True +- _async_mock(mock_api.control_blind).assert_called_once_with("01", "02") ++ _async_mock(mock_api.control_blind).assert_called_once_with( ++ "01", "02", device_id="ABC123" ++ ) + + + @pytest.mark.asyncio +@@ -284,7 +321,9 @@ async def test_cover_async_stop_cover( + + assert cover._attr_is_opening is False + assert cover._attr_is_closing is False +- _async_mock(mock_api.control_blind).assert_called_once_with("01", "00") ++ _async_mock(mock_api.control_blind).assert_called_once_with( ++ "01", "00", device_id="ABC123" ++ ) + + + @pytest.mark.asyncio +@@ -625,7 +664,13 @@ async def test_cover_registers_with_api( + with patch.object(cover, "async_write_ha_state"): + await cover.async_added_to_hass() + +- _magic_mock(mock_api.register_entity).assert_called_once_with("ABC123", "01") ++ _magic_mock(mock_api.register_entity).assert_called_once_with( ++ "ABC123", ++ "01", ++ "Test Cover", ++ command_device_id="ABC123", ++ command_enum="01", ++ ) + + + @pytest.mark.asyncio +diff --git a/tests/test_init.py b/tests/test_init.py +index 9f7b44f..a28d5a9 100644 +--- a/tests/test_init.py ++++ b/tests/test_init.py +@@ -12,9 +12,14 @@ from homeassistant.helpers import device_registry as dr + + from custom_components.schellenberg_usb.api import SchellenbergUsbApi + from custom_components.schellenberg_usb.const import ( ++ CMD_UP, ++ CONF_COMMAND, ++ CONF_DEVICE_ID, ++ CONF_ENUM, + CONF_SERIAL_PORT, + DOMAIN, + PLATFORMS, ++ SERVICE_TEST_COMMAND, + ) + + +@@ -62,6 +67,34 @@ async def test_async_setup_entry_basic( + assert isinstance(mock_config_entry.runtime_data, SchellenbergUsbApi) + + ++@pytest.mark.asyncio ++async def test_async_setup_registers_test_command_service( ++ hass: HomeAssistant, mock_config_entry: ConfigEntry ++) -> None: ++ """Test the diagnostic service validates and forwards a command.""" ++ from custom_components.schellenberg_usb import async_setup ++ ++ api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") ++ api.control_blind = AsyncMock(return_value=True) # type: ignore[method-assign] ++ mock_config_entry.runtime_data = api ++ ++ assert await async_setup(hass, {}) ++ await hass.services.async_call( ++ DOMAIN, ++ SERVICE_TEST_COMMAND, ++ { ++ CONF_DEVICE_ID: "f2b8d5", ++ CONF_ENUM: "23", ++ CONF_COMMAND: "open", ++ }, ++ blocking=True, ++ ) ++ ++ api.control_blind.assert_awaited_once_with( # type: ignore[attr-defined] ++ "23", CMD_UP, device_id="F2B8D5" ++ ) ++ ++ + @pytest.mark.asyncio + async def test_async_setup_entry_creates_hub_device( + hass: HomeAssistant, mock_config_entry: ConfigEntry diff --git a/schellenberg-usb-manual-and-diagnostics.patch b/schellenberg-usb-manual-and-diagnostics.patch new file mode 100644 index 0000000..4caf594 --- /dev/null +++ b/schellenberg-usb-manual-and-diagnostics.patch @@ -0,0 +1,3037 @@ +diff --git a/README.md b/README.md +index 5cbaf16..6cc6c4f 100644 +--- a/README.md ++++ b/README.md +@@ -15,7 +15,9 @@ Home Assistant component that interfaces with the [Schellenberg Usb Funk-Stick]( + ## Features + + * Supports blind movement Up, Down, and Stop +-* After calibation, position tracking is possible. ++* Tracks estimated position from measured or manually supplied travel times ++* Supports manual command/status identities when calibration cannot create a blind ++* Can test a paired command before calibration and edit a blind after creation + + ## Installation + +@@ -28,9 +30,20 @@ Home Assistant component that interfaces with the [Schellenberg Usb Funk-Stick]( + Make sure you have HACS installed. If you don't, run `wget -O - https://get.hacs.xyz | bash -` in HA. + Choose Integrations under HACS. Click the '+' button on the bottom of the page, search for "schellenberg usb", choose it, and click install in HACS. + ++ + #### Option 2: Manual + Clone this repository or download the source code as a zip file and add/merge the `custom_components/` folder with its contents in your configuration directory. + ++To test a local checkout on Home Assistant OS, use the Samba Share or Studio Code ++Server add-on and copy the repository's `custom_components/schellenberg_usb` ++directory to `/config/custom_components/schellenberg_usb`. The resulting directory ++must contain `manifest.json` directly. Replace the integration files, restart Home ++Assistant (a configuration reload is not sufficient), then check **Settings > System ++> Logs** for `schellenberg_usb` messages. ++ ++Back up an existing copy first. HACS may overwrite these test files during an update, ++so restore or reinstall the released version after testing if necessary. ++ + + ### Step 2: Restart HA + In order for the newly added integration to be loaded, HA needs to be restarted. +@@ -43,17 +56,54 @@ If the component is properly installed, you should be able to find 'Schellenberg + + Select it, and the schellenberg usb integration is ready for use. + +-### Step 4: Pair your devices ++### Step 4: Add your devices + + 1. In Home Assistant, go to **Settings > Devices & Services** + 2. Find the **Schellenberg USB** integration and click on it +-3. Click the **+** button or select **Pair device** from the menu +-4. Put your blind motor into pairing mode (see [Device Pairing Instructions](#device-pairing-instructions)) +-5. Once pairing is successful, provide a friendly name for your blind ++3. Click the **+** button or select **Add blind** from the menu ++4. Choose one of the setup methods: ++ - **Pair and test (recommended)** pairs the motor, sends Open for 0.75 seconds ++ followed by Stop, and asks whether it moved. A successful test can continue to ++ calibration or save with manual travel times. A failed test opens the detected ++ values for editing. ++ - **Pair and calibrate (legacy)** keeps the original guided setup unchanged. ++ - **Add manually** creates a cover from a name, command ID/enum, optional separate ++ status ID/enum, and measured open/close travel times. Calibration is optional. ++ ++ ++The command enum selects the paired slot in the USB stick and is inserted into the ++outgoing serial packet. The command device ID is retained for identification and ++diagnostic logging. The status ID and enum are matched exactly against incoming ++movement messages. ++ ++To edit an existing blind, open its configuration action from the Schellenberg USB ++integration and choose **Edit identities and travel times**. The same menu also ++offers a short motor command test and recalibration. Editing protocol values keeps ++the existing Home Assistant entity unique ID. ++Choose **Developer tools** in the same blind configuration menu to see the last ++frame matching its status identity, the current transmit target, direct Open, ++Close, and Stop actions, and a copyable diagnostics snapshot. ++ ++ ++### Diagnostic command service ++ ++Before creating or changing a blind, use **Developer Tools > Actions** and run ++`schellenberg_usb.test_command`: ++ ++```yaml ++device_id: F2B8D5 ++enum: "23" ++command: open ++``` ++ ++Valid commands are `open`, `close`, and `stop`. Open and close are direct commands, ++so send `stop` yourself when performing a short test. If more than one Schellenberg ++USB hub is loaded, also supply its `config_entry_id`. Sending logs the command, ++device ID, enum, raw serial payload, and whether it was queued successfully. + + ### Step 5: Calibrate your blinds + +-Calibration is essential for accurate position tracking. The integration measures how long it takes your blind to fully open and close, allowing it to calculate the current position during operation. ++Calibration is recommended for accurate position tracking. The integration measures how long it takes your blind to fully open and close, allowing it to calculate the current position during operation. It is optional when the travel times were supplied during manual setup. + + > [!IMPORTANT] + > This calibration is **not** the same as setting the end positions (fully open/closed limits) on your blind motor. End positions must be configured directly on the device itself using the motor's built-in adjustment features or a Schellenberg remote control before using this integration. +diff --git a/custom_components/schellenberg_usb/__init__.py b/custom_components/schellenberg_usb/__init__.py +index e223568..0549a63 100644 +--- a/custom_components/schellenberg_usb/__init__.py ++++ b/custom_components/schellenberg_usb/__init__.py +@@ -7,15 +7,24 @@ from types import MappingProxyType + + import voluptuous as vol + from homeassistant.config_entries import ConfigSubentry +-from homeassistant.core import HomeAssistant ++from homeassistant.core import HomeAssistant, ServiceCall ++from homeassistant.exceptions import ServiceValidationError + from homeassistant.helpers import config_validation as cv + from homeassistant.helpers import device_registry as dr + + from .api import SchellenbergUsbApi + from .const import ( ++ CMD_DOWN, ++ CMD_STOP, ++ CMD_UP, ++ CONF_COMMAND, ++ CONF_CONFIG_ENTRY_ID, ++ CONF_DEVICE_ID, ++ CONF_ENUM, + CONF_SERIAL_PORT, + DOMAIN, + PLATFORMS, ++ SERVICE_TEST_COMMAND, + SUBENTRY_TYPE_HUB, + SchellenbergConfigEntry, + ) +@@ -31,6 +40,89 @@ CONFIG_SCHEMA = vol.Schema( + _SETUP_CALLBACKS: dict[str, dict] = {} + + ++def _validate_device_id(value: str) -> str: ++ """Validate and normalize a six-character protocol device ID.""" ++ normalized = cv.string(value).strip().upper() ++ if len(normalized) != 6 or any( ++ character not in "0123456789ABCDEF" for character in normalized ++ ): ++ raise vol.Invalid("device ID must be six hexadecimal characters") ++ return normalized ++ ++ ++def _validate_device_enum(value: str) -> str: ++ """Validate and normalize a two-character protocol enum.""" ++ normalized = cv.string(value).strip().upper() ++ if len(normalized) != 2 or any( ++ character not in "0123456789ABCDEF" for character in normalized ++ ): ++ raise vol.Invalid("enum must be two hexadecimal characters") ++ return normalized ++ ++ ++TEST_COMMAND_SCHEMA = vol.Schema( ++ { ++ vol.Required(CONF_DEVICE_ID): _validate_device_id, ++ vol.Required(CONF_ENUM): _validate_device_enum, ++ vol.Required(CONF_COMMAND): vol.In({"open", "close", "stop"}), ++ vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, ++ } ++) ++ ++ ++async def async_setup(hass: HomeAssistant, config: dict) -> bool: ++ """Set up integration-level diagnostic services.""" ++ ++ async def _handle_test_command(call: ServiceCall) -> None: ++ requested_entry_id = call.data.get(CONF_CONFIG_ENTRY_ID) ++ loaded_entries: list[tuple[SchellenbergConfigEntry, SchellenbergUsbApi]] = [] ++ for candidate in hass.config_entries.async_entries(DOMAIN): ++ api = getattr(candidate, "runtime_data", None) ++ if isinstance(api, SchellenbergUsbApi): ++ loaded_entries.append((candidate, api)) ++ ++ if requested_entry_id: ++ api = next( ++ ( ++ candidate_api ++ for candidate, candidate_api in loaded_entries ++ if candidate.entry_id == requested_entry_id ++ ), ++ None, ++ ) ++ if api is None: ++ raise ServiceValidationError( ++ f"No loaded Schellenberg USB entry {requested_entry_id}" ++ ) ++ elif len(loaded_entries) == 1: ++ api = loaded_entries[0][1] ++ else: ++ raise ServiceValidationError( ++ "Exactly one Schellenberg USB hub must be loaded, or config_entry_id " ++ "must be supplied" ++ ) ++ ++ action = { ++ "open": CMD_UP, ++ "close": CMD_DOWN, ++ "stop": CMD_STOP, ++ }[call.data[CONF_COMMAND]] ++ if not await api.control_blind( ++ call.data[CONF_ENUM], ++ action, ++ device_id=call.data[CONF_DEVICE_ID], ++ ): ++ raise ServiceValidationError("The serial command could not be queued") ++ ++ hass.services.async_register( ++ DOMAIN, ++ SERVICE_TEST_COMMAND, ++ _handle_test_command, ++ schema=TEST_COMMAND_SCHEMA, ++ ) ++ return True ++ ++ + async def async_setup_entry( + hass: HomeAssistant, entry: SchellenbergConfigEntry + ) -> bool: +@@ -107,14 +199,17 @@ async def async_setup_entry( + # Forward setup to the hub's platforms (cover, sensor, switch) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + +- # Add listener to reload entry when subentries are added ++ # Reload when subentries are added, removed, renamed, or edited. + async def _on_entry_updated( + hass_instance: HomeAssistant, updated_entry: SchellenbergConfigEntry + ) -> None: +- """Handle updates to the hub entry (including subentry additions).""" +- current_subentries = set(updated_entry.subentries.keys()) ++ """Handle changes to the hub's blind subentries.""" ++ current_subentries = { ++ subentry_id: (subentry.title, dict(subentry.data)) ++ for subentry_id, subentry in updated_entry.subentries.items() ++ } + known_subentries = _SETUP_CALLBACKS.get(entry.entry_id, {}).get( +- "subentry_ids", set() ++ "subentries", {} + ) + + _LOGGER.debug( +@@ -125,13 +220,9 @@ async def async_setup_entry( + + if current_subentries != known_subentries: + _LOGGER.info( +- "Subentries changed, reloading entry. Old: %s, New: %s", +- known_subentries, +- current_subentries, ++ "Subentry configuration changed; reloading entry %s", entry.entry_id + ) +- # Update tracked subentries before reloading +- _SETUP_CALLBACKS[entry.entry_id]["subentry_ids"] = current_subentries +- # Reload the entire entry to re-setup all platforms with new subentries ++ _SETUP_CALLBACKS[entry.entry_id]["subentries"] = current_subentries + await hass_instance.config_entries.async_reload(entry.entry_id) + + entry.add_update_listener(_on_entry_updated) +@@ -139,7 +230,10 @@ async def async_setup_entry( + # Track known subentries + if entry.entry_id not in _SETUP_CALLBACKS: + _SETUP_CALLBACKS[entry.entry_id] = {} +- _SETUP_CALLBACKS[entry.entry_id]["subentry_ids"] = set(entry.subentries.keys()) ++ _SETUP_CALLBACKS[entry.entry_id]["subentries"] = { ++ subentry_id: (subentry.title, dict(subentry.data)) ++ for subentry_id, subentry in entry.subentries.items() ++ } + + return True + +diff --git a/custom_components/schellenberg_usb/api.py b/custom_components/schellenberg_usb/api.py +index 7aceb85..f760b18 100644 +--- a/custom_components/schellenberg_usb/api.py ++++ b/custom_components/schellenberg_usb/api.py +@@ -5,10 +5,12 @@ from __future__ import annotations + import asyncio + import logging + from collections.abc import Callable ++from typing import Any + + import serial_asyncio + from homeassistant.core import HomeAssistant, callback + from homeassistant.helpers.dispatcher import async_dispatcher_send ++from homeassistant.util import dt as dt_util + + from .const import ( + CMD_ALLOW_PAIRING, +@@ -40,6 +42,10 @@ from .const import ( + CMD_TRANSMIT, + CMD_UP, + CMD_VERIFY, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + PAIRING_DEVICE_ENUM_START, + PAIRING_TIMEOUT, + SIGNAL_DEVICE_EVENT, +@@ -62,6 +68,9 @@ class SchellenbergUsbApi: + self._registered_devices: dict[ + str, str + ] = {} # Dict[device_id, device_enum] for registered entities ++ self._registered_entity_keys: dict[tuple[str, str], str] = {} ++ # Exact (status_device_id, status_enum) -> entity name for event matching. ++ self._last_received_messages: dict[tuple[str, str], dict[str, str]] = {} + self._is_connecting = False + self._pairing_future: asyncio.Future[str] | None = None + self._stop_pairing_task: asyncio.Task[None] | None = ( +@@ -266,10 +275,27 @@ class SchellenbergUsbApi: + _LOGGER.debug( + "Parsed: enum=%s, id=%s, cmd=%s", device_enum, device_id, command + ) ++ normalized_device_id = device_id.upper() ++ normalized_device_enum = device_enum.upper() ++ self._last_received_messages[ ++ (normalized_device_id, normalized_device_enum) ++ ] = { ++ "device_id": normalized_device_id, ++ "enum": normalized_device_enum, ++ "command": command.upper(), ++ "time": dt_util.now().strftime("%H:%M:%S"), ++ } + + # If we're in pairing mode and this is a new device + if self._pairing_future and not self._pairing_future.done(): +- if device_id not in self._registered_devices: ++ known_status_id = any( ++ key[0] == device_id.upper() ++ for key in self._registered_entity_keys ++ ) ++ if ( ++ device_id not in self._registered_devices ++ and not known_status_id ++ ): + _LOGGER.info("Pairing successful! New device ID: %s", device_id) + self._pairing_future.set_result(device_id) + # Stop pairing mode after a 2 second delay to ensure device has fully paired +@@ -282,45 +308,60 @@ class SchellenbergUsbApi: + # Don't send dispatcher signal here - let the caller handle persistence + return + +- # If this is the first time we see this device (auto-discovery mode) +- if device_id not in self._registered_devices: ++ entity_name = self._registered_entity_keys.get( ++ (normalized_device_id, normalized_device_enum) ++ ) ++ if entity_name is None: + _LOGGER.warning( +- "Received message for device %s (enum=%s, cmd=%s) but no " +- "corresponding entity found. The device may need to be added " +- "to Home Assistant", ++ "Received device_id=%s enum=%s cmd=%s matched=False; no " ++ "cover has this status identity", + device_id, + device_enum, + command, + ) + else: +- # The entity will handle the event via the dispatcher + _LOGGER.debug( +- "Forwarding event to device %s (enum=%s): command=%s", ++ "Received device_id=%s enum=%s cmd=%s matched=True entity=%s", + device_id, + device_enum, + command, ++ entity_name, + ) + +- # Forward the event to the correct entity (if it exists) ++ # Retain the ID-only signal for calibration before an entity exists. + async_dispatcher_send( +- self.hass, f"{SIGNAL_DEVICE_EVENT}_{device_id}", command ++ self.hass, ++ f"{SIGNAL_DEVICE_EVENT}_{normalized_device_id}", ++ command, + ) ++ if entity_name is not None: ++ async_dispatcher_send( ++ self.hass, ++ f"{SIGNAL_DEVICE_EVENT}_{normalized_device_id}_" ++ f"{normalized_device_enum}", ++ command, ++ ) + except (IndexError, ValueError) as err: + _LOGGER.debug("Failed to parse message %s: %s", message, err) + +- async def send_command(self, command: str) -> None: +- """Send a command to the USB stick.""" ++ async def send_command(self, command: str) -> bool: ++ """Queue a raw command on the serial transport.""" + if self._transport is None or self._transport.is_closing(): + _LOGGER.warning("Serial port not connected. Command dropped: %s", command) +- return ++ return False + + # Store command for potential retry on "stick busy" error + self._pending_retry_command = command + + full_command = f"{command}\r\n".encode("ascii") + _LOGGER.debug("Sending to serial device: %s", full_command.strip()) +- self._transport.write(full_command) ++ try: ++ self._transport.write(full_command) ++ except (OSError, RuntimeError): ++ _LOGGER.exception("Serial transmit failed for payload=%s", command) ++ return False + _LOGGER.debug("Command sent to serial device: %s", full_command.strip()) ++ return True + + async def _retry_command_after_delay(self) -> None: + """Retry sending the pending command after a 100ms delay.""" +@@ -411,23 +452,42 @@ class SchellenbergUsbApi: + except OSError as err: + _LOGGER.debug("Error stopping pairing mode (communication error): %s", err) + +- async def control_blind(self, device_enum: str, action: str) -> None: ++ async def control_blind( ++ self, ++ device_enum: str, ++ action: str, ++ *, ++ device_id: str | None = None, ++ ) -> bool: + """Send a control command to a specific blind. + + Args: + device_enum: The device enumerator (hex string like "10") + action: Command (CMD_UP, CMD_DOWN, CMD_STOP) ++ device_id: Optional command device ID for diagnostic logging + + """ + if action not in (CMD_UP, CMD_DOWN, CMD_STOP): + _LOGGER.error("Invalid blind action: %s", action) +- return ++ return False + + # Format: ssXX9AAZZZ + # XX = device enum, 9 = number of messages, AA = command, ZZZ = padding +- command = f"{CMD_TRANSMIT}{device_enum}9{action}0000" +- _LOGGER.debug("Sending blind control: %s", command) +- await self.send_command(command) ++ raw_payload = f"{CMD_TRANSMIT}{device_enum.upper()}9{action}0000" ++ action_name = {CMD_UP: "open", CMD_DOWN: "close", CMD_STOP: "stop"}[action] ++ _LOGGER.info( ++ "Sending blind command=%s device_id=%s enum=%s payload=%s", ++ action_name, ++ device_id or "unknown", ++ device_enum, ++ raw_payload, ++ ) ++ sent = await self.send_command(raw_payload) ++ if sent: ++ _LOGGER.info("Serial transmit queued successfully: %s", raw_payload) ++ else: ++ _LOGGER.error("Serial transmit failed: %s", raw_payload) ++ return sent + + def initialize_next_device_enum(self) -> str: + """Get the next available device enum based on registered devices. +@@ -470,20 +530,46 @@ class SchellenbergUsbApi: + ) + return result + +- def register_existing_devices(self, devices: list[dict]) -> None: ++ def register_existing_devices(self, devices: list[dict[str, Any]]) -> None: + """Register existing devices from storage. + + Args: + devices: List of device dicts with 'id' and 'enum' keys + """ + for device in devices: +- device_id = device.get("id") +- device_enum = device.get("enum") +- if device_id and device_enum: +- self._registered_devices[device_id] = device_enum +- _LOGGER.debug( +- "Registered existing device %s with enum %s", device_id, device_enum +- ) ++ command_device_id = ( ++ device.get(CONF_COMMAND_DEVICE_ID) ++ or device.get("id") ++ or device.get(CONF_STATUS_DEVICE_ID) ++ ) ++ command_enum = ( ++ device.get(CONF_COMMAND_ENUM) ++ or device.get("enum") ++ or device.get(CONF_STATUS_ENUM) ++ ) ++ status_device_id = ( ++ device.get(CONF_STATUS_DEVICE_ID) ++ or device.get("id") ++ or command_device_id ++ ) ++ status_enum = ( ++ device.get(CONF_STATUS_ENUM) or device.get("enum") or command_enum ++ ) ++ entity_name = str(device.get("name") or status_device_id) ++ if command_device_id and command_enum: ++ self._registered_devices[str(command_device_id)] = str(command_enum) ++ if status_device_id and status_enum: ++ self._registered_entity_keys[ ++ (str(status_device_id).upper(), str(status_enum).upper()) ++ ] = entity_name ++ _LOGGER.debug( ++ "Registered existing entity=%s command=%s/%s status=%s/%s", ++ entity_name, ++ command_device_id, ++ command_enum, ++ status_device_id, ++ status_enum, ++ ) + + def remove_known_device(self, device_id: str) -> None: + """Remove a device from the registered entities. +@@ -491,14 +577,48 @@ class SchellenbergUsbApi: + After removal, messages from this device will be treated as unknown. + """ + self._registered_devices.pop(device_id, None) ++ normalized_id = device_id.upper() ++ self._registered_entity_keys = { ++ key: name ++ for key, name in self._registered_entity_keys.items() ++ if key[0] != normalized_id ++ } + _LOGGER.debug("Removed device %s from registered entities", device_id) + +- def register_entity(self, device_id: str, device_enum: str) -> None: +- """Register that an entity exists for this device ID with its enum.""" +- self._registered_devices[device_id] = device_enum ++ def register_entity( ++ self, ++ status_device_id: str, ++ status_enum: str, ++ entity_name: str | None = None, ++ *, ++ command_device_id: str | None = None, ++ command_enum: str | None = None, ++ ) -> None: ++ """Register command and exact incoming status identities for an entity.""" ++ command_id = command_device_id or status_device_id ++ command_slot = command_enum or status_enum ++ self._registered_devices[command_id] = command_slot ++ self._registered_entity_keys[ ++ (status_device_id.upper(), status_enum.upper()) ++ ] = entity_name or status_device_id + _LOGGER.debug( +- "Registered entity for device %s with enum %s", device_id, device_enum ++ "Registered entity=%s command=%s/%s status=%s/%s", ++ entity_name or status_device_id, ++ command_id, ++ command_slot, ++ status_device_id, ++ status_enum, ++ ) ++ ++ @callback ++ def get_last_received( ++ self, status_device_id: str, status_enum: str ++ ) -> dict[str, str] | None: ++ """Return a copy of the latest frame for an exact status identity.""" ++ message = self._last_received_messages.get( ++ (status_device_id.upper(), status_enum.upper()) + ) ++ return dict(message) if message is not None else None + + async def verify_device(self) -> bool: + """Verify this is a Schellenberg USB stick by sending !? command. +diff --git a/custom_components/schellenberg_usb/config_flow.py b/custom_components/schellenberg_usb/config_flow.py +index ab21588..7d671af 100644 +--- a/custom_components/schellenberg_usb/config_flow.py ++++ b/custom_components/schellenberg_usb/config_flow.py +@@ -2,6 +2,7 @@ + + from __future__ import annotations + ++import asyncio + import logging + from typing import Any, Awaitable, cast + +@@ -18,11 +19,25 @@ from homeassistant.helpers import selector + from homeassistant.helpers.service_info.usb import UsbServiceInfo + + from .const import ( ++ CMD_DOWN, ++ CMD_STOP, ++ CMD_UP, + CONF_CLOSE_TIME, ++ CONF_CLOSE_TIME_SECONDS, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, ++ CONF_DEVICE_ENUM, ++ CONF_DEVICE_ID, ++ CONF_DEVICE_NAME, ++ CONF_INVERT_DIRECTION, + CONF_OPEN_TIME, ++ CONF_OPEN_TIME_SECONDS, + CONF_SERIAL_PORT, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + DOMAIN, + SUBENTRY_TYPE_BLIND, ++ TEST_COMMAND_DELAY, + ) + from .options_flow import SchellenbergOptionsFlowHandler + from .options_flow_calibration import CalibrationFlowHandler +@@ -201,6 +216,13 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + self._pending_device_id: str | None = None + self._pending_device_enum: str | None = None + self._pending_device_name: str | None = None ++ self._pending_status_device_id: str | None = None ++ self._pending_status_enum: str | None = None ++ self._pending_open_time: float | None = None ++ self._pending_close_time: float | None = None ++ self._pending_invert_direction = False ++ self._pairing_workflow = "legacy" ++ self._developer_notice = "No test command sent in this session." + + def _get_calibration_handler(self) -> CalibrationFlowHandler: + """Return (and lazily create) the calibration flow handler.""" +@@ -218,7 +240,7 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + async def async_step_blind( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: +- """Entry point when the user clicks the 'Pair device' button. ++ """Entry point when the user clicks the 'Add blind' button. + + Home Assistant calls async_step_{subentry_type}() where subentry_type is + the key returned by async_get_supported_subentry_types. Since our type is +@@ -226,17 +248,39 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + async_step_pairing, which caused the flow to fall back and the + translation key for the initiate button to be missing. + """ +- _LOGGER.debug("Subentry blind flow initiated (pairing new device)") ++ _LOGGER.debug("Subentry blind flow initiated") + return await self.async_step_user(user_input) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: +- """Handle pairing initialization.""" ++ """Choose between pairing/calibration and manual setup.""" ++ return self.async_show_menu( ++ step_id="user", menu_options=["pair_test", "pair_device", "manual"] ++ ) ++ ++ async def async_step_pair_device( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Run the original pair-then-calibrate workflow.""" ++ self._pairing_workflow = "legacy" ++ return await self._async_pair_device("pair_device", user_input) ++ ++ async def async_step_pair_test( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Pair a blind and verify outgoing control before calibration.""" ++ self._pairing_workflow = "hybrid" ++ return await self._async_pair_device("pair_test", user_input) ++ ++ async def _async_pair_device( ++ self, step_id: str, user_input: dict[str, Any] | None ++ ) -> SubentryFlowResult: ++ """Pair a device for either supported pairing workflow.""" + _LOGGER.debug("Pairing step user input: %s", user_input) + if user_input is None: + _LOGGER.info("Showing pairing form") +- return self.async_show_form(step_id="user", data_schema=vol.Schema({})) ++ return self.async_show_form(step_id=step_id, data_schema=vol.Schema({})) + + # Get the hub entry (parent config entry) + hub_entry = self._get_entry() +@@ -253,9 +297,310 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + device_id, device_enum = pairing_result + self._pending_device_id = device_id + self._pending_device_enum = device_enum ++ self._pending_status_device_id = device_id ++ self._pending_status_enum = device_enum + self._pending_device_name = None + return await self.async_step_name_device() + ++ async def async_step_manual( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Collect manual command/status identities and travel times.""" ++ errors: dict[str, str] = {} ++ ++ if user_input is not None: ++ device_name = str(user_input[CONF_DEVICE_NAME]).strip() ++ command_device_id = str(user_input[CONF_DEVICE_ID]).strip().upper() ++ command_enum = str(user_input[CONF_DEVICE_ENUM]).strip().upper() ++ status_device_id = ( ++ str(user_input.get(CONF_STATUS_DEVICE_ID, "")).strip().upper() ++ or command_device_id ++ ) ++ status_enum = ( ++ str(user_input.get(CONF_STATUS_ENUM, "")).strip().upper() ++ or command_enum ++ ) ++ open_time = float(user_input[CONF_OPEN_TIME_SECONDS]) ++ close_time = float(user_input[CONF_CLOSE_TIME_SECONDS]) ++ ++ if not device_name: ++ errors[CONF_DEVICE_NAME] = "required" ++ if not self._is_hex_value(command_device_id, 6): ++ errors[CONF_DEVICE_ID] = "invalid_device_id" ++ if not self._is_hex_value(command_enum, 2): ++ errors[CONF_DEVICE_ENUM] = "invalid_device_enum" ++ if not self._is_hex_value(status_device_id, 6): ++ errors[CONF_STATUS_DEVICE_ID] = "invalid_device_id" ++ if not self._is_hex_value(status_enum, 2): ++ errors[CONF_STATUS_ENUM] = "invalid_device_enum" ++ if open_time <= 0: ++ errors[CONF_OPEN_TIME_SECONDS] = "invalid_travel_time" ++ if close_time <= 0: ++ errors[CONF_CLOSE_TIME_SECONDS] = "invalid_travel_time" ++ ++ hub_entry = self._get_entry() ++ if any( ++ str( ++ subentry.data.get(CONF_COMMAND_DEVICE_ID) ++ or subentry.data.get(CONF_DEVICE_ID, "") ++ ).upper() ++ == command_device_id ++ for subentry in hub_entry.subentries.values() ++ ): ++ errors[CONF_DEVICE_ID] = "already_configured" ++ ++ if not errors: ++ self._pending_device_name = device_name ++ self._pending_device_id = command_device_id ++ self._pending_device_enum = command_enum ++ self._pending_status_device_id = status_device_id ++ self._pending_status_enum = status_enum ++ self._pending_open_time = open_time ++ self._pending_close_time = close_time ++ self._pending_invert_direction = bool( ++ user_input.get(CONF_INVERT_DIRECTION, False) ++ ) ++ self._pairing_workflow = "manual" ++ return await self.async_step_manual_next() ++ ++ return self.async_show_form( ++ step_id="manual", ++ data_schema=self._manual_schema(), ++ errors=errors, ++ ) ++ ++ @staticmethod ++ def _is_hex_value(value: str, length: int) -> bool: ++ """Return whether value is an exact-length hexadecimal string.""" ++ return len(value) == length and all( ++ character in "0123456789ABCDEF" for character in value ++ ) ++ ++ def _manual_schema(self) -> vol.Schema: ++ """Build the manual form with pending values as defaults.""" ++ open_time_key = ( ++ vol.Required( ++ CONF_OPEN_TIME_SECONDS, ++ default=self._pending_open_time, ++ ) ++ if self._pending_open_time is not None ++ else vol.Required(CONF_OPEN_TIME_SECONDS) ++ ) ++ close_time_key = ( ++ vol.Required( ++ CONF_CLOSE_TIME_SECONDS, ++ default=self._pending_close_time, ++ ) ++ if self._pending_close_time is not None ++ else vol.Required(CONF_CLOSE_TIME_SECONDS) ++ ) ++ return vol.Schema( ++ { ++ vol.Required( ++ CONF_DEVICE_NAME, ++ default=self._pending_device_name or "", ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_DEVICE_ID, ++ default=self._pending_device_id or "", ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_DEVICE_ENUM, ++ default=self._pending_device_enum or "", ++ ): selector.TextSelector(), ++ vol.Optional( ++ CONF_STATUS_DEVICE_ID, ++ default=self._pending_status_device_id or "", ++ ): selector.TextSelector(), ++ vol.Optional( ++ CONF_STATUS_ENUM, ++ default=self._pending_status_enum or "", ++ ): selector.TextSelector(), ++ open_time_key: selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ close_time_key: selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ vol.Optional( ++ CONF_INVERT_DIRECTION, ++ default=self._pending_invert_direction, ++ ): selector.BooleanSelector(), ++ } ++ ) ++ ++ def _pending_data(self) -> dict[str, Any]: ++ """Return config-subentry data for the pending blind.""" ++ assert self._pending_device_id is not None ++ assert self._pending_device_enum is not None ++ assert self._pending_status_device_id is not None ++ assert self._pending_status_enum is not None ++ assert self._pending_open_time is not None ++ assert self._pending_close_time is not None ++ return { ++ # Legacy keys stay populated for backward compatibility. ++ CONF_DEVICE_ID: self._pending_device_id, ++ CONF_DEVICE_ENUM: self._pending_device_enum, ++ CONF_COMMAND_DEVICE_ID: self._pending_device_id, ++ CONF_COMMAND_ENUM: self._pending_device_enum, ++ CONF_STATUS_DEVICE_ID: self._pending_status_device_id, ++ CONF_STATUS_ENUM: self._pending_status_enum, ++ CONF_OPEN_TIME: self._pending_open_time, ++ CONF_CLOSE_TIME: self._pending_close_time, ++ CONF_INVERT_DIRECTION: self._pending_invert_direction, ++ } ++ ++ async def async_step_manual_next( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Offer command testing before saving a manually configured blind.""" ++ return self.async_show_menu( ++ step_id="manual_next", menu_options=["test_motor", "save_manual"] ++ ) ++ ++ async def async_step_save_manual( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Save the pending blind without calibration.""" ++ if ( ++ not self._pending_device_name ++ or not self._pending_device_id ++ or self._pending_open_time is None ++ or self._pending_close_time is None ++ ): ++ return self.async_abort(reason="device_not_found") ++ return self.async_create_entry( ++ title=self._pending_device_name, ++ data=self._pending_data(), ++ unique_id=self._pending_device_id, ++ ) ++ ++ async def async_step_test_motor( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Send a short logical-open command followed by stop.""" ++ if not self._pending_device_id or not self._pending_device_enum: ++ return self.async_abort(reason="device_not_found") ++ ++ placeholders = { ++ "device_id": self._pending_device_id, ++ "device_enum": self._pending_device_enum, ++ } ++ if user_input is None: ++ return self.async_show_form( ++ step_id="test_motor", ++ data_schema=vol.Schema({}), ++ description_placeholders=placeholders, ++ ) ++ ++ api = self._get_entry().runtime_data ++ action = CMD_DOWN if self._pending_invert_direction else CMD_UP ++ if not await api.control_blind( ++ self._pending_device_enum, ++ action, ++ device_id=self._pending_device_id, ++ ): ++ return self.async_show_form( ++ step_id="test_motor", ++ data_schema=vol.Schema({}), ++ description_placeholders=placeholders, ++ errors={"base": "command_failed"}, ++ ) ++ try: ++ await asyncio.sleep(TEST_COMMAND_DELAY) ++ finally: ++ stopped = await api.control_blind( ++ self._pending_device_enum, ++ CMD_STOP, ++ device_id=self._pending_device_id, ++ ) ++ if not stopped: ++ return self.async_show_form( ++ step_id="test_motor", ++ data_schema=vol.Schema({}), ++ description_placeholders=placeholders, ++ errors={"base": "command_failed"}, ++ ) ++ return await self.async_step_did_motor_move() ++ ++ async def async_step_did_motor_move( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Ask whether the short command moved the motor.""" ++ if user_input is None: ++ return self.async_show_form( ++ step_id="did_motor_move", ++ data_schema=vol.Schema( ++ { ++ vol.Required("motor_moved", default=True): ( ++ selector.BooleanSelector() ++ ) ++ } ++ ), ++ description_placeholders={ ++ "device_id": self._pending_device_id or "unknown", ++ "device_enum": self._pending_device_enum or "unknown", ++ }, ++ ) ++ ++ if not user_input["motor_moved"]: ++ if self._pairing_workflow == "existing": ++ return await self.async_step_edit() ++ # Reuse the manual form with the detected/current values prefilled. ++ return await self.async_step_manual() ++ ++ if self._pairing_workflow == "existing": ++ return self.async_abort(reason="command_test_successful") ++ if self._pairing_workflow == "hybrid": ++ return self.async_show_menu( ++ step_id="test_success", ++ menu_options=["calibration_close", "manual_times"], ++ ) ++ return await self.async_step_save_manual() ++ ++ async def async_step_manual_times( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Collect travel times after a successful paired command test.""" ++ if user_input is not None: ++ self._pending_open_time = float(user_input[CONF_OPEN_TIME_SECONDS]) ++ self._pending_close_time = float(user_input[CONF_CLOSE_TIME_SECONDS]) ++ return await self.async_step_save_manual() ++ ++ return self.async_show_form( ++ step_id="manual_times", ++ data_schema=vol.Schema( ++ { ++ vol.Required(CONF_OPEN_TIME_SECONDS): selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ vol.Required(CONF_CLOSE_TIME_SECONDS): selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ } ++ ), ++ ) ++ + async def async_step_name_device( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: +@@ -280,7 +625,7 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + }, + ) + +- # User provided a name – begin calibration prior to creating subentry ++ # User provided a name; configure calibration state for either workflow. + if not device_id or not device_enum: + return self.async_abort(reason="pairing_failed") + +@@ -292,16 +637,23 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + # Provide minimal device to handler + handler.set_selected_device( + { +- "id": device_id, ++ "id": self._pending_status_device_id or device_id, ++ "entity_id": device_id, + "name": device_name, +- "enum": device_enum, ++ "enum": self._pending_status_enum or device_enum, + } + ) + handler.enable_subentry_creation( + device_id=device_id, + device_enum=device_enum, + device_name=device_name, ++ status_device_id=self._pending_status_device_id or device_id, ++ status_enum=self._pending_status_enum or device_enum, ++ invert_direction=self._pending_invert_direction, + ) ++ if self._pairing_workflow == "hybrid": ++ return await self.async_step_test_motor() ++ + _LOGGER.debug( + "Starting calibration for paired device %s (%s) before creating subentry", + device_id, +@@ -314,30 +666,358 @@ class SchellenbergPairingSubentryFlow(ConfigSubentryFlow): + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: +- """Configure a blind: run calibration for the single device under this subentry. ++ """Choose whether to edit settings or recalibrate a blind.""" ++ return self.async_show_menu( ++ step_id="reconfigure", ++ menu_options=["edit", "test_existing", "developer_tools", "calibrate"], ++ ) + +- We bypass storage lookup and set the calibration handler's selected device +- directly from the subentry data to avoid device_not_found errors before +- calibration has ever run. +- """ ++ def _developer_details(self) -> dict[str, Any]: ++ """Return normalized protocol details for the selected blind.""" ++ subentry = self._get_reconfigure_subentry() ++ data = subentry.data ++ command_device_id = str( ++ data.get(CONF_COMMAND_DEVICE_ID) or data.get(CONF_DEVICE_ID, "") ++ ).upper() ++ command_enum = str( ++ data.get(CONF_COMMAND_ENUM) or data.get(CONF_DEVICE_ENUM, "") ++ ).upper() ++ return { ++ "name": subentry.title, ++ "command_device_id": command_device_id, ++ "command_enum": command_enum, ++ "status_device_id": str( ++ data.get(CONF_STATUS_DEVICE_ID) or command_device_id ++ ).upper(), ++ "status_enum": str(data.get(CONF_STATUS_ENUM) or command_enum).upper(), ++ "invert_direction": bool(data.get(CONF_INVERT_DIRECTION, False)), ++ "open_time": float(data.get(CONF_OPEN_TIME, 60.0)), ++ "close_time": float(data.get(CONF_CLOSE_TIME, 60.0)), ++ } ++ ++ def _developer_snapshot( ++ self, ++ ) -> tuple[dict[str, Any], dict[str, str]]: ++ """Return current blind details and its latest received frame.""" ++ details = self._developer_details() ++ api = self._get_entry().runtime_data ++ last_received = api.get_last_received( ++ details["status_device_id"], details["status_enum"] ++ ) or { ++ "device_id": "No matching frame received", ++ "enum": "--", ++ "command": "--", ++ "time": "--", ++ } ++ return details, last_received ++ ++ async def async_step_developer_tools( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Show live protocol diagnostics and direct test actions.""" ++ details, last_received = self._developer_snapshot() ++ return self.async_show_menu( ++ step_id="developer_tools", ++ menu_options=[ ++ "test_open", ++ "test_close", ++ "test_stop", ++ "copy_diagnostics", ++ ], ++ description_placeholders={ ++ "selected_blind": str(details["name"]), ++ "last_device_id": last_received["device_id"], ++ "last_enum": last_received["enum"], ++ "last_command": last_received["command"], ++ "last_time": last_received["time"], ++ "command_device_id": details["command_device_id"], ++ "command_enum": details["command_enum"], ++ "result": self._developer_notice, ++ }, ++ ) ++ ++ async def _async_developer_command(self, command: str) -> SubentryFlowResult: ++ """Send one logical command from the developer tools menu.""" ++ details = self._developer_details() ++ invert_direction = details["invert_direction"] ++ action = { ++ "open": CMD_DOWN if invert_direction else CMD_UP, ++ "close": CMD_UP if invert_direction else CMD_DOWN, ++ "stop": CMD_STOP, ++ }[command] ++ sent = await self._get_entry().runtime_data.control_blind( ++ details["command_enum"], ++ action, ++ device_id=details["command_device_id"], ++ ) ++ self._developer_notice = ( ++ f"{command.title()} command queued successfully." ++ if sent ++ else f"{command.title()} command failed; check the integration logs." ++ ) ++ return await self.async_step_developer_tools() ++ ++ async def async_step_test_open( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Send a direct logical-open test command.""" ++ return await self._async_developer_command("open") ++ ++ async def async_step_test_close( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Send a direct logical-close test command.""" ++ return await self._async_developer_command("close") ++ ++ async def async_step_test_stop( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Send a direct stop test command.""" ++ return await self._async_developer_command("stop") ++ ++ async def async_step_copy_diagnostics( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Show a copyable text snapshot for troubleshooting.""" ++ if user_input is not None: ++ return await self.async_step_developer_tools() ++ ++ details, last_received = self._developer_snapshot() ++ diagnostics = "\n".join( ++ ( ++ "Schellenberg USB blind diagnostics", ++ f"Selected blind: {details['name']}", ++ "", ++ "Last received:", ++ f"Device ID: {last_received['device_id']}", ++ f"Enum: {last_received['enum']}", ++ f"Command: {last_received['command']}", ++ f"Time: {last_received['time']}", ++ "", ++ "Current transmit target:", ++ f"Device ID: {details['command_device_id']}", ++ f"Enum: {details['command_enum']}", ++ "", ++ "Configured status identity:", ++ f"Device ID: {details['status_device_id']}", ++ f"Enum: {details['status_enum']}", ++ f"Open time: {details['open_time']:.2f} seconds", ++ f"Close time: {details['close_time']:.2f} seconds", ++ f"Invert direction: {details['invert_direction']}", ++ ) ++ ) ++ return self.async_show_form( ++ step_id="copy_diagnostics", ++ data_schema=vol.Schema( ++ { ++ vol.Required("diagnostics", default=diagnostics): ( ++ selector.TextSelector( ++ selector.TextSelectorConfig(multiline=True) ++ ) ++ ) ++ } ++ ), ++ ) ++ ++ async def async_step_test_existing( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Load an existing blind into the short command test.""" ++ subentry = self._get_reconfigure_subentry() ++ data = subentry.data ++ self._pending_device_name = subentry.title ++ self._pending_device_id = str( ++ data.get(CONF_COMMAND_DEVICE_ID) or data.get(CONF_DEVICE_ID, "") ++ ) ++ self._pending_device_enum = str( ++ data.get(CONF_COMMAND_ENUM) or data.get(CONF_DEVICE_ENUM, "") ++ ) ++ self._pending_status_device_id = str( ++ data.get(CONF_STATUS_DEVICE_ID) or self._pending_device_id ++ ) ++ self._pending_status_enum = str( ++ data.get(CONF_STATUS_ENUM) or self._pending_device_enum ++ ) ++ self._pending_open_time = float(data.get(CONF_OPEN_TIME, 60.0)) ++ self._pending_close_time = float(data.get(CONF_CLOSE_TIME, 60.0)) ++ self._pending_invert_direction = bool(data.get(CONF_INVERT_DIRECTION, False)) ++ self._pairing_workflow = "existing" ++ return await self.async_step_test_motor(user_input) ++ ++ async def async_step_edit( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Edit a blind while preserving its subentry and entity unique IDs.""" ++ entry = self._get_entry() ++ subentry = self._get_reconfigure_subentry() ++ current_data = dict(subentry.data) ++ command_device_id = str( ++ current_data.get(CONF_COMMAND_DEVICE_ID) ++ or current_data.get(CONF_DEVICE_ID, "") ++ ) ++ command_enum = str( ++ current_data.get(CONF_COMMAND_ENUM) ++ or current_data.get(CONF_DEVICE_ENUM, "") ++ ) ++ status_device_id = str( ++ current_data.get(CONF_STATUS_DEVICE_ID) or command_device_id ++ ) ++ status_enum = str(current_data.get(CONF_STATUS_ENUM) or command_enum) ++ errors: dict[str, str] = {} ++ ++ if user_input is not None: ++ device_name = str(user_input[CONF_DEVICE_NAME]).strip() ++ command_device_id = str(user_input[CONF_DEVICE_ID]).strip().upper() ++ command_enum = str(user_input[CONF_DEVICE_ENUM]).strip().upper() ++ status_device_id = ( ++ str(user_input.get(CONF_STATUS_DEVICE_ID, "")).strip().upper() ++ or command_device_id ++ ) ++ status_enum = ( ++ str(user_input.get(CONF_STATUS_ENUM, "")).strip().upper() ++ or command_enum ++ ) ++ open_time = float(user_input[CONF_OPEN_TIME_SECONDS]) ++ close_time = float(user_input[CONF_CLOSE_TIME_SECONDS]) ++ ++ if not device_name: ++ errors[CONF_DEVICE_NAME] = "required" ++ if not self._is_hex_value(command_device_id, 6): ++ errors[CONF_DEVICE_ID] = "invalid_device_id" ++ if not self._is_hex_value(command_enum, 2): ++ errors[CONF_DEVICE_ENUM] = "invalid_device_enum" ++ if not self._is_hex_value(status_device_id, 6): ++ errors[CONF_STATUS_DEVICE_ID] = "invalid_device_id" ++ if not self._is_hex_value(status_enum, 2): ++ errors[CONF_STATUS_ENUM] = "invalid_device_enum" ++ if open_time <= 0: ++ errors[CONF_OPEN_TIME_SECONDS] = "invalid_travel_time" ++ if close_time <= 0: ++ errors[CONF_CLOSE_TIME_SECONDS] = "invalid_travel_time" ++ ++ if any( ++ candidate.subentry_id != subentry.subentry_id ++ and str( ++ candidate.data.get(CONF_COMMAND_DEVICE_ID) ++ or candidate.data.get(CONF_DEVICE_ID, "") ++ ).upper() ++ == command_device_id ++ for candidate in entry.subentries.values() ++ ): ++ errors[CONF_DEVICE_ID] = "already_configured" ++ ++ if not errors: ++ current_data.update( ++ { ++ CONF_DEVICE_ID: command_device_id, ++ CONF_DEVICE_ENUM: command_enum, ++ CONF_COMMAND_DEVICE_ID: command_device_id, ++ CONF_COMMAND_ENUM: command_enum, ++ CONF_STATUS_DEVICE_ID: status_device_id, ++ CONF_STATUS_ENUM: status_enum, ++ CONF_OPEN_TIME: open_time, ++ CONF_CLOSE_TIME: close_time, ++ CONF_INVERT_DIRECTION: bool( ++ user_input.get(CONF_INVERT_DIRECTION, False) ++ ), ++ } ++ ) ++ return self.async_update_and_abort( ++ entry, ++ subentry, ++ title=device_name, ++ data=current_data, ++ ) ++ ++ return self.async_show_form( ++ step_id="edit", ++ data_schema=vol.Schema( ++ { ++ vol.Required( ++ CONF_DEVICE_NAME, ++ default=subentry.title, ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_DEVICE_ID, ++ default=command_device_id, ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_DEVICE_ENUM, ++ default=command_enum, ++ ): selector.TextSelector(), ++ vol.Optional( ++ CONF_STATUS_DEVICE_ID, ++ default=status_device_id, ++ ): selector.TextSelector(), ++ vol.Optional( ++ CONF_STATUS_ENUM, ++ default=status_enum, ++ ): selector.TextSelector(), ++ vol.Required( ++ CONF_OPEN_TIME_SECONDS, ++ default=current_data.get(CONF_OPEN_TIME, 60.0), ++ ): selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ vol.Required( ++ CONF_CLOSE_TIME_SECONDS, ++ default=current_data.get(CONF_CLOSE_TIME, 60.0), ++ ): selector.NumberSelector( ++ selector.NumberSelectorConfig( ++ min=0.1, ++ step=0.1, ++ unit_of_measurement="s", ++ mode=selector.NumberSelectorMode.BOX, ++ ) ++ ), ++ vol.Optional( ++ CONF_INVERT_DIRECTION, ++ default=current_data.get(CONF_INVERT_DIRECTION, False), ++ ): selector.BooleanSelector(), ++ } ++ ), ++ errors=errors, ++ ) ++ ++ async def async_step_calibrate( ++ self, user_input: dict[str, Any] | None = None ++ ) -> SubentryFlowResult: ++ """Run calibration for the selected blind subentry.""" + handler = self._get_calibration_handler() + handler.disable_subentry_creation() + + subentry = self._get_reconfigure_subentry() +- device_id = subentry.data.get("device_id") +- device_enum = subentry.data.get("device_enum") +- if not device_id: ++ command_device_id = subentry.data.get( ++ CONF_COMMAND_DEVICE_ID, subentry.data.get(CONF_DEVICE_ID) ++ ) ++ command_enum = subentry.data.get( ++ CONF_COMMAND_ENUM, subentry.data.get(CONF_DEVICE_ENUM) ++ ) ++ status_device_id = subentry.data.get(CONF_STATUS_DEVICE_ID, command_device_id) ++ status_enum = subentry.data.get(CONF_STATUS_ENUM, command_enum) ++ if not command_device_id or not status_device_id: + return self.async_abort(reason="device_not_found") + +- # Build a minimal device record; calibration handler will enrich after timing +- device_name = subentry.title or f"Blind {device_id}" ++ stable_id = ( ++ subentry.unique_id ++ if isinstance(subentry.unique_id, str) and subentry.unique_id ++ else command_device_id ++ ) ++ device_name = subentry.title or f"Blind {stable_id}" + handler.set_selected_device( + { +- "id": device_id, ++ "id": status_device_id, ++ "entity_id": stable_id, + "name": device_name, + CONF_OPEN_TIME: subentry.data.get(CONF_OPEN_TIME), + CONF_CLOSE_TIME: subentry.data.get(CONF_CLOSE_TIME), +- "enum": device_enum, ++ CONF_INVERT_DIRECTION: subentry.data.get(CONF_INVERT_DIRECTION, False), ++ "enum": status_enum, + } + ) + +diff --git a/custom_components/schellenberg_usb/const.py b/custom_components/schellenberg_usb/const.py +index db1c864..73d151c 100644 +--- a/custom_components/schellenberg_usb/const.py ++++ b/custom_components/schellenberg_usb/const.py +@@ -26,6 +26,17 @@ SUBENTRY_TYPE_BLIND = "blind" + # Configuration keys + CONF_SERIAL_PORT = "serial_port" + CONF_DEVICE_NAME = "device_name" ++CONF_DEVICE_ENUM = "device_enum" ++CONF_COMMAND_DEVICE_ID = "command_device_id" ++CONF_COMMAND_ENUM = "command_enum" ++CONF_STATUS_DEVICE_ID = "status_device_id" ++CONF_STATUS_ENUM = "status_enum" ++CONF_INVERT_DIRECTION = "invert_direction" ++CONF_COMMAND = "command" ++CONF_ENUM = "enum" ++CONF_CONFIG_ENTRY_ID = "config_entry_id" ++CONF_OPEN_TIME_SECONDS = "open_time_seconds" ++CONF_CLOSE_TIME_SECONDS = "close_time_seconds" + + # Data keys + DATA_API_INSTANCE = "api_instance" +@@ -91,6 +102,10 @@ SIGNAL_PAIRING_TIMEOUT = f"{DOMAIN}_pairing_timeout" + SIGNAL_STICK_STATUS_UPDATED = f"{DOMAIN}_stick_status_updated" + SIGNAL_CALIBRATION_COMPLETED = f"{DOMAIN}_calibration_completed" + ++# Services ++SERVICE_TEST_COMMAND = "test_command" ++TEST_COMMAND_DELAY = 0.75 ++ + # Device verification + VERIFY_TIMEOUT = 5 # seconds to wait for verification response + # Expected response format: RFTU_V20 F:20180510_DFBD B:1 +diff --git a/custom_components/schellenberg_usb/cover.py b/custom_components/schellenberg_usb/cover.py +index 23ac1ec..ef702e9 100644 +--- a/custom_components/schellenberg_usb/cover.py ++++ b/custom_components/schellenberg_usb/cover.py +@@ -25,8 +25,15 @@ from .const import ( + CMD_STOP, + CMD_UP, + CONF_CLOSE_TIME, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, ++ CONF_DEVICE_ENUM, ++ CONF_DEVICE_ID, ++ CONF_INVERT_DIRECTION, + CONF_OPEN_TIME, + CONF_SERIAL_PORT, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + DOMAIN, + EVENT_STARTED_MOVING_DOWN, + EVENT_STARTED_MOVING_UP, +@@ -78,25 +85,57 @@ async def async_setup_entry( + # Skip LED subentry; handled by switch platform + if subentry.subentry_type == SUBENTRY_TYPE_LED: + continue +- device_id = subentry.data.get("device_id") +- device_enum = subentry.data.get("device_enum") ++ legacy_device_id = subentry.data.get(CONF_DEVICE_ID) ++ legacy_device_enum = subentry.data.get(CONF_DEVICE_ENUM) ++ command_device_id = ( ++ subentry.data.get(CONF_COMMAND_DEVICE_ID) or legacy_device_id ++ ) ++ command_enum = subentry.data.get(CONF_COMMAND_ENUM) or legacy_device_enum ++ status_device_id = ( ++ subentry.data.get(CONF_STATUS_DEVICE_ID) ++ or legacy_device_id ++ or command_device_id ++ ) ++ status_enum = ( ++ subentry.data.get(CONF_STATUS_ENUM) ++ or legacy_device_enum ++ or command_enum ++ ) ++ subentry_unique_id = getattr(subentry, "unique_id", None) ++ stable_device_id = ( ++ subentry_unique_id ++ if isinstance(subentry_unique_id, str) and subentry_unique_id ++ else legacy_device_id or command_device_id ++ ) + device_name = subentry.title + +- if not device_id or not device_enum: ++ if not all( ++ ( ++ stable_device_id, ++ command_device_id, ++ command_enum, ++ status_device_id, ++ status_enum, ++ ) ++ ): + # This subentry lacks motor identification info; it's likely a non-motor type + # or pairing is incomplete. Downgrade to debug to avoid user confusion. + _LOGGER.debug( +- "Skipping subentry %s (type=%s) missing device_id/device_enum", ++ "Skipping subentry %s (type=%s) with incomplete command/status identity", + subentry.subentry_id, + getattr(subentry, "subentry_type", "unknown"), + ) + continue + ++ stable_device_id = str(stable_device_id) ++ command_device_id = str(command_device_id) ++ command_enum = str(command_enum) ++ status_device_id = str(status_device_id) ++ status_enum = str(status_enum) ++ + # Check if entity already exists to avoid duplicates. +- # NOTE: The cover entity sets its unique_id to f"schellenberg_{device_id}" in the entity class. +- # We must use the same pattern here; previously this used f"{device_id}_cover" which never matched +- # and caused a new entity to be created on every reload, losing the restored position (defaulting to 0). +- entity_unique_id = f"schellenberg_{device_id}" ++ # Keep the original subentry unique ID stable when editable protocol IDs change. ++ entity_unique_id = f"schellenberg_{stable_device_id}" + existing_entity_id = entity_registry.async_get_entity_id( + "cover", DOMAIN, entity_unique_id + ) +@@ -124,29 +163,38 @@ async def async_setup_entry( + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id=subentry.subentry_id, +- identifiers={(DOMAIN, device_id)}, ++ identifiers={(DOMAIN, stable_device_id)}, + name=device_name, + manufacturer="Schellenberg", +- model=f"USB Stick Motor ({device_id}/{device_enum})", ++ model=( ++ f"USB Stick Motor (command {command_device_id}/{command_enum}, " ++ f"status {status_device_id}/{status_enum})" ++ ), + ) + _LOGGER.debug( + "Created/updated device %s for paired device %s", + device.id, +- device_id, ++ stable_device_id, + ) + + # Create cover entity linked to this device + # Create and add the new cover entity attached to the subentry +- _LOGGER.debug("Creating cover entity for device %s", device_id) ++ _LOGGER.debug("Creating cover entity for device %s", stable_device_id) + async_add_entities( + [ + SchellenbergCover( + api=api, +- device_id=device_id, +- device_enum=device_enum, ++ device_id=stable_device_id, ++ device_enum=command_enum, + device_name=device_name, + device_data=subentry.data, + config_entry_id=entry.entry_id, ++ command_device_id=command_device_id, ++ status_device_id=status_device_id, ++ status_enum=status_enum, ++ invert_direction=bool( ++ subentry.data.get(CONF_INVERT_DIRECTION, False) ++ ), + ) + ], + config_subentry_id=subentry.subentry_id, +@@ -178,6 +226,10 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + device_name: str, + device_data: Mapping[str, Any] | None = None, + config_entry_id: str | None = None, ++ command_device_id: str | None = None, ++ status_device_id: str | None = None, ++ status_enum: str | None = None, ++ invert_direction: bool = False, + ) -> None: + """Initialize the Schellenberg cover entity. + +@@ -188,11 +240,21 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + device_name: Friendly name for the device + device_data: Device data dict containing calibration times + config_entry_id: The config entry ID for linking to device ++ command_device_id: Protocol ID associated with outgoing commands ++ status_device_id: Protocol ID expected in incoming status messages ++ status_enum: Enum expected in incoming status messages ++ invert_direction: Swap physical up/down commands for logical open/close + + """ + self._api = api + self._device_id = device_id +- self._device_enum = device_enum ++ self._command_device_id = command_device_id or device_id ++ self._command_enum = device_enum ++ self._status_device_id = status_device_id or self._command_device_id ++ self._status_enum = status_enum or device_enum ++ self._invert_direction = invert_direction ++ # Backward-compatible alias retained for diagnostics. ++ self._device_enum = self._command_enum + self._config_entry_id = config_entry_id + + # Entity attributes +@@ -262,7 +324,13 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + await super().async_added_to_hass() + + # Register this entity with the API so it knows we're listening +- self._api.register_entity(self._device_id, self._device_enum) ++ self._api.register_entity( ++ self._status_device_id, ++ self._status_enum, ++ self._attr_name, ++ command_device_id=self._command_device_id, ++ command_enum=self._command_enum, ++ ) + + # Restore the last known state + last_state = await self.async_get_last_state() +@@ -322,7 +390,7 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + self.async_on_remove( + async_dispatcher_connect( + self.hass, +- f"{SIGNAL_DEVICE_EVENT}_{self._device_id}", ++ f"{SIGNAL_DEVICE_EVENT}_{self._status_device_id}_{self._status_enum}", + self._handle_event, + ) + ) +@@ -394,21 +462,19 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + event, + ) + +- if event == EVENT_STARTED_MOVING_UP: +- _LOGGER.info("Device %s started moving UP", self._attr_name) +- self._attr_is_opening = True +- self._attr_is_closing = False +- self._move_start_time = time.monotonic() +- self._move_start_position = self._attr_current_cover_position +- # Start real-time position tracking +- self._start_position_tracking() +- elif event == EVENT_STARTED_MOVING_DOWN: +- _LOGGER.info("Device %s started moving DOWN", self._attr_name) +- self._attr_is_opening = False +- self._attr_is_closing = True ++ if event in (EVENT_STARTED_MOVING_UP, EVENT_STARTED_MOVING_DOWN): ++ physical_up = event == EVENT_STARTED_MOVING_UP ++ logical_opening = physical_up != self._invert_direction ++ _LOGGER.info( ++ "Device %s physical_direction=%s logical_direction=%s", ++ self._attr_name, ++ "up" if physical_up else "down", ++ "opening" if logical_opening else "closing", ++ ) ++ self._attr_is_opening = logical_opening ++ self._attr_is_closing = not logical_opening + self._move_start_time = time.monotonic() + self._move_start_position = self._attr_current_cover_position +- # Start real-time position tracking + self._start_position_tracking() + elif event == EVENT_STOPPED: + _LOGGER.info( +@@ -498,7 +564,11 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + # If target is 0 or 100, let the device stop naturally at its limits. + # For intermediate, send STOP and wait for STOP event to finalize & clear target. + if self._target_position not in (0, 100): +- await self._api.control_blind(self._device_enum, CMD_STOP) ++ await self._api.control_blind( ++ self._command_enum, ++ CMD_STOP, ++ device_id=self._command_device_id, ++ ) + # Stop tracking loop + self._position_update_task = None + # Leave opening/closing flags as-is until STOP to aid debugging +@@ -593,7 +663,14 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" +- _LOGGER.debug("Opening cover %s (enum=%s)", self._attr_name, self._device_enum) ++ action = CMD_DOWN if self._invert_direction else CMD_UP ++ _LOGGER.debug( ++ "Opening cover %s (command_id=%s enum=%s action=%s)", ++ self._attr_name, ++ self._command_device_id, ++ self._command_enum, ++ action, ++ ) + self._attr_is_opening = True + self._attr_is_closing = False + self._move_start_time = time.monotonic() +@@ -603,11 +680,20 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + self._move_start_position = self._attr_current_cover_position + self._start_position_tracking() + self.async_write_ha_state() +- await self._api.control_blind(self._device_enum, CMD_UP) ++ await self._api.control_blind( ++ self._command_enum, action, device_id=self._command_device_id ++ ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" +- _LOGGER.debug("Closing cover %s (enum=%s)", self._attr_name, self._device_enum) ++ action = CMD_UP if self._invert_direction else CMD_DOWN ++ _LOGGER.debug( ++ "Closing cover %s (command_id=%s enum=%s action=%s)", ++ self._attr_name, ++ self._command_device_id, ++ self._command_enum, ++ action, ++ ) + self._attr_is_opening = False + self._attr_is_closing = True + self._move_start_time = time.monotonic() +@@ -616,11 +702,18 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + self._move_start_position = self._attr_current_cover_position + self._start_position_tracking() + self.async_write_ha_state() +- await self._api.control_blind(self._device_enum, CMD_DOWN) ++ await self._api.control_blind( ++ self._command_enum, action, device_id=self._command_device_id ++ ) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" +- _LOGGER.debug("Stopping cover %s (enum=%s)", self._attr_name, self._device_enum) ++ _LOGGER.debug( ++ "Stopping cover %s (command_id=%s enum=%s)", ++ self._attr_name, ++ self._command_device_id, ++ self._command_enum, ++ ) + self._stop_position_tracking() + self._update_position() + self._attr_is_opening = False +@@ -629,7 +722,9 @@ class SchellenbergCover(CoverEntity, RestoreEntity): + self._move_start_position = None + self._target_position = None + self.async_write_ha_state() +- await self._api.control_blind(self._device_enum, CMD_STOP) ++ await self._api.control_blind( ++ self._command_enum, CMD_STOP, device_id=self._command_device_id ++ ) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" +diff --git a/custom_components/schellenberg_usb/options_flow_calibration.py b/custom_components/schellenberg_usb/options_flow_calibration.py +index fcafb2e..68f5681 100644 +--- a/custom_components/schellenberg_usb/options_flow_calibration.py ++++ b/custom_components/schellenberg_usb/options_flow_calibration.py +@@ -23,8 +23,14 @@ from homeassistant.helpers.storage import Store + from .const import ( + CALIBRATION_TIMEOUT, + CONF_CLOSE_TIME, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, ++ CONF_DEVICE_ENUM, + CONF_DEVICE_ID, ++ CONF_INVERT_DIRECTION, + CONF_OPEN_TIME, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + EVENT_STARTED_MOVING_DOWN, + EVENT_STARTED_MOVING_UP, + EVENT_STOPPED, +@@ -58,6 +64,9 @@ class CalibrationFlowHandler: + self._pending_device_id: str | None = None + self._pending_device_enum: str | None = None + self._pending_device_name: str | None = None ++ self._pending_status_device_id: str | None = None ++ self._pending_status_enum: str | None = None ++ self._pending_invert_direction = False + + async def set_device_by_id(self, device_id: str) -> None: + """Set the device to calibrate by its ID. +@@ -210,9 +219,13 @@ class CalibrationFlowHandler: + + # User clicked Next - wait for movement start and measure timing + try: +- # Wait for user to manually open the blinds +- # This will trigger EVENT_STARTED_MOVING_UP from the device +- start_ok = await self._wait_for_movement_start(EVENT_STARTED_MOVING_UP) ++ # Wait for the physical direction that corresponds to logical opening. ++ open_event = ( ++ EVENT_STARTED_MOVING_DOWN ++ if self._selected_device.get(CONF_INVERT_DIRECTION, False) ++ else EVENT_STARTED_MOVING_UP ++ ) ++ start_ok = await self._wait_for_movement_start(open_event) + if not start_ok: + errors["base"] = "calibration_start_timeout" + return self.flow.async_show_form( +@@ -283,9 +296,13 @@ class CalibrationFlowHandler: + + # User clicked Next - wait for movement start and measure timing + try: +- # Wait for user to manually close the blinds +- # This will trigger EVENT_STARTED_MOVING_DOWN from the device +- start_ok = await self._wait_for_movement_start(EVENT_STARTED_MOVING_DOWN) ++ # Wait for the physical direction that corresponds to logical closing. ++ close_event = ( ++ EVENT_STARTED_MOVING_UP ++ if self._selected_device.get(CONF_INVERT_DIRECTION, False) ++ else EVENT_STARTED_MOVING_DOWN ++ ) ++ start_ok = await self._wait_for_movement_start(close_event) + if not start_ok: + errors["base"] = "calibration_start_timeout" + return self.flow.async_show_form( +@@ -360,8 +377,15 @@ class CalibrationFlowHandler: + return self.flow.async_create_entry( # type: ignore[attr-defined] + title=self._pending_device_name, + data={ +- "device_id": self._pending_device_id, +- "device_enum": self._pending_device_enum, ++ CONF_DEVICE_ID: self._pending_device_id, ++ CONF_DEVICE_ENUM: self._pending_device_enum, ++ CONF_COMMAND_DEVICE_ID: self._pending_device_id, ++ CONF_COMMAND_ENUM: self._pending_device_enum, ++ CONF_STATUS_DEVICE_ID: self._pending_status_device_id, ++ CONF_STATUS_ENUM: self._pending_status_enum, ++ CONF_OPEN_TIME: round(self._open_time, 2), ++ CONF_CLOSE_TIME: round(self._close_time, 2), ++ CONF_INVERT_DIRECTION: self._pending_invert_direction, + }, + unique_id=self._pending_device_id, + ) +@@ -370,6 +394,16 @@ class CalibrationFlowHandler: + if isinstance(self.flow, OptionsFlow): + return self.flow.async_create_entry(title="", data={}) + ++ if isinstance(self.flow, ConfigSubentryFlow): ++ return self.flow.async_update_and_abort( ++ self.flow._get_entry(), ++ self.flow._get_reconfigure_subentry(), ++ data_updates={ ++ CONF_OPEN_TIME: round(self._open_time, 2), ++ CONF_CLOSE_TIME: round(self._close_time, 2), ++ }, ++ ) ++ + # Fallback: abort with success if no creation path triggered + return self.flow.async_abort(reason="reconfigure_successful") + +@@ -493,7 +527,7 @@ class CalibrationFlowHandler: + async_dispatcher_send( + self.flow.hass, + SIGNAL_CALIBRATION_COMPLETED, +- self._selected_device["id"], ++ self._selected_device.get("entity_id", self._selected_device["id"]), + round(open_time, 2), + round(close_time, 2), + ) +@@ -504,12 +538,18 @@ class CalibrationFlowHandler: + device_id: str, + device_enum: str, + device_name: str, ++ status_device_id: str | None = None, ++ status_enum: str | None = None, ++ invert_direction: bool = False, + ) -> None: + """Enable creating a subentry after calibration completes.""" + self._create_subentry_after_calibration = True + self._pending_device_id = device_id + self._pending_device_enum = device_enum + self._pending_device_name = device_name ++ self._pending_status_device_id = status_device_id or device_id ++ self._pending_status_enum = status_enum or device_enum ++ self._pending_invert_direction = invert_direction + + def disable_subentry_creation(self) -> None: + """Disable subentry creation (used for reconfigure flows).""" +@@ -517,3 +557,6 @@ class CalibrationFlowHandler: + self._pending_device_id = None + self._pending_device_enum = None + self._pending_device_name = None ++ self._pending_status_device_id = None ++ self._pending_status_enum = None ++ self._pending_invert_direction = False +diff --git a/custom_components/schellenberg_usb/services.yaml b/custom_components/schellenberg_usb/services.yaml +index c9b9b1b..d33d89b 100644 +--- a/custom_components/schellenberg_usb/services.yaml ++++ b/custom_components/schellenberg_usb/services.yaml +@@ -3,3 +3,37 @@ + pair: + name: Pair device + description: Activate pairing mode on the Schellenberg USB stick to pair a new blind motor. After calling this service, press the pairing button on your blind motor within 10 seconds. ++ ++test_command: ++ name: Test blind command ++ description: Send a raw open, close, or stop command to a device enum without creating a cover. ++ fields: ++ device_id: ++ name: Device ID ++ description: Six-character device ID used for logging and diagnosis. ++ required: true ++ example: "F2B8D5" ++ selector: ++ text: ++ enum: ++ name: Enum ++ description: Two-character enum inserted into the outgoing serial payload. ++ required: true ++ example: "23" ++ selector: ++ text: ++ command: ++ name: Command ++ required: true ++ selector: ++ select: ++ options: ++ - open ++ - close ++ - stop ++ config_entry_id: ++ name: Config entry ID ++ description: Required only when more than one Schellenberg USB hub is loaded. ++ required: false ++ selector: ++ text: +diff --git a/custom_components/schellenberg_usb/strings.json b/custom_components/schellenberg_usb/strings.json +index 1e9f943..7e89c2c 100644 +--- a/custom_components/schellenberg_usb/strings.json ++++ b/custom_components/schellenberg_usb/strings.json +@@ -38,15 +38,50 @@ + "blind": { + "entry_type": "Blind", + "initiate_flow": { +- "user": "Pair device", +- "reconfigure": "Calibrate" ++ "user": "Add blind", ++ "reconfigure": "Configure" + }, + "step": { + "user": { +- "title": "Pair a new device", +- "description": "Press the pair button to activate pairing mode on your Schellenberg USB stick. Then press the pairing button on your blind motor within 2 minutes.", ++ "title": "Add a blind", ++ "menu_options": { ++ "pair_test": "Pair and test (recommended)", ++ "pair_device": "Pair and calibrate (legacy)", ++ "manual": "Add manually" ++ } ++ }, ++ "pair_device": { ++ "title": "Pair and calibrate", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes.", ++ "submit": "Pair" ++ }, ++ "pair_test": { ++ "title": "Pair and test", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes. After pairing, a short motor command will be tested before calibration.", + "submit": "Pair" + }, ++ "manual": { ++ "title": "Configure blind identities", ++ "description": "The command identity is used for outgoing control. The optional status identity is matched against received movement messages; leave it blank to use the command identity.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" ++ }, ++ "submit": "Continue" ++ }, ++ "manual_next": { ++ "title": "Manual blind ready", ++ "menu_options": { ++ "test_motor": "Test motor command", ++ "save_manual": "Save without testing" ++ } ++ }, + "name_device": { + "title": "Name your device", + "description": "Pairing successful! Device ID: {device_id}\n\nPlease provide a friendly name for this blind.", +@@ -54,6 +89,77 @@ + "device_name": "Device name" + } + }, ++ "test_motor": { ++ "title": "Test motor command", ++ "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", ++ "submit": "Test" ++ }, ++ "did_motor_move": { ++ "title": "Did the motor move?", ++ "description": "Tested command identity {device_id}/{device_enum}.", ++ "data": { ++ "motor_moved": "The motor moved" ++ }, ++ "submit": "Continue" ++ }, ++ "test_success": { ++ "title": "Command test successful", ++ "menu_options": { ++ "calibration_close": "Continue to calibration", ++ "manual_times": "Save with manual travel times" ++ } ++ }, ++ "manual_times": { ++ "title": "Enter travel times", ++ "description": "Enter the measured full opening and closing times.", ++ "data": { ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time" ++ }, ++ "submit": "Save" ++ }, ++ "reconfigure": { ++ "title": "Configure blind", ++ "menu_options": { ++ "edit": "Edit identities and travel times", ++ "test_existing": "Test motor command", ++ "developer_tools": "Developer tools", ++ "calibrate": "Calibrate" ++ } ++ }, ++ "developer_tools": { ++ "title": "Developer tools", ++ "description": "Selected blind: {selected_blind}\n\nLast received:\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nTime: {last_time}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.", ++ "menu_options": { ++ "test_open": "Test Open", ++ "test_close": "Test Close", ++ "test_stop": "Test Stop", ++ "copy_diagnostics": "Copy diagnostics" ++ } ++ }, ++ "copy_diagnostics": { ++ "title": "Copy diagnostics", ++ "description": "Select and copy the diagnostic text below. Press Submit to return.", ++ "data": { ++ "diagnostics": "Diagnostics" ++ }, ++ "submit": "Return" ++ }, ++ "edit": { ++ "title": "Edit blind settings", ++ "description": "Changing protocol identities does not change the Home Assistant entity unique ID.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" ++ }, ++ "submit": "Save" ++ }, + "calibration_close": { + "title": "Step 1: Close the blind", + "description": "First, ensure {device_name} is fully closed (all the way down). Press 'Next' when ready.", +@@ -61,31 +167,38 @@ + }, + "calibration_open_instruction": { + "title": "Step 2: Open the blind", +- "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving up message from the blinds.", ++ "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring.", + "submit": "Start" + }, + "calibration_close_instruction": { + "title": "Step 3: Close the blind", +- "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving down message from the blinds.", ++ "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring.", + "submit": "Start" + }, + "calibration_complete": { + "title": "Calibration complete", +- "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds\n\nThese times will be used to accurately track the blind position.", ++ "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds", + "submit": "Done" + } + }, + "error": { +- "pairing_timeout": "No device responded within 2 minutes. Please try again and ensure you press the pairing button on your blind motor.", ++ "required": "This field is required.", ++ "invalid_device_id": "Device ID must contain exactly 6 hexadecimal characters.", ++ "invalid_device_enum": "Enum must contain exactly 2 hexadecimal characters.", ++ "invalid_travel_time": "Travel time must be greater than zero.", ++ "already_configured": "This command device ID is already configured.", ++ "command_failed": "The serial command could not be queued. Check the USB connection and logs.", ++ "pairing_timeout": "No device responded within 2 minutes. Please try again.", + "pairing_failed": "Pairing failed.", +- "calibration_timeout": "Calibration timed out. The blind took more than 5 minutes to complete the movement. Please check the device and try again.", +- "calibration_start_timeout": "Timeout waiting for the blind to start moving. Please ensure the device is properly connected and try again.", +- "unknown": "An unexpected error occurred during calibration. Please try again." ++ "calibration_timeout": "Calibration timed out. Please check the device and try again.", ++ "calibration_start_timeout": "Timeout waiting for the blind to start moving.", ++ "unknown": "An unexpected error occurred." + }, + "abort": { + "pairing_timeout": "No device responded within 2 minutes. Please try again.", + "pairing_failed": "Pairing failed.", +- "reconfigure_successful": "Calibration completed successfully" ++ "command_test_successful": "The motor command test succeeded.", ++ "reconfigure_successful": "Blind configuration updated successfully" + } + } + }, +@@ -127,6 +240,10 @@ + "pair": { + "name": "Pair device", + "description": "Activate pairing mode on the Schellenberg USB stick to pair a new blind motor. After calling this service, press the pairing button on your blind motor within 10 seconds." ++ }, ++ "test_command": { ++ "name": "Test blind command", ++ "description": "Send open, close, or stop to a device enum without creating a cover." + } + } + } +diff --git a/custom_components/schellenberg_usb/translations/de.json b/custom_components/schellenberg_usb/translations/de.json +index 8215d8c..62ed404 100644 +--- a/custom_components/schellenberg_usb/translations/de.json ++++ b/custom_components/schellenberg_usb/translations/de.json +@@ -38,15 +38,34 @@ + "blind": { + "entry_type": "Rollladen", + "initiate_flow": { +- "user": "Gerät koppeln", ++ "user": "Rollladen hinzufügen", + "reconfigure": "Kalibrieren" + }, + "step": { + "user": { ++ "title": "Rollladen hinzufügen", ++ "menu_options": { ++ "pair_device": "Koppeln und kalibrieren", ++ "manual": "Manuell hinzufügen" ++ } ++ }, ++ "pair_device": { + "title": "Neues Gerät koppeln", + "description": "Drücken Sie die Kopplungstaste, um den Kopplungsmodus an Ihrem Schellenberg USB-Stick zu aktivieren. Drücken Sie dann innerhalb von 2 Minuten die Kopplungstaste an Ihrem Rollladenmotor.", + "submit": "Koppeln" + }, ++ "manual": { ++ "title": "Rollladen manuell hinzufügen", ++ "description": "Geben Sie die Geräte-ID und den Enum-Wert aus den Protokollen sowie die gemessenen vollständigen Laufzeiten ein. Die Kalibrierung kann später ausgeführt werden.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Geräte-ID", ++ "device_enum": "Enum", ++ "open_time_seconds": "Öffnungszeit", ++ "close_time_seconds": "Schließzeit" ++ }, ++ "submit": "Hinzufügen" ++ }, + "name_device": { + "title": "Gerät benennen", + "description": "Kopplung erfolgreich! Geräte-ID: {device_id}\n\nBitte geben Sie einen Namen für diesen Rollladen ein.", +@@ -76,6 +95,11 @@ + } + }, + "error": { ++ "required": "Dieses Feld ist erforderlich.", ++ "invalid_device_id": "Die Geräte-ID muss genau 6 Hexadezimalzeichen enthalten.", ++ "invalid_device_enum": "Der Enum-Wert muss genau 2 Hexadezimalzeichen enthalten.", ++ "invalid_travel_time": "Die Laufzeit muss größer als null sein.", ++ "already_configured": "Dieses Gerät ist bereits konfiguriert.", + "pairing_timeout": "Kein Gerät hat innerhalb von 2 Minuten geantwortet. Bitte versuchen Sie es erneut und stellen Sie sicher, dass Sie die Kopplungstaste an Ihrem Rollladenmotor drücken.", + "pairing_failed": "Kopplung fehlgeschlagen.", + "calibration_timeout": "Kalibrierung abgelaufen. Der Rollladen hat mehr als 5 Minuten für die Bewegung benötigt. Bitte überprüfen Sie das Gerät und versuchen Sie es erneut.", +diff --git a/custom_components/schellenberg_usb/translations/en.json b/custom_components/schellenberg_usb/translations/en.json +index 1e9f943..7e89c2c 100644 +--- a/custom_components/schellenberg_usb/translations/en.json ++++ b/custom_components/schellenberg_usb/translations/en.json +@@ -38,15 +38,50 @@ + "blind": { + "entry_type": "Blind", + "initiate_flow": { +- "user": "Pair device", +- "reconfigure": "Calibrate" ++ "user": "Add blind", ++ "reconfigure": "Configure" + }, + "step": { + "user": { +- "title": "Pair a new device", +- "description": "Press the pair button to activate pairing mode on your Schellenberg USB stick. Then press the pairing button on your blind motor within 2 minutes.", ++ "title": "Add a blind", ++ "menu_options": { ++ "pair_test": "Pair and test (recommended)", ++ "pair_device": "Pair and calibrate (legacy)", ++ "manual": "Add manually" ++ } ++ }, ++ "pair_device": { ++ "title": "Pair and calibrate", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes.", ++ "submit": "Pair" ++ }, ++ "pair_test": { ++ "title": "Pair and test", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes. After pairing, a short motor command will be tested before calibration.", + "submit": "Pair" + }, ++ "manual": { ++ "title": "Configure blind identities", ++ "description": "The command identity is used for outgoing control. The optional status identity is matched against received movement messages; leave it blank to use the command identity.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" ++ }, ++ "submit": "Continue" ++ }, ++ "manual_next": { ++ "title": "Manual blind ready", ++ "menu_options": { ++ "test_motor": "Test motor command", ++ "save_manual": "Save without testing" ++ } ++ }, + "name_device": { + "title": "Name your device", + "description": "Pairing successful! Device ID: {device_id}\n\nPlease provide a friendly name for this blind.", +@@ -54,6 +89,77 @@ + "device_name": "Device name" + } + }, ++ "test_motor": { ++ "title": "Test motor command", ++ "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", ++ "submit": "Test" ++ }, ++ "did_motor_move": { ++ "title": "Did the motor move?", ++ "description": "Tested command identity {device_id}/{device_enum}.", ++ "data": { ++ "motor_moved": "The motor moved" ++ }, ++ "submit": "Continue" ++ }, ++ "test_success": { ++ "title": "Command test successful", ++ "menu_options": { ++ "calibration_close": "Continue to calibration", ++ "manual_times": "Save with manual travel times" ++ } ++ }, ++ "manual_times": { ++ "title": "Enter travel times", ++ "description": "Enter the measured full opening and closing times.", ++ "data": { ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time" ++ }, ++ "submit": "Save" ++ }, ++ "reconfigure": { ++ "title": "Configure blind", ++ "menu_options": { ++ "edit": "Edit identities and travel times", ++ "test_existing": "Test motor command", ++ "developer_tools": "Developer tools", ++ "calibrate": "Calibrate" ++ } ++ }, ++ "developer_tools": { ++ "title": "Developer tools", ++ "description": "Selected blind: {selected_blind}\n\nLast received:\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nTime: {last_time}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.", ++ "menu_options": { ++ "test_open": "Test Open", ++ "test_close": "Test Close", ++ "test_stop": "Test Stop", ++ "copy_diagnostics": "Copy diagnostics" ++ } ++ }, ++ "copy_diagnostics": { ++ "title": "Copy diagnostics", ++ "description": "Select and copy the diagnostic text below. Press Submit to return.", ++ "data": { ++ "diagnostics": "Diagnostics" ++ }, ++ "submit": "Return" ++ }, ++ "edit": { ++ "title": "Edit blind settings", ++ "description": "Changing protocol identities does not change the Home Assistant entity unique ID.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" ++ }, ++ "submit": "Save" ++ }, + "calibration_close": { + "title": "Step 1: Close the blind", + "description": "First, ensure {device_name} is fully closed (all the way down). Press 'Next' when ready.", +@@ -61,31 +167,38 @@ + }, + "calibration_open_instruction": { + "title": "Step 2: Open the blind", +- "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving up message from the blinds.", ++ "description": "Now we will measure how long it takes to open {device_name}. Press Start **then** press the open button on the control to start measuring.", + "submit": "Start" + }, + "calibration_close_instruction": { + "title": "Step 3: Close the blind", +- "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring. There is no need to rush, the timer does not start until we receive a moving down message from the blinds.", ++ "description": "Now we will measure how long it takes to close {device_name}. Press Start **then** press the close button on the control to start measuring.", + "submit": "Start" + }, + "calibration_complete": { + "title": "Calibration complete", +- "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds\n\nThese times will be used to accurately track the blind position.", ++ "description": "Calibration for {device_name} complete!\n\nMeasured times:\n- Open time: {open_time} seconds\n- Close time: {close_time} seconds", + "submit": "Done" + } + }, + "error": { +- "pairing_timeout": "No device responded within 2 minutes. Please try again and ensure you press the pairing button on your blind motor.", ++ "required": "This field is required.", ++ "invalid_device_id": "Device ID must contain exactly 6 hexadecimal characters.", ++ "invalid_device_enum": "Enum must contain exactly 2 hexadecimal characters.", ++ "invalid_travel_time": "Travel time must be greater than zero.", ++ "already_configured": "This command device ID is already configured.", ++ "command_failed": "The serial command could not be queued. Check the USB connection and logs.", ++ "pairing_timeout": "No device responded within 2 minutes. Please try again.", + "pairing_failed": "Pairing failed.", +- "calibration_timeout": "Calibration timed out. The blind took more than 5 minutes to complete the movement. Please check the device and try again.", +- "calibration_start_timeout": "Timeout waiting for the blind to start moving. Please ensure the device is properly connected and try again.", +- "unknown": "An unexpected error occurred during calibration. Please try again." ++ "calibration_timeout": "Calibration timed out. Please check the device and try again.", ++ "calibration_start_timeout": "Timeout waiting for the blind to start moving.", ++ "unknown": "An unexpected error occurred." + }, + "abort": { + "pairing_timeout": "No device responded within 2 minutes. Please try again.", + "pairing_failed": "Pairing failed.", +- "reconfigure_successful": "Calibration completed successfully" ++ "command_test_successful": "The motor command test succeeded.", ++ "reconfigure_successful": "Blind configuration updated successfully" + } + } + }, +@@ -127,6 +240,10 @@ + "pair": { + "name": "Pair device", + "description": "Activate pairing mode on the Schellenberg USB stick to pair a new blind motor. After calling this service, press the pairing button on your blind motor within 10 seconds." ++ }, ++ "test_command": { ++ "name": "Test blind command", ++ "description": "Send open, close, or stop to a device enum without creating a cover." + } + } + } +diff --git a/custom_components/schellenberg_usb/translations/es.json b/custom_components/schellenberg_usb/translations/es.json +index 15dc230..09e86f1 100644 +--- a/custom_components/schellenberg_usb/translations/es.json ++++ b/custom_components/schellenberg_usb/translations/es.json +@@ -38,15 +38,34 @@ + "blind": { + "entry_type": "Persiana", + "initiate_flow": { +- "user": "Emparejar dispositivo", ++ "user": "Añadir persiana", + "reconfigure": "Calibrar" + }, + "step": { + "user": { ++ "title": "Añadir una persiana", ++ "menu_options": { ++ "pair_device": "Emparejar y calibrar", ++ "manual": "Añadir manualmente" ++ } ++ }, ++ "pair_device": { + "title": "Emparejar un nuevo dispositivo", + "description": "Presione el botón de emparejamiento para activar el modo de emparejamiento en su memoria USB Schellenberg. Luego presione el botón de emparejamiento en su motor de persiana en los próximos 2 minutos.", + "submit": "Emparejar" + }, ++ "manual": { ++ "title": "Añadir una persiana manualmente", ++ "description": "Introduzca el ID del dispositivo y el enum que aparecen en los registros, además de los tiempos de recorrido completo medidos. La calibración se puede ejecutar más adelante.", ++ "data": { ++ "device_name": "Nombre", ++ "device_id": "ID del dispositivo", ++ "device_enum": "Enum", ++ "open_time_seconds": "Tiempo de apertura", ++ "close_time_seconds": "Tiempo de cierre" ++ }, ++ "submit": "Añadir" ++ }, + "name_device": { + "title": "Nombre su dispositivo", + "description": "¡Emparejamiento exitoso! ID del dispositivo: {device_id}\n\nPor favor, proporcione un nombre para esta persiana.", +@@ -76,6 +95,11 @@ + } + }, + "error": { ++ "required": "Este campo es obligatorio.", ++ "invalid_device_id": "El ID del dispositivo debe contener exactamente 6 caracteres hexadecimales.", ++ "invalid_device_enum": "El enum debe contener exactamente 2 caracteres hexadecimales.", ++ "invalid_travel_time": "El tiempo de recorrido debe ser mayor que cero.", ++ "already_configured": "Este dispositivo ya está configurado.", + "pairing_timeout": "Ningún dispositivo respondió en 2 minutos. Por favor, inténtelo de nuevo y asegúrese de presionar el botón de emparejamiento en su motor de persiana.", + "pairing_failed": "Error en el emparejamiento.", + "calibration_timeout": "Tiempo de calibración agotado. La persiana tardó más de 5 minutos en completar el movimiento. Por favor, verifique el dispositivo e inténtelo de nuevo.", +diff --git a/custom_components/schellenberg_usb/translations/fr.json b/custom_components/schellenberg_usb/translations/fr.json +index b724368..3c519d9 100644 +--- a/custom_components/schellenberg_usb/translations/fr.json ++++ b/custom_components/schellenberg_usb/translations/fr.json +@@ -38,15 +38,34 @@ + "blind": { + "entry_type": "Volet", + "initiate_flow": { +- "user": "Appairer l'appareil", ++ "user": "Ajouter un volet", + "reconfigure": "Calibrer" + }, + "step": { + "user": { ++ "title": "Ajouter un volet", ++ "menu_options": { ++ "pair_device": "Appairer et calibrer", ++ "manual": "Ajouter manuellement" ++ } ++ }, ++ "pair_device": { + "title": "Appairer un nouvel appareil", + "description": "Appuyez sur le bouton d'appairage pour activer le mode d'appairage sur votre clé USB Schellenberg. Ensuite, appuyez sur le bouton d'appairage de votre moteur de volet dans les 2 minutes.", + "submit": "Appairer" + }, ++ "manual": { ++ "title": "Ajouter un volet manuellement", ++ "description": "Saisissez l'ID de l'appareil et l'énumération affichés dans les journaux, ainsi que les durées de course complètes mesurées. Le calibrage pourra être effectué ultérieurement.", ++ "data": { ++ "device_name": "Nom", ++ "device_id": "ID de l'appareil", ++ "device_enum": "Énumération", ++ "open_time_seconds": "Durée d'ouverture", ++ "close_time_seconds": "Durée de fermeture" ++ }, ++ "submit": "Ajouter" ++ }, + "name_device": { + "title": "Nommer votre appareil", + "description": "Appairage réussi ! ID de l'appareil : {device_id}\n\nVeuillez donner un nom à ce volet.", +@@ -76,6 +95,11 @@ + } + }, + "error": { ++ "required": "Ce champ est obligatoire.", ++ "invalid_device_id": "L'ID de l'appareil doit contenir exactement 6 caractères hexadécimaux.", ++ "invalid_device_enum": "L'énumération doit contenir exactement 2 caractères hexadécimaux.", ++ "invalid_travel_time": "La durée de course doit être supérieure à zéro.", ++ "already_configured": "Cet appareil est déjà configuré.", + "pairing_timeout": "Aucun appareil n'a répondu dans les 2 minutes. Veuillez réessayer et vous assurer d'appuyer sur le bouton d'appairage de votre moteur de volet.", + "pairing_failed": "Échec de l'appairage.", + "calibration_timeout": "Délai de calibration dépassé. Le volet a mis plus de 5 minutes pour terminer le mouvement. Veuillez vérifier l'appareil et réessayer.", +diff --git a/tests/test_api_messages.py b/tests/test_api_messages.py +index 082492e..064d067 100644 +--- a/tests/test_api_messages.py ++++ b/tests/test_api_messages.py +@@ -147,11 +147,49 @@ async def test_handle_message_device_event_registered_device( + # Format: ssXXYYYYYYZZZZCCPPRR where XX=enum, YYYYYY=device_id, CC=command + api._handle_message("ss10ABC123ZZZZ01PP00") + +- # Should dispatch event to device +- mock_send.assert_called_once() +- call_args = mock_send.call_args[0] +- assert "schellenberg_usb_device_event_ABC123" in call_args[1] +- assert call_args[2] == "01" # command ++ # Calibration receives the ID-only signal and the cover receives an exact signal. ++ assert mock_send.call_count == 2 ++ assert mock_send.call_args_list[0].args == ( ++ hass, ++ "schellenberg_usb_device_event_ABC123", ++ "01", ++ ) ++ assert mock_send.call_args_list[1].args == ( ++ hass, ++ "schellenberg_usb_device_event_ABC123_10", ++ "01", ++ ) ++ ++ ++@pytest.mark.asyncio ++async def test_handle_message_requires_exact_status_pair( ++ hass: HomeAssistant, ++) -> None: ++ """Test a matching ID with another enum does not reach the cover.""" ++ api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") ++ api.register_entity( ++ "3720B8", ++ "08", ++ "Sitting room", ++ command_device_id="F2B8D5", ++ command_enum="23", ++ ) ++ ++ with patch( ++ "custom_components.schellenberg_usb.api.async_dispatcher_send" ++ ) as mock_send: ++ api._handle_message("ss133720B8ZZZZ01PP00") ++ ++ mock_send.assert_called_once_with( ++ hass, ++ "schellenberg_usb_device_event_3720B8", ++ "01", ++ ) ++ last_received = api.get_last_received("3720B8", "13") ++ assert last_received is not None ++ assert last_received["device_id"] == "3720B8" ++ assert last_received["enum"] == "13" ++ assert last_received["command"] == "01" + + + @pytest.mark.asyncio +diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py +new file mode 100644 +index 0000000..3e39f78 +--- /dev/null ++++ b/tests/test_config_flow.py +@@ -0,0 +1,360 @@ ++"""Tests for Schellenberg USB blind subentry flows.""" ++ ++from __future__ import annotations ++ ++from types import MappingProxyType ++from unittest.mock import AsyncMock, MagicMock, call, patch ++ ++import pytest ++from homeassistant.config_entries import ConfigSubentryFlow, SOURCE_USER ++from homeassistant.data_entry_flow import FlowResultType ++ ++from custom_components.schellenberg_usb.config_flow import ( ++ SchellenbergPairingSubentryFlow, ++) ++from custom_components.schellenberg_usb.const import ( ++ CMD_DOWN, ++ CMD_STOP, ++ CMD_UP, ++ CONF_CLOSE_TIME, ++ CONF_CLOSE_TIME_SECONDS, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, ++ CONF_DEVICE_ENUM, ++ CONF_DEVICE_ID, ++ CONF_DEVICE_NAME, ++ CONF_INVERT_DIRECTION, ++ CONF_OPEN_TIME, ++ CONF_OPEN_TIME_SECONDS, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, ++) ++from custom_components.schellenberg_usb.options_flow_calibration import ( ++ CalibrationFlowHandler, ++) ++ ++ ++def _create_flow() -> SchellenbergPairingSubentryFlow: ++ """Create a subentry flow with user source context.""" ++ flow = SchellenbergPairingSubentryFlow() ++ flow.context = {"source": SOURCE_USER} ++ return flow ++ ++ ++@pytest.mark.asyncio ++async def test_blind_subentry_flow_shows_setup_method_menu() -> None: ++ """Test that legacy, hybrid, and manual setup remain available.""" ++ result = await _create_flow().async_step_user() ++ ++ assert result["type"] is FlowResultType.MENU ++ assert result["menu_options"] == ["pair_test", "pair_device", "manual"] ++ ++ ++@pytest.mark.asyncio ++async def test_legacy_pairing_path_is_unchanged() -> None: ++ """Test selecting legacy pairing still reaches naming and calibration.""" ++ flow = _create_flow() ++ api = MagicMock() ++ api.pair_device_and_wait = AsyncMock(return_value=("3720B8", "08")) ++ hub_entry = MagicMock(runtime_data=api) ++ ++ form = await flow.async_step_pair_device() ++ assert form["step_id"] == "pair_device" ++ ++ with patch.object(flow, "_get_entry", return_value=hub_entry): ++ result = await flow.async_step_pair_device({}) ++ ++ assert result["step_id"] == "name_device" ++ assert flow._pairing_workflow == "legacy" ++ ++ ++@pytest.mark.asyncio ++async def test_hybrid_pairing_reaches_command_test() -> None: ++ """Test hybrid pairing names the device before command testing.""" ++ flow = _create_flow() ++ api = MagicMock() ++ api.pair_device_and_wait = AsyncMock(return_value=("F2B8D5", "23")) ++ hub_entry = MagicMock(runtime_data=api) ++ ++ with patch.object(flow, "_get_entry", return_value=hub_entry): ++ await flow.async_step_pair_test({}) ++ result = await flow.async_step_name_device({CONF_DEVICE_NAME: "Sitting room"}) ++ ++ assert result["type"] is FlowResultType.FORM ++ assert result["step_id"] == "test_motor" ++ assert result["description_placeholders"] == { ++ "device_id": "F2B8D5", ++ "device_enum": "23", ++ } ++ ++ ++@pytest.mark.asyncio ++async def test_manual_setup_stores_separate_command_and_status_identity() -> None: ++ """Test manual setup stores split identities while retaining legacy keys.""" ++ flow = _create_flow() ++ hub_entry = MagicMock(subentries=MappingProxyType({})) ++ ++ with patch.object(flow, "_get_entry", return_value=hub_entry): ++ result = await flow.async_step_manual( ++ { ++ CONF_DEVICE_NAME: "Sitting room", ++ CONF_DEVICE_ID: "f2b8d5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720b8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME_SECONDS: 25.06, ++ CONF_CLOSE_TIME_SECONDS: 23.05, ++ CONF_INVERT_DIRECTION: False, ++ } ++ ) ++ ++ assert result["type"] is FlowResultType.MENU ++ assert result["step_id"] == "manual_next" ++ ++ result = await flow.async_step_save_manual() ++ assert result["type"] is FlowResultType.CREATE_ENTRY ++ assert result["title"] == "Sitting room" ++ assert result["data"] == { ++ CONF_DEVICE_ID: "F2B8D5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_COMMAND_DEVICE_ID: "F2B8D5", ++ CONF_COMMAND_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720B8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME: 25.06, ++ CONF_CLOSE_TIME: 23.05, ++ CONF_INVERT_DIRECTION: False, ++ } ++ assert result["unique_id"] == "F2B8D5" ++ ++ ++@pytest.mark.asyncio ++async def test_manual_setup_validates_protocol_values() -> None: ++ """Test validation of command/status identities and travel times.""" ++ flow = _create_flow() ++ hub_entry = MagicMock(subentries=MappingProxyType({})) ++ ++ with patch.object(flow, "_get_entry", return_value=hub_entry): ++ result = await flow.async_step_manual( ++ { ++ CONF_DEVICE_NAME: " ", ++ CONF_DEVICE_ID: "not-hex", ++ CONF_DEVICE_ENUM: "123", ++ CONF_STATUS_DEVICE_ID: "also-bad", ++ CONF_STATUS_ENUM: "x", ++ CONF_OPEN_TIME_SECONDS: 0, ++ CONF_CLOSE_TIME_SECONDS: -1, ++ } ++ ) ++ ++ assert result["type"] is FlowResultType.FORM ++ assert result["errors"] == { ++ CONF_DEVICE_NAME: "required", ++ CONF_DEVICE_ID: "invalid_device_id", ++ CONF_DEVICE_ENUM: "invalid_device_enum", ++ CONF_STATUS_DEVICE_ID: "invalid_device_id", ++ CONF_STATUS_ENUM: "invalid_device_enum", ++ CONF_OPEN_TIME_SECONDS: "invalid_travel_time", ++ CONF_CLOSE_TIME_SECONDS: "invalid_travel_time", ++ } ++ ++ ++@pytest.mark.asyncio ++async def test_short_motor_command_sends_open_then_stop() -> None: ++ """Test the hybrid command sequence and its diagnostic context.""" ++ flow = _create_flow() ++ flow._pending_device_id = "F2B8D5" ++ flow._pending_device_enum = "23" ++ api = MagicMock() ++ api.control_blind = AsyncMock(return_value=True) ++ hub_entry = MagicMock(runtime_data=api) ++ ++ with ( ++ patch.object(flow, "_get_entry", return_value=hub_entry), ++ patch("custom_components.schellenberg_usb.config_flow.asyncio.sleep") as sleep, ++ ): ++ result = await flow.async_step_test_motor({}) ++ ++ assert result["step_id"] == "did_motor_move" ++ api.control_blind.assert_has_awaits( ++ [ ++ call("23", CMD_UP, device_id="F2B8D5"), ++ call("23", CMD_STOP, device_id="F2B8D5"), ++ ] ++ ) ++ sleep.assert_awaited_once_with(0.75) ++ ++ ++@pytest.mark.asyncio ++async def test_edit_updates_protocol_data_without_unique_id_change() -> None: ++ """Test editing updates subentry data without replacing the subentry.""" ++ flow = _create_flow() ++ subentry = MagicMock( ++ subentry_id="sub1", ++ title="Sitting room", ++ unique_id="F2B8D5", ++ data={ ++ CONF_DEVICE_ID: "F2B8D5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_OPEN_TIME: 25.06, ++ CONF_CLOSE_TIME: 23.05, ++ }, ++ ) ++ entry = MagicMock(subentries=MappingProxyType({"sub1": subentry})) ++ expected_result = {"type": FlowResultType.ABORT} ++ ++ with ( ++ patch.object(flow, "_get_entry", return_value=entry), ++ patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), ++ patch.object( ++ flow, "async_update_and_abort", return_value=expected_result ++ ) as update, ++ ): ++ result = await flow.async_step_edit( ++ { ++ CONF_DEVICE_NAME: "Door", ++ CONF_DEVICE_ID: "f2b8d5", ++ CONF_DEVICE_ENUM: "13", ++ CONF_STATUS_DEVICE_ID: "3720b8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME_SECONDS: 25.06, ++ CONF_CLOSE_TIME_SECONDS: 23.05, ++ CONF_INVERT_DIRECTION: True, ++ } ++ ) ++ ++ assert result is expected_result ++ _, kwargs = update.call_args ++ assert kwargs["title"] == "Door" ++ assert kwargs["data"][CONF_COMMAND_DEVICE_ID] == "F2B8D5" ++ assert kwargs["data"][CONF_COMMAND_ENUM] == "13" ++ assert kwargs["data"][CONF_STATUS_DEVICE_ID] == "3720B8" ++ assert kwargs["data"][CONF_STATUS_ENUM] == "08" ++ assert kwargs["data"][CONF_INVERT_DIRECTION] is True ++ assert "unique_id" not in kwargs ++ ++ ++@pytest.mark.asyncio ++async def test_developer_tools_show_last_frame_and_send_selected_target() -> None: ++ """Test the native diagnostic view and its direct command actions.""" ++ flow = _create_flow() ++ subentry = MagicMock( ++ title="Sitting room door", ++ data={ ++ CONF_COMMAND_DEVICE_ID: "F2B8D5", ++ CONF_COMMAND_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720B8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME: 25.06, ++ CONF_CLOSE_TIME: 23.05, ++ CONF_INVERT_DIRECTION: True, ++ }, ++ ) ++ api = MagicMock() ++ api.get_last_received.return_value = { ++ "device_id": "3720B8", ++ "enum": "08", ++ "command": "01", ++ "time": "17:32:14", ++ } ++ api.control_blind = AsyncMock(return_value=True) ++ entry = MagicMock(runtime_data=api) ++ ++ with ( ++ patch.object(flow, "_get_entry", return_value=entry), ++ patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), ++ ): ++ result = await flow.async_step_developer_tools() ++ command_result = await flow.async_step_test_open() ++ copy_result = await flow.async_step_copy_diagnostics() ++ ++ placeholders = result["description_placeholders"] ++ assert placeholders is not None ++ assert placeholders["selected_blind"] == "Sitting room door" ++ assert placeholders["last_device_id"] == "3720B8" ++ assert placeholders["command_device_id"] == "F2B8D5" ++ assert placeholders["command_enum"] == "23" ++ api.control_blind.assert_awaited_once_with("23", CMD_DOWN, device_id="F2B8D5") ++ command_placeholders = command_result["description_placeholders"] ++ assert command_placeholders is not None ++ assert "queued successfully" in command_placeholders["result"] ++ schema = copy_result["data_schema"] ++ assert schema is not None ++ diagnostics = schema({})["diagnostics"] ++ assert "Selected blind: Sitting room door" in diagnostics ++ assert "Device ID: 3720B8" in diagnostics ++ assert "Device ID: F2B8D5" in diagnostics ++ ++ ++@pytest.mark.asyncio ++async def test_pairing_persists_calibrated_protocol_data() -> None: ++ """Test paired subentries retain identities and measured times.""" ++ flow = MagicMock(spec=ConfigSubentryFlow) ++ handler = CalibrationFlowHandler(flow) ++ handler.set_selected_device( ++ { ++ "id": "3720B8", ++ "entity_id": "F2B8D5", ++ "name": "Sitting room", ++ "enum": "08", ++ } ++ ) ++ handler.enable_subentry_creation( ++ device_id="F2B8D5", ++ device_enum="23", ++ device_name="Sitting room", ++ status_device_id="3720B8", ++ status_enum="08", ++ ) ++ handler._open_time = 25.064 ++ handler._close_time = 23.054 ++ ++ with patch.object(handler, "_save_calibration_data", new=AsyncMock()): ++ await handler.async_step_calibration_complete({}) ++ ++ flow.async_create_entry.assert_called_once_with( ++ title="Sitting room", ++ data={ ++ CONF_DEVICE_ID: "F2B8D5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_COMMAND_DEVICE_ID: "F2B8D5", ++ CONF_COMMAND_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720B8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME: 25.06, ++ CONF_CLOSE_TIME: 23.05, ++ CONF_INVERT_DIRECTION: False, ++ }, ++ unique_id="F2B8D5", ++ ) ++ ++ ++@pytest.mark.asyncio ++async def test_reconfigure_persists_calibrated_travel_times() -> None: ++ """Test recalibration updates the existing blind subentry.""" ++ flow = MagicMock(spec=ConfigSubentryFlow) ++ entry = MagicMock() ++ subentry = MagicMock() ++ flow._get_entry.return_value = entry ++ flow._get_reconfigure_subentry.return_value = subentry ++ expected_result = {"type": FlowResultType.ABORT} ++ flow.async_update_and_abort.return_value = expected_result ++ handler = CalibrationFlowHandler(flow) ++ handler.set_selected_device( ++ {"id": "3720B8", "entity_id": "F2B8D5", "name": "Door", "enum": "08"} ++ ) ++ handler._open_time = 25.678 ++ handler._close_time = 23.456 ++ ++ with patch.object(handler, "_save_calibration_data", new=AsyncMock()): ++ result = await handler.async_step_calibration_complete({}) ++ ++ assert result is expected_result ++ flow.async_update_and_abort.assert_called_once_with( ++ entry, ++ subentry, ++ data_updates={ ++ CONF_OPEN_TIME: 25.68, ++ CONF_CLOSE_TIME: 23.46, ++ }, ++ ) +diff --git a/tests/test_const.py b/tests/test_const.py +index fc5f584..c1bf04f 100644 +--- a/tests/test_const.py ++++ b/tests/test_const.py +@@ -12,7 +12,12 @@ from custom_components.schellenberg_usb.const import ( + CMD_STOP, + CMD_UP, + CONF_CLOSE_TIME, ++ CONF_CLOSE_TIME_SECONDS, ++ CONF_DEVICE_ENUM, ++ CONF_DEVICE_ID, ++ CONF_DEVICE_NAME, + CONF_OPEN_TIME, ++ CONF_OPEN_TIME_SECONDS, + CONF_SERIAL_PORT, + DATA_API_INSTANCE, + DATA_UNSUB_DISPATCHER, +@@ -45,6 +50,11 @@ def test_configuration_constants() -> None: + """Test configuration constants.""" + assert CONF_SERIAL_PORT == "serial_port" + assert CONF_OPEN_TIME == "open_time" ++ assert CONF_DEVICE_NAME == "device_name" ++ assert CONF_DEVICE_ID == "device_id" ++ assert CONF_DEVICE_ENUM == "device_enum" ++ assert CONF_OPEN_TIME_SECONDS == "open_time_seconds" ++ assert CONF_CLOSE_TIME_SECONDS == "close_time_seconds" + assert CONF_CLOSE_TIME == "close_time" + + +diff --git a/tests/test_cover.py b/tests/test_cover.py +index 5bfd954..99d3f96 100644 +--- a/tests/test_cover.py ++++ b/tests/test_cover.py +@@ -235,7 +235,42 @@ async def test_cover_async_open_cover( + + assert cover._attr_is_opening is True + assert cover._attr_is_closing is False +- _async_mock(mock_api.control_blind).assert_called_once_with("01", "01") ++ _async_mock(mock_api.control_blind).assert_called_once_with( ++ "01", "01", device_id="ABC123" ++ ) ++ ++ ++@pytest.mark.asyncio ++async def test_cover_uses_split_identity_and_inverted_direction( ++ hass: HomeAssistant, ++ mock_api: SchellenbergUsbApi, ++) -> None: ++ """Test commands use command identity while status direction is inverted.""" ++ cover = SchellenbergCover( ++ api=mock_api, ++ device_id="STABLE1", ++ device_enum="23", ++ device_name="Sitting room", ++ command_device_id="F2B8D5", ++ status_device_id="3720B8", ++ status_enum="08", ++ invert_direction=True, ++ ) ++ cover.hass = hass ++ cover._attr_current_cover_position = 0 ++ ++ with ( ++ patch.object(cover, "_start_position_tracking"), ++ patch.object(cover, "async_write_ha_state"), ++ ): ++ await cover.async_open_cover() ++ cover._handle_event(EVENT_STARTED_MOVING_DOWN) ++ ++ _async_mock(mock_api.control_blind).assert_awaited_once_with( ++ "23", "02", device_id="F2B8D5" ++ ) ++ assert cover._attr_is_opening is True ++ assert cover._attr_is_closing is False + + + @pytest.mark.asyncio +@@ -259,7 +294,9 @@ async def test_cover_async_close_cover( + + assert cover._attr_is_opening is False + assert cover._attr_is_closing is True +- _async_mock(mock_api.control_blind).assert_called_once_with("01", "02") ++ _async_mock(mock_api.control_blind).assert_called_once_with( ++ "01", "02", device_id="ABC123" ++ ) + + + @pytest.mark.asyncio +@@ -284,7 +321,9 @@ async def test_cover_async_stop_cover( + + assert cover._attr_is_opening is False + assert cover._attr_is_closing is False +- _async_mock(mock_api.control_blind).assert_called_once_with("01", "00") ++ _async_mock(mock_api.control_blind).assert_called_once_with( ++ "01", "00", device_id="ABC123" ++ ) + + + @pytest.mark.asyncio +@@ -625,7 +664,13 @@ async def test_cover_registers_with_api( + with patch.object(cover, "async_write_ha_state"): + await cover.async_added_to_hass() + +- _magic_mock(mock_api.register_entity).assert_called_once_with("ABC123", "01") ++ _magic_mock(mock_api.register_entity).assert_called_once_with( ++ "ABC123", ++ "01", ++ "Test Cover", ++ command_device_id="ABC123", ++ command_enum="01", ++ ) + + + @pytest.mark.asyncio +diff --git a/tests/test_init.py b/tests/test_init.py +index 9f7b44f..a28d5a9 100644 +--- a/tests/test_init.py ++++ b/tests/test_init.py +@@ -12,9 +12,14 @@ from homeassistant.helpers import device_registry as dr + + from custom_components.schellenberg_usb.api import SchellenbergUsbApi + from custom_components.schellenberg_usb.const import ( ++ CMD_UP, ++ CONF_COMMAND, ++ CONF_DEVICE_ID, ++ CONF_ENUM, + CONF_SERIAL_PORT, + DOMAIN, + PLATFORMS, ++ SERVICE_TEST_COMMAND, + ) + + +@@ -62,6 +67,34 @@ async def test_async_setup_entry_basic( + assert isinstance(mock_config_entry.runtime_data, SchellenbergUsbApi) + + ++@pytest.mark.asyncio ++async def test_async_setup_registers_test_command_service( ++ hass: HomeAssistant, mock_config_entry: ConfigEntry ++) -> None: ++ """Test the diagnostic service validates and forwards a command.""" ++ from custom_components.schellenberg_usb import async_setup ++ ++ api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") ++ api.control_blind = AsyncMock(return_value=True) # type: ignore[method-assign] ++ mock_config_entry.runtime_data = api ++ ++ assert await async_setup(hass, {}) ++ await hass.services.async_call( ++ DOMAIN, ++ SERVICE_TEST_COMMAND, ++ { ++ CONF_DEVICE_ID: "f2b8d5", ++ CONF_ENUM: "23", ++ CONF_COMMAND: "open", ++ }, ++ blocking=True, ++ ) ++ ++ api.control_blind.assert_awaited_once_with( # type: ignore[attr-defined] ++ "23", CMD_UP, device_id="F2B8D5" ++ ) ++ ++ + @pytest.mark.asyncio + async def test_async_setup_entry_creates_hub_device( + hass: HomeAssistant, mock_config_entry: ConfigEntry diff --git a/schellenberg-usb-menu-labels.patch b/schellenberg-usb-menu-labels.patch new file mode 100644 index 0000000..9dd82b7 --- /dev/null +++ b/schellenberg-usb-menu-labels.patch @@ -0,0 +1,545 @@ +diff --git a/custom_components/schellenberg_usb/translations/de.json b/custom_components/schellenberg_usb/translations/de.json +index 62ed404..f864b25 100644 +--- a/custom_components/schellenberg_usb/translations/de.json ++++ b/custom_components/schellenberg_usb/translations/de.json +@@ -46,7 +46,8 @@ + "title": "Rollladen hinzufügen", + "menu_options": { + "pair_device": "Koppeln und kalibrieren", +- "manual": "Manuell hinzufügen" ++ "manual": "Manuell hinzufügen", ++ "pair_test": "Pair and test (recommended)" + } + }, + "pair_device": { +@@ -62,7 +63,10 @@ + "device_id": "Geräte-ID", + "device_enum": "Enum", + "open_time_seconds": "Öffnungszeit", +- "close_time_seconds": "Schließzeit" ++ "close_time_seconds": "Schließzeit", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "invert_direction": "Invert open/close direction" + }, + "submit": "Hinzufügen" + }, +@@ -92,6 +96,89 @@ + "title": "Kalibrierung abgeschlossen", + "description": "Kalibrierung für {device_name} abgeschlossen!\n\nGemessene Zeiten:\n- Öffnungszeit: {open_time} Sekunden\n- Schließzeit: {close_time} Sekunden\n\nDiese Zeiten werden verwendet, um die Rollladenposition genau zu verfolgen.", + "submit": "Fertig" ++ }, ++ "pair_test": { ++ "title": "Pair and test", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes. After pairing, a short motor command will be tested before calibration.", ++ "submit": "Pair" ++ }, ++ "manual_next": { ++ "title": "Manual blind ready", ++ "menu_options": { ++ "test_motor": "Test motor command", ++ "save_manual": "Save without testing" ++ } ++ }, ++ "test_motor": { ++ "title": "Test motor command", ++ "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", ++ "submit": "Test" ++ }, ++ "did_motor_move": { ++ "title": "Did the motor move?", ++ "description": "Tested command identity {device_id}/{device_enum}.", ++ "data": { ++ "motor_moved": "The motor moved" ++ }, ++ "submit": "Continue" ++ }, ++ "test_success": { ++ "title": "Command test successful", ++ "menu_options": { ++ "calibration_close": "Continue to calibration", ++ "manual_times": "Save with manual travel times" ++ } ++ }, ++ "manual_times": { ++ "title": "Enter travel times", ++ "description": "Enter the measured full opening and closing times.", ++ "data": { ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time" ++ }, ++ "submit": "Save" ++ }, ++ "reconfigure": { ++ "title": "Configure blind", ++ "menu_options": { ++ "edit": "Edit identities and travel times", ++ "test_existing": "Test motor command", ++ "developer_tools": "Developer tools", ++ "calibrate": "Calibrate" ++ } ++ }, ++ "developer_tools": { ++ "title": "Developer tools", ++ "description": "Selected blind: {selected_blind}\n\nLast received:\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nTime: {last_time}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.", ++ "menu_options": { ++ "test_open": "Test Open", ++ "test_close": "Test Close", ++ "test_stop": "Test Stop", ++ "copy_diagnostics": "Copy diagnostics" ++ } ++ }, ++ "copy_diagnostics": { ++ "title": "Copy diagnostics", ++ "description": "Select and copy the diagnostic text below. Press Submit to return.", ++ "data": { ++ "diagnostics": "Diagnostics" ++ }, ++ "submit": "Return" ++ }, ++ "edit": { ++ "title": "Edit blind settings", ++ "description": "Changing protocol identities does not change the Home Assistant entity unique ID.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" ++ }, ++ "submit": "Save" + } + }, + "error": { +@@ -104,12 +191,14 @@ + "pairing_failed": "Kopplung fehlgeschlagen.", + "calibration_timeout": "Kalibrierung abgelaufen. Der Rollladen hat mehr als 5 Minuten für die Bewegung benötigt. Bitte überprüfen Sie das Gerät und versuchen Sie es erneut.", + "calibration_start_timeout": "Zeitüberschreitung beim Warten auf die Bewegung des Rollladens. Bitte stellen Sie sicher, dass das Gerät ordnungsgemäß angeschlossen ist, und versuchen Sie es erneut.", +- "unknown": "Ein unerwarteter Fehler ist während der Kalibrierung aufgetreten. Bitte versuchen Sie es erneut." ++ "unknown": "Ein unerwarteter Fehler ist während der Kalibrierung aufgetreten. Bitte versuchen Sie es erneut.", ++ "command_failed": "The serial command could not be queued. Check the USB connection and logs." + }, + "abort": { + "pairing_timeout": "Kein Gerät hat innerhalb von 2 Minuten geantwortet. Bitte versuchen Sie es erneut.", + "pairing_failed": "Kopplung fehlgeschlagen.", +- "reconfigure_successful": "Kalibrierung erfolgreich abgeschlossen" ++ "reconfigure_successful": "Kalibrierung erfolgreich abgeschlossen", ++ "command_test_successful": "The motor command test succeeded." + } + } + }, +@@ -151,6 +240,10 @@ + "pair": { + "name": "Gerät koppeln", + "description": "Aktivieren Sie den Kopplungsmodus am Schellenberg USB-Stick, um einen neuen Rollladenmotor zu koppeln. Drücken Sie nach dem Aufrufen dieses Dienstes innerhalb von 10 Sekunden die Kopplungstaste an Ihrem Rollladenmotor." ++ }, ++ "test_command": { ++ "name": "Test blind command", ++ "description": "Send open, close, or stop to a device enum without creating a cover." + } + } + } +diff --git a/custom_components/schellenberg_usb/translations/es.json b/custom_components/schellenberg_usb/translations/es.json +index 09e86f1..7aa5b47 100644 +--- a/custom_components/schellenberg_usb/translations/es.json ++++ b/custom_components/schellenberg_usb/translations/es.json +@@ -46,7 +46,8 @@ + "title": "Añadir una persiana", + "menu_options": { + "pair_device": "Emparejar y calibrar", +- "manual": "Añadir manualmente" ++ "manual": "Añadir manualmente", ++ "pair_test": "Pair and test (recommended)" + } + }, + "pair_device": { +@@ -62,7 +63,10 @@ + "device_id": "ID del dispositivo", + "device_enum": "Enum", + "open_time_seconds": "Tiempo de apertura", +- "close_time_seconds": "Tiempo de cierre" ++ "close_time_seconds": "Tiempo de cierre", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "invert_direction": "Invert open/close direction" + }, + "submit": "Añadir" + }, +@@ -92,6 +96,89 @@ + "title": "Calibración completada", + "description": "¡Calibración de {device_name} completada!\n\nTiempos medidos:\n- Tiempo de apertura: {open_time} segundos\n- Tiempo de cierre: {close_time} segundos\n\nEstos tiempos se utilizarán para rastrear con precisión la posición de la persiana.", + "submit": "Hecho" ++ }, ++ "pair_test": { ++ "title": "Pair and test", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes. After pairing, a short motor command will be tested before calibration.", ++ "submit": "Pair" ++ }, ++ "manual_next": { ++ "title": "Manual blind ready", ++ "menu_options": { ++ "test_motor": "Test motor command", ++ "save_manual": "Save without testing" ++ } ++ }, ++ "test_motor": { ++ "title": "Test motor command", ++ "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", ++ "submit": "Test" ++ }, ++ "did_motor_move": { ++ "title": "Did the motor move?", ++ "description": "Tested command identity {device_id}/{device_enum}.", ++ "data": { ++ "motor_moved": "The motor moved" ++ }, ++ "submit": "Continue" ++ }, ++ "test_success": { ++ "title": "Command test successful", ++ "menu_options": { ++ "calibration_close": "Continue to calibration", ++ "manual_times": "Save with manual travel times" ++ } ++ }, ++ "manual_times": { ++ "title": "Enter travel times", ++ "description": "Enter the measured full opening and closing times.", ++ "data": { ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time" ++ }, ++ "submit": "Save" ++ }, ++ "reconfigure": { ++ "title": "Configure blind", ++ "menu_options": { ++ "edit": "Edit identities and travel times", ++ "test_existing": "Test motor command", ++ "developer_tools": "Developer tools", ++ "calibrate": "Calibrate" ++ } ++ }, ++ "developer_tools": { ++ "title": "Developer tools", ++ "description": "Selected blind: {selected_blind}\n\nLast received:\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nTime: {last_time}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.", ++ "menu_options": { ++ "test_open": "Test Open", ++ "test_close": "Test Close", ++ "test_stop": "Test Stop", ++ "copy_diagnostics": "Copy diagnostics" ++ } ++ }, ++ "copy_diagnostics": { ++ "title": "Copy diagnostics", ++ "description": "Select and copy the diagnostic text below. Press Submit to return.", ++ "data": { ++ "diagnostics": "Diagnostics" ++ }, ++ "submit": "Return" ++ }, ++ "edit": { ++ "title": "Edit blind settings", ++ "description": "Changing protocol identities does not change the Home Assistant entity unique ID.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" ++ }, ++ "submit": "Save" + } + }, + "error": { +@@ -104,12 +191,14 @@ + "pairing_failed": "Error en el emparejamiento.", + "calibration_timeout": "Tiempo de calibración agotado. La persiana tardó más de 5 minutos en completar el movimiento. Por favor, verifique el dispositivo e inténtelo de nuevo.", + "calibration_start_timeout": "Tiempo de espera agotado para que la persiana comience a moverse. Por favor, asegúrese de que el dispositivo esté correctamente conectado e inténtelo de nuevo.", +- "unknown": "Se produjo un error inesperado durante la calibración. Por favor, inténtelo de nuevo." ++ "unknown": "Se produjo un error inesperado durante la calibración. Por favor, inténtelo de nuevo.", ++ "command_failed": "The serial command could not be queued. Check the USB connection and logs." + }, + "abort": { + "pairing_timeout": "Ningún dispositivo respondió en 2 minutos. Por favor, inténtelo de nuevo.", + "pairing_failed": "Error en el emparejamiento.", +- "reconfigure_successful": "Calibración completada con éxito" ++ "reconfigure_successful": "Calibración completada con éxito", ++ "command_test_successful": "The motor command test succeeded." + } + } + }, +@@ -151,6 +240,10 @@ + "pair": { + "name": "Emparejar dispositivo", + "description": "Active el modo de emparejamiento en la memoria USB Schellenberg para emparejar un nuevo motor de persiana. Después de llamar a este servicio, presione el botón de emparejamiento en su motor de persiana en los próximos 10 segundos." ++ }, ++ "test_command": { ++ "name": "Test blind command", ++ "description": "Send open, close, or stop to a device enum without creating a cover." + } + } + } +diff --git a/custom_components/schellenberg_usb/translations/fr.json b/custom_components/schellenberg_usb/translations/fr.json +index 3c519d9..4349e99 100644 +--- a/custom_components/schellenberg_usb/translations/fr.json ++++ b/custom_components/schellenberg_usb/translations/fr.json +@@ -46,7 +46,8 @@ + "title": "Ajouter un volet", + "menu_options": { + "pair_device": "Appairer et calibrer", +- "manual": "Ajouter manuellement" ++ "manual": "Ajouter manuellement", ++ "pair_test": "Pair and test (recommended)" + } + }, + "pair_device": { +@@ -62,7 +63,10 @@ + "device_id": "ID de l'appareil", + "device_enum": "Énumération", + "open_time_seconds": "Durée d'ouverture", +- "close_time_seconds": "Durée de fermeture" ++ "close_time_seconds": "Durée de fermeture", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "invert_direction": "Invert open/close direction" + }, + "submit": "Ajouter" + }, +@@ -92,6 +96,89 @@ + "title": "Calibration terminée", + "description": "Calibration de {device_name} terminée !\n\nTemps mesurés :\n- Temps d'ouverture : {open_time} secondes\n- Temps de fermeture : {close_time} secondes\n\nCes temps seront utilisés pour suivre précisément la position du volet.", + "submit": "Terminé" ++ }, ++ "pair_test": { ++ "title": "Pair and test", ++ "description": "Activate pairing on the USB stick, then put the blind motor into pairing mode within 2 minutes. After pairing, a short motor command will be tested before calibration.", ++ "submit": "Pair" ++ }, ++ "manual_next": { ++ "title": "Manual blind ready", ++ "menu_options": { ++ "test_motor": "Test motor command", ++ "save_manual": "Save without testing" ++ } ++ }, ++ "test_motor": { ++ "title": "Test motor command", ++ "description": "This sends a short Open command and then Stop to command identity {device_id}/{device_enum}. Watch the motor and press Test.", ++ "submit": "Test" ++ }, ++ "did_motor_move": { ++ "title": "Did the motor move?", ++ "description": "Tested command identity {device_id}/{device_enum}.", ++ "data": { ++ "motor_moved": "The motor moved" ++ }, ++ "submit": "Continue" ++ }, ++ "test_success": { ++ "title": "Command test successful", ++ "menu_options": { ++ "calibration_close": "Continue to calibration", ++ "manual_times": "Save with manual travel times" ++ } ++ }, ++ "manual_times": { ++ "title": "Enter travel times", ++ "description": "Enter the measured full opening and closing times.", ++ "data": { ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time" ++ }, ++ "submit": "Save" ++ }, ++ "reconfigure": { ++ "title": "Configure blind", ++ "menu_options": { ++ "edit": "Edit identities and travel times", ++ "test_existing": "Test motor command", ++ "developer_tools": "Developer tools", ++ "calibrate": "Calibrate" ++ } ++ }, ++ "developer_tools": { ++ "title": "Developer tools", ++ "description": "Selected blind: {selected_blind}\n\nLast received:\nDevice ID: {last_device_id}\nEnum: {last_enum}\nCommand: {last_command}\nTime: {last_time}\n\nCurrent transmit target:\nDevice ID: {command_device_id}\nEnum: {command_enum}\n\n{result}\n\nOpen and Close continue moving until Stop or an end position is reached.", ++ "menu_options": { ++ "test_open": "Test Open", ++ "test_close": "Test Close", ++ "test_stop": "Test Stop", ++ "copy_diagnostics": "Copy diagnostics" ++ } ++ }, ++ "copy_diagnostics": { ++ "title": "Copy diagnostics", ++ "description": "Select and copy the diagnostic text below. Press Submit to return.", ++ "data": { ++ "diagnostics": "Diagnostics" ++ }, ++ "submit": "Return" ++ }, ++ "edit": { ++ "title": "Edit blind settings", ++ "description": "Changing protocol identities does not change the Home Assistant entity unique ID.", ++ "data": { ++ "device_name": "Name", ++ "device_id": "Command device ID", ++ "device_enum": "Command enum", ++ "status_device_id": "Status device ID (optional)", ++ "status_enum": "Status enum (optional)", ++ "open_time_seconds": "Open time", ++ "close_time_seconds": "Close time", ++ "invert_direction": "Invert open/close direction" ++ }, ++ "submit": "Save" + } + }, + "error": { +@@ -104,12 +191,14 @@ + "pairing_failed": "Échec de l'appairage.", + "calibration_timeout": "Délai de calibration dépassé. Le volet a mis plus de 5 minutes pour terminer le mouvement. Veuillez vérifier l'appareil et réessayer.", + "calibration_start_timeout": "Délai d'attente dépassé pour le début du mouvement du volet. Veuillez vous assurer que l'appareil est correctement connecté et réessayer.", +- "unknown": "Une erreur inattendue s'est produite pendant la calibration. Veuillez réessayer." ++ "unknown": "Une erreur inattendue s'est produite pendant la calibration. Veuillez réessayer.", ++ "command_failed": "The serial command could not be queued. Check the USB connection and logs." + }, + "abort": { + "pairing_timeout": "Aucun appareil n'a répondu dans les 2 minutes. Veuillez réessayer.", + "pairing_failed": "Échec de l'appairage.", +- "reconfigure_successful": "Calibration terminée avec succès" ++ "reconfigure_successful": "Calibration terminée avec succès", ++ "command_test_successful": "The motor command test succeeded." + } + } + }, +@@ -151,6 +240,10 @@ + "pair": { + "name": "Appairer l'appareil", + "description": "Activez le mode d'appairage sur la clé USB Schellenberg pour appairer un nouveau moteur de volet. Après avoir appelé ce service, appuyez sur le bouton d'appairage de votre moteur de volet dans les 10 secondes." ++ }, ++ "test_command": { ++ "name": "Test blind command", ++ "description": "Send open, close, or stop to a device enum without creating a cover." + } + } + } +diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py +index 3e39f78..08b8527 100644 +--- a/tests/test_config_flow.py ++++ b/tests/test_config_flow.py +@@ -50,6 +50,26 @@ async def test_blind_subentry_flow_shows_setup_method_menu() -> None: + assert result["menu_options"] == ["pair_test", "pair_device", "manual"] + + ++@pytest.mark.asyncio ++async def test_all_blind_subentry_navigation_menus_expose_expected_options() -> None: ++ """Test every static subentry navigation menu uses the translated keys.""" ++ flow = _create_flow() ++ ++ manual_next = await flow.async_step_manual_next() ++ reconfigure = await flow.async_step_reconfigure() ++ flow._pairing_workflow = "hybrid" ++ test_success = await flow.async_step_did_motor_move({"motor_moved": True}) ++ ++ assert manual_next["menu_options"] == ["test_motor", "save_manual"] ++ assert reconfigure["menu_options"] == [ ++ "edit", ++ "test_existing", ++ "developer_tools", ++ "calibrate", ++ ] ++ assert test_success["menu_options"] == ["calibration_close", "manual_times"] ++ ++ + @pytest.mark.asyncio + async def test_legacy_pairing_path_is_unchanged() -> None: + """Test selecting legacy pairing still reaches naming and calibration.""" +@@ -274,6 +294,12 @@ async def test_developer_tools_show_last_frame_and_send_selected_target() -> Non + assert placeholders["last_device_id"] == "3720B8" + assert placeholders["command_device_id"] == "F2B8D5" + assert placeholders["command_enum"] == "23" ++ assert result["menu_options"] == [ ++ "test_open", ++ "test_close", ++ "test_stop", ++ "copy_diagnostics", ++ ] + api.control_blind.assert_awaited_once_with("23", CMD_DOWN, device_id="F2B8D5") + command_placeholders = command_result["description_placeholders"] + assert command_placeholders is not None +diff --git a/tests/test_translations.py b/tests/test_translations.py +new file mode 100644 +index 0000000..23833a9 +--- /dev/null ++++ b/tests/test_translations.py +@@ -0,0 +1,63 @@ ++"""Test config-flow menu translation coverage.""" ++ ++from __future__ import annotations ++ ++import json ++from pathlib import Path ++from typing import Any ++ ++COMPONENT_DIR = Path(__file__).parents[1] / "custom_components" / "schellenberg_usb" ++EXPECTED_MENU_OPTIONS = { ++ "user": {"pair_test", "pair_device", "manual"}, ++ "manual_next": {"test_motor", "save_manual"}, ++ "test_success": {"calibration_close", "manual_times"}, ++ "reconfigure": {"edit", "test_existing", "developer_tools", "calibrate"}, ++ "developer_tools": { ++ "test_open", ++ "test_close", ++ "test_stop", ++ "copy_diagnostics", ++ }, ++} ++ ++ ++def _load_json(path: Path) -> dict[str, Any]: ++ """Load one integration translation document.""" ++ return json.loads(path.read_text(encoding="utf-8")) ++ ++ ++def _leaf_paths(value: Any, prefix: str = "") -> set[str]: ++ """Return all leaf paths in a nested translation document.""" ++ if not isinstance(value, dict): ++ return {prefix} ++ return { ++ leaf ++ for key, child in value.items() ++ for leaf in _leaf_paths(child, f"{prefix}.{key}" if prefix else key) ++ } ++ ++ ++def test_all_config_subentry_menu_options_have_labels() -> None: ++ """Every runtime menu option must have a non-empty label in every locale.""" ++ paths = [ ++ COMPONENT_DIR / "strings.json", ++ *sorted((COMPONENT_DIR / "translations").glob("*.json")), ++ ] ++ ++ for path in paths: ++ steps = _load_json(path)["config_subentries"]["blind"]["step"] ++ for step_id, expected_options in EXPECTED_MENU_OPTIONS.items(): ++ labels = steps[step_id]["menu_options"] ++ assert set(labels) == expected_options, f"{path.name}: {step_id}" ++ assert all( ++ isinstance(label, str) and label.strip() for label in labels.values() ++ ), f"{path.name}: {step_id} contains a blank label" ++ ++ ++def test_locale_files_cover_all_source_translation_keys() -> None: ++ """Partial locale files must not hide newer English flow labels.""" ++ source_paths = _leaf_paths(_load_json(COMPONENT_DIR / "strings.json")) ++ ++ for path in sorted((COMPONENT_DIR / "translations").glob("*.json")): ++ missing = source_paths - _leaf_paths(_load_json(path)) ++ assert not missing, f"{path.name} is missing: {sorted(missing)}" diff --git a/schellenberg-usb-pyserial-asyncio-fast.patch b/schellenberg-usb-pyserial-asyncio-fast.patch new file mode 100644 index 0000000..fed5fc3 --- /dev/null +++ b/schellenberg-usb-pyserial-asyncio-fast.patch @@ -0,0 +1,160 @@ +diff --git a/custom_components/schellenberg_usb/api.py b/custom_components/schellenberg_usb/api.py +index f760b18..ed7cf41 100644 +--- a/custom_components/schellenberg_usb/api.py ++++ b/custom_components/schellenberg_usb/api.py +@@ -5,9 +5,10 @@ from __future__ import annotations + import asyncio + import logging + from collections.abc import Callable +-from typing import Any ++from typing import Any, cast + +-import serial_asyncio ++import serial ++import serial_asyncio_fast + from homeassistant.core import HomeAssistant, callback + from homeassistant.helpers.dispatcher import async_dispatcher_send + from homeassistant.util import dt as dt_util +@@ -100,15 +101,15 @@ class SchellenbergUsbApi: + self._is_connecting = True + _LOGGER.info("Connecting to Schellenberg USB stick at %s", self.port) + try: +- ( +- self._transport, +- self._protocol, +- ) = await serial_asyncio.create_serial_connection( ++ transport, protocol = await serial_asyncio_fast.create_serial_connection( + self.hass.loop, + lambda: SchellenbergProtocol(self._handle_message, self), + self.port, + baudrate=112500, + ) ++ self._transport = transport ++ # The factory above always creates this concrete protocol type. ++ self._protocol = cast(SchellenbergProtocol, protocol) + self._is_connecting = False + _LOGGER.info("Successfully connected to Schellenberg USB stick") + +@@ -149,7 +150,7 @@ class SchellenbergUsbApi: + _LOGGER.info("Hub device ID retrieved: %s", self._hub_id) + else: + _LOGGER.warning("Failed to retrieve hub device ID") +- except (serial_asyncio.serial.SerialException, OSError) as err: ++ except (serial.SerialException, OSError) as err: + _LOGGER.error( + "Failed to connect to %s: %s. Retrying in 5 seconds", + self.port, +diff --git a/custom_components/schellenberg_usb/manifest.json b/custom_components/schellenberg_usb/manifest.json +index 0cd6518..0330d1d 100644 +--- a/custom_components/schellenberg_usb/manifest.json ++++ b/custom_components/schellenberg_usb/manifest.json +@@ -7,7 +7,7 @@ + "integration_type": "hub", + "iot_class": "local_push", + "issue_tracker": "https://github.com/GimpArm/schellenberg_usb/issues", +- "requirements": ["pyserial-asyncio==0.6"], ++ "requirements": ["pyserial-asyncio-fast==0.16"], + "usb": [ + { + "vid": "16C0", +diff --git a/pyproject.toml b/pyproject.toml +index 3447bbb..25799c9 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -4,7 +4,7 @@ version = "1.0.0" + readme = "README.md" + requires-python = ">=3.13.2" + dependencies = [ +- "pyserial-asyncio==0.6", ++ "pyserial-asyncio-fast==0.16", + ] + + [dependency-groups] +diff --git a/tests/test_api.py b/tests/test_api.py +index d3909c7..528f61f 100644 +--- a/tests/test_api.py ++++ b/tests/test_api.py +@@ -75,7 +75,7 @@ async def test_api_connect_success(hass: HomeAssistant) -> None: + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + + with patch( +- "serial_asyncio.create_serial_connection", new_callable=AsyncMock ++ "serial_asyncio_fast.create_serial_connection", new_callable=AsyncMock + ) as mock_create: + mock_transport = MagicMock() + mock_protocol = MagicMock() +@@ -84,7 +84,13 @@ async def test_api_connect_success(hass: HomeAssistant) -> None: + await api.connect() + + assert api._is_connecting is False +- mock_create.assert_called_once() ++ mock_create.assert_awaited_once() ++ serial_call = mock_create.await_args ++ assert serial_call is not None ++ assert serial_call.args[0] is hass.loop ++ assert callable(serial_call.args[1]) ++ assert serial_call.args[2] == "/dev/ttyUSB0" ++ assert serial_call.kwargs == {"baudrate": 112500} + + + @pytest.mark.asyncio +@@ -94,7 +100,7 @@ async def test_api_connect_already_connecting(hass: HomeAssistant) -> None: + api._is_connecting = True + + with patch( +- "serial_asyncio.create_serial_connection", new_callable=AsyncMock ++ "serial_asyncio_fast.create_serial_connection", new_callable=AsyncMock + ) as mock_create: + await api.connect() + +@@ -153,7 +159,7 @@ async def test_api_send_command_not_connected(hass: HomeAssistant) -> None: + api._is_connected = False + + # Should not raise but also not send +- with patch("serial_asyncio.create_serial_connection", new_callable=AsyncMock): ++ with patch("serial_asyncio_fast.create_serial_connection", new_callable=AsyncMock): + await api.send_command("test_command") + # This would not raise an error, but wouldn't send either + +diff --git a/uv.lock b/uv.lock +index a1259e5..aa894c1 100644 +--- a/uv.lock ++++ b/uv.lock +@@ -2469,15 +2469,15 @@ wheels = [ + ] + + [[package]] +-name = "pyserial-asyncio" +-version = "0.6" ++name = "pyserial-asyncio-fast" ++version = "0.16" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "pyserial" }, + ] +-sdist = { url = "https://files.pythonhosted.org/packages/4a/9a/8477699dcbc1882ea51dcff4d3c25aa3f2063ed8f7d7a849fd8f610506b6/pyserial-asyncio-0.6.tar.gz", hash = "sha256:b6032923e05e9d75ec17a5af9a98429c46d2839adfaf80604d52e0faacd7a32f", size = 31322, upload-time = "2021-09-30T22:29:02.174Z" } ++sdist = { url = "https://files.pythonhosted.org/packages/74/d1/6c444e0f6b886345a7993d358c6734ccc440521cdca4999601e86f111708/pyserial_asyncio_fast-0.16.tar.gz", hash = "sha256:fd52643380406739d777014b0aea0873d756b542eb62f7556567239cec007115", size = 32696, upload-time = "2025-03-27T02:35:20.624Z" } + wheels = [ +- { url = "https://files.pythonhosted.org/packages/27/24/c820cf15f87f7b164e83710c1852d4f900d9793961579e5ef64189bc0c10/pyserial_asyncio-0.6-py3-none-any.whl", hash = "sha256:de9337922619421b62b9b1a84048634b3ac520e1d690a674ed246a2af7ce1fc5", size = 7594, upload-time = "2021-09-30T22:29:00.12Z" }, ++ { url = "https://files.pythonhosted.org/packages/1b/19/f76987bad313bb2dabf21914c1ec7441a1e846f05764f9948f1ccc2640a8/pyserial_asyncio_fast-0.16-py3-none-any.whl", hash = "sha256:88939d94e341a04c0c8bc3c1ed4e874439cb5a1e21ccfb0fd7315a8e45df1687", size = 9729, upload-time = "2025-03-27T02:35:19.062Z" }, + ] + + [[package]] +@@ -2938,7 +2938,7 @@ name = "schellenber-usb" + version = "1.0.0" + source = { virtual = "." } + dependencies = [ +- { name = "pyserial-asyncio" }, ++ { name = "pyserial-asyncio-fast" }, + ] + + [package.dev-dependencies] +@@ -2978,7 +2978,7 @@ test = [ + ] + + [package.metadata] +-requires-dist = [{ name = "pyserial-asyncio", specifier = "==0.6" }] ++requires-dist = [{ name = "pyserial-asyncio-fast", specifier = "==0.16" }] + + [package.metadata.requires-dev] + dev = [ diff --git a/schellenberg-usb-subentry-persistence.patch b/schellenberg-usb-subentry-persistence.patch new file mode 100644 index 0000000..61aa852 --- /dev/null +++ b/schellenberg-usb-subentry-persistence.patch @@ -0,0 +1,361 @@ +diff --git a/custom_components/schellenberg_usb/__init__.py b/custom_components/schellenberg_usb/__init__.py +index 0549a63..958b0d9 100644 +--- a/custom_components/schellenberg_usb/__init__.py ++++ b/custom_components/schellenberg_usb/__init__.py +@@ -36,9 +36,6 @@ CONFIG_SCHEMA = vol.Schema( + extra=vol.ALLOW_EXTRA, + ) + +-# Store setup callbacks for each entry so we can track subentries +-_SETUP_CALLBACKS: dict[str, dict] = {} +- + + def _validate_device_id(value: str) -> str: + """Validate and normalize a six-character protocol device ID.""" +@@ -199,41 +196,39 @@ async def async_setup_entry( + # Forward setup to the hub's platforms (cover, sensor, switch) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + +- # Reload when subentries are added, removed, renamed, or edited. ++ # Reload when blind subentries are added, removed, renamed, or edited. ++ known_subentries = { ++ subentry_id: ( ++ subentry.subentry_type, ++ subentry.title, ++ subentry.unique_id, ++ dict(subentry.data), ++ ) ++ for subentry_id, subentry in entry.subentries.items() ++ } ++ + async def _on_entry_updated( + hass_instance: HomeAssistant, updated_entry: SchellenbergConfigEntry + ) -> None: + """Handle changes to the hub's blind subentries.""" ++ nonlocal known_subentries + current_subentries = { +- subentry_id: (subentry.title, dict(subentry.data)) ++ subentry_id: ( ++ subentry.subentry_type, ++ subentry.title, ++ subentry.unique_id, ++ dict(subentry.data), ++ ) + for subentry_id, subentry in updated_entry.subentries.items() + } +- known_subentries = _SETUP_CALLBACKS.get(entry.entry_id, {}).get( +- "subentries", {} +- ) +- +- _LOGGER.debug( +- "Entry update detected. Current subentries: %s, Known subentries: %s", +- current_subentries, +- known_subentries, +- ) +- + if current_subentries != known_subentries: + _LOGGER.info( + "Subentry configuration changed; reloading entry %s", entry.entry_id + ) +- _SETUP_CALLBACKS[entry.entry_id]["subentries"] = current_subentries ++ known_subentries = current_subentries + await hass_instance.config_entries.async_reload(entry.entry_id) + +- entry.add_update_listener(_on_entry_updated) +- +- # Track known subentries +- if entry.entry_id not in _SETUP_CALLBACKS: +- _SETUP_CALLBACKS[entry.entry_id] = {} +- _SETUP_CALLBACKS[entry.entry_id]["subentries"] = { +- subentry_id: (subentry.title, dict(subentry.data)) +- for subentry_id, subentry in entry.subentries.items() +- } ++ entry.async_on_unload(entry.add_update_listener(_on_entry_updated)) + + return True + +diff --git a/custom_components/schellenberg_usb/cover.py b/custom_components/schellenberg_usb/cover.py +index ef702e9..d07f77f 100644 +--- a/custom_components/schellenberg_usb/cover.py ++++ b/custom_components/schellenberg_usb/cover.py +@@ -41,7 +41,7 @@ from .const import ( + SIGNAL_CALIBRATION_COMPLETED, + SIGNAL_DEVICE_EVENT, + SIGNAL_STICK_STATUS_UPDATED, +- SUBENTRY_TYPE_LED, ++ SUBENTRY_TYPE_BLIND, + SchellenbergConfigEntry, + ) + +@@ -72,19 +72,20 @@ async def async_setup_entry( + api = entry.runtime_data + + # Get paired devices from subentries +- subentries = entry.subentries.values() +- _LOGGER.info("Hub has %d subentries (paired devices)", len(entry.subentries)) +- +- if not entry.subentries: +- _LOGGER.info("No subentries (paired devices) found for hub") ++ subentries = [ ++ subentry ++ for subentry in entry.subentries.values() ++ if subentry.subentry_type == SUBENTRY_TYPE_BLIND ++ ] ++ _LOGGER.info("Hub has %d saved blind subentries", len(subentries)) ++ ++ if not subentries: ++ _LOGGER.info("No saved blind subentries found for hub") + return + +- _LOGGER.info("Loading %d paired Schellenberg devices", len(entry.subentries)) ++ _LOGGER.info("Loading %d saved Schellenberg blinds", len(subentries)) + + for subentry in subentries: +- # Skip LED subentry; handled by switch platform +- if subentry.subentry_type == SUBENTRY_TYPE_LED: +- continue + legacy_device_id = subentry.data.get(CONF_DEVICE_ID) + legacy_device_enum = subentry.data.get(CONF_DEVICE_ENUM) + command_device_id = ( +diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py +index 3e39f78..a4ca84b 100644 +--- a/tests/test_config_flow.py ++++ b/tests/test_config_flow.py +@@ -6,8 +6,10 @@ from types import MappingProxyType + from unittest.mock import AsyncMock, MagicMock, call, patch + + import pytest +-from homeassistant.config_entries import ConfigSubentryFlow, SOURCE_USER ++from homeassistant.config_entries import ConfigEntries, ConfigSubentryFlow, SOURCE_USER ++from homeassistant.core import HomeAssistant + from homeassistant.data_entry_flow import FlowResultType ++from pytest_homeassistant_custom_component.common import MockConfigEntry + + from custom_components.schellenberg_usb.config_flow import ( + SchellenbergPairingSubentryFlow, +@@ -28,6 +30,8 @@ from custom_components.schellenberg_usb.const import ( + CONF_OPEN_TIME_SECONDS, + CONF_STATUS_DEVICE_ID, + CONF_STATUS_ENUM, ++ DOMAIN, ++ SUBENTRY_TYPE_BLIND, + ) + from custom_components.schellenberg_usb.options_flow_calibration import ( + CalibrationFlowHandler, +@@ -128,6 +132,60 @@ async def test_manual_setup_stores_separate_command_and_status_identity() -> Non + assert result["unique_id"] == "F2B8D5" + + ++@pytest.mark.asyncio ++async def test_manual_subentry_persists_through_storage_reload( ++ hass: HomeAssistant, ++ enable_custom_integrations: None, ++) -> None: ++ """Test manual add is committed by HA and survives config-entry hydration.""" ++ entry = MockConfigEntry( ++ domain=DOMAIN, ++ data={"serial_port": "/dev/ttyUSB0"}, ++ title="Schellenberg USB", ++ ) ++ entry.add_to_hass(hass) ++ ++ result = await hass.config_entries.subentries.async_init( ++ (entry.entry_id, SUBENTRY_TYPE_BLIND), ++ context={"source": SOURCE_USER}, ++ ) ++ result = await hass.config_entries.subentries.async_configure( ++ result["flow_id"], {"next_step_id": "manual"} ++ ) ++ result = await hass.config_entries.subentries.async_configure( ++ result["flow_id"], ++ { ++ CONF_DEVICE_NAME: "Sitting room door", ++ CONF_DEVICE_ID: "F2B8D5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720B8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME_SECONDS: 25.06, ++ CONF_CLOSE_TIME_SECONDS: 23.05, ++ CONF_INVERT_DIRECTION: False, ++ }, ++ ) ++ await hass.config_entries.subentries.async_configure( ++ result["flow_id"], {"next_step_id": "save_manual"} ++ ) ++ saved_subentry_id = next(iter(entry.subentries)) ++ ++ await hass.config_entries._store.async_save(hass.config_entries._data_to_save()) ++ restarted_config_entries = ConfigEntries(hass, {}) ++ await restarted_config_entries.async_initialize() ++ restored = restarted_config_entries.async_get_entry(entry.entry_id) ++ ++ assert restored is not None ++ assert len(restored.subentries) == 1 ++ subentry = next(iter(restored.subentries.values())) ++ assert subentry.subentry_id == saved_subentry_id ++ assert subentry.subentry_type == SUBENTRY_TYPE_BLIND ++ assert subentry.title == "Sitting room door" ++ assert subentry.unique_id == "F2B8D5" ++ assert subentry.data[CONF_STATUS_DEVICE_ID] == "3720B8" ++ assert subentry.data[CONF_OPEN_TIME] == 25.06 ++ ++ + @pytest.mark.asyncio + async def test_manual_setup_validates_protocol_values() -> None: + """Test validation of command/status identities and travel times.""" +diff --git a/tests/test_cover.py b/tests/test_cover.py +index 99d3f96..f6813bc 100644 +--- a/tests/test_cover.py ++++ b/tests/test_cover.py +@@ -15,12 +15,19 @@ from homeassistant.helpers import device_registry as dr + from custom_components.schellenberg_usb.api import SchellenbergUsbApi + from custom_components.schellenberg_usb.const import ( + CONF_CLOSE_TIME, ++ CONF_COMMAND_DEVICE_ID, ++ CONF_COMMAND_ENUM, ++ CONF_DEVICE_ENUM, ++ CONF_DEVICE_ID, + CONF_OPEN_TIME, + CONF_SERIAL_PORT, ++ CONF_STATUS_DEVICE_ID, ++ CONF_STATUS_ENUM, + DOMAIN, + EVENT_STARTED_MOVING_DOWN, + EVENT_STARTED_MOVING_UP, + EVENT_STOPPED, ++ SUBENTRY_TYPE_BLIND, + ) + from custom_components.schellenberg_usb.cover import ( + DEFAULT_TRAVEL_TIME, +@@ -57,6 +64,7 @@ def mock_config_entry(hass: HomeAssistant) -> ConfigEntry: + # Create a real subentry dict instead of MagicMock to avoid serialization issues + subentry = MagicMock() + subentry.subentry_id = "sub1" ++ subentry.subentry_type = SUBENTRY_TYPE_BLIND + subentry.data = { + "device_id": "ABC123", + "device_enum": "01", +@@ -116,6 +124,60 @@ async def test_async_setup_entry_creates_covers( + assert entities[0]._device_enum == "01" + + ++@pytest.mark.asyncio ++async def test_setup_restores_manual_cover_from_persisted_subentry( ++ hass: HomeAssistant, ++ mock_api: SchellenbergUsbApi, ++) -> None: ++ """Test a stored manual blind is recreated during platform setup.""" ++ entry = ConfigEntry( ++ version=1, ++ minor_version=1, ++ domain=DOMAIN, ++ title="Schellenberg USB", ++ data={CONF_SERIAL_PORT: "/dev/ttyUSB0"}, ++ options={}, ++ source="user", ++ unique_id="/dev/ttyUSB0", ++ discovery_keys=MappingProxyType({}), ++ subentries_data=[ ++ { ++ "subentry_id": "manual_blind", ++ "subentry_type": SUBENTRY_TYPE_BLIND, ++ "title": "Sitting room door", ++ "unique_id": "F2B8D5", ++ "data": { ++ CONF_DEVICE_ID: "F2B8D5", ++ CONF_DEVICE_ENUM: "23", ++ CONF_COMMAND_DEVICE_ID: "F2B8D5", ++ CONF_COMMAND_ENUM: "23", ++ CONF_STATUS_DEVICE_ID: "3720B8", ++ CONF_STATUS_ENUM: "08", ++ CONF_OPEN_TIME: 25.06, ++ CONF_CLOSE_TIME: 23.05, ++ }, ++ } ++ ], ++ ) ++ entry.runtime_data = mock_api ++ hass.config_entries._entries[entry.entry_id] = entry ++ add_entities = MagicMock() ++ ++ await async_setup_entry(hass, entry, add_entities) ++ ++ add_entities.assert_called_once() ++ assert add_entities.call_args.kwargs == {"config_subentry_id": "manual_blind"} ++ cover = add_entities.call_args.args[0][0] ++ assert isinstance(cover, SchellenbergCover) ++ assert cover.name == "Sitting room door" ++ assert cover.unique_id == "schellenberg_F2B8D5" ++ assert cover._command_enum == "23" ++ assert cover._status_device_id == "3720B8" ++ assert cover._status_enum == "08" ++ assert cover._travel_time_open == 25.06 ++ assert cover._travel_time_close == 23.05 ++ ++ + @pytest.mark.asyncio + async def test_cover_initialization( + hass: HomeAssistant, +diff --git a/tests/test_init.py b/tests/test_init.py +index a28d5a9..9b8a312 100644 +--- a/tests/test_init.py ++++ b/tests/test_init.py +@@ -6,7 +6,7 @@ from types import MappingProxyType + from unittest.mock import AsyncMock, patch + + import pytest +-from homeassistant.config_entries import ConfigEntry, ConfigEntryState ++from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigSubentry + from homeassistant.core import HomeAssistant + from homeassistant.helpers import device_registry as dr + +@@ -20,6 +20,7 @@ from custom_components.schellenberg_usb.const import ( + DOMAIN, + PLATFORMS, + SERVICE_TEST_COMMAND, ++ SUBENTRY_TYPE_BLIND, + ) + + +@@ -67,6 +68,41 @@ async def test_async_setup_entry_basic( + assert isinstance(mock_config_entry.runtime_data, SchellenbergUsbApi) + + ++@pytest.mark.asyncio ++async def test_subentry_change_listener_reloads_once_and_is_removed_on_unload( ++ hass: HomeAssistant, mock_config_entry: ConfigEntry ++) -> None: ++ """Test subentry reload listeners do not accumulate across reloads.""" ++ from custom_components.schellenberg_usb import async_setup_entry ++ ++ with ( ++ patch.object(SchellenbergUsbApi, "connect", new_callable=AsyncMock), ++ patch.object( ++ hass.config_entries, "async_forward_entry_setups", new_callable=AsyncMock ++ ), ++ patch.object( ++ hass.config_entries, "async_reload", new_callable=AsyncMock ++ ) as reload_entry, ++ ): ++ await async_setup_entry(hass, mock_config_entry) ++ hass.config_entries.async_add_subentry( ++ mock_config_entry, ++ ConfigSubentry( ++ data=MappingProxyType({CONF_DEVICE_ID: "F2B8D5"}), ++ subentry_type=SUBENTRY_TYPE_BLIND, ++ title="Sitting room door", ++ unique_id="F2B8D5", ++ ), ++ ) ++ await hass.async_block_till_done() ++ ++ reload_entry.assert_awaited_once_with(mock_config_entry.entry_id) ++ assert len(mock_config_entry.update_listeners) == 1 ++ await mock_config_entry._async_process_on_unload(hass) ++ ++ assert mock_config_entry.update_listeners == [] ++ ++ + @pytest.mark.asyncio + async def test_async_setup_registers_test_command_service( + hass: HomeAssistant, mock_config_entry: ConfigEntry diff --git a/tests/test_api.py b/tests/test_api.py index d3909c7..528f61f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -75,7 +75,7 @@ async def test_api_connect_success(hass: HomeAssistant) -> None: api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") with patch( - "serial_asyncio.create_serial_connection", new_callable=AsyncMock + "serial_asyncio_fast.create_serial_connection", new_callable=AsyncMock ) as mock_create: mock_transport = MagicMock() mock_protocol = MagicMock() @@ -84,7 +84,13 @@ async def test_api_connect_success(hass: HomeAssistant) -> None: await api.connect() assert api._is_connecting is False - mock_create.assert_called_once() + mock_create.assert_awaited_once() + serial_call = mock_create.await_args + assert serial_call is not None + assert serial_call.args[0] is hass.loop + assert callable(serial_call.args[1]) + assert serial_call.args[2] == "/dev/ttyUSB0" + assert serial_call.kwargs == {"baudrate": 112500} @pytest.mark.asyncio @@ -94,7 +100,7 @@ async def test_api_connect_already_connecting(hass: HomeAssistant) -> None: api._is_connecting = True with patch( - "serial_asyncio.create_serial_connection", new_callable=AsyncMock + "serial_asyncio_fast.create_serial_connection", new_callable=AsyncMock ) as mock_create: await api.connect() @@ -153,7 +159,7 @@ async def test_api_send_command_not_connected(hass: HomeAssistant) -> None: api._is_connected = False # Should not raise but also not send - with patch("serial_asyncio.create_serial_connection", new_callable=AsyncMock): + with patch("serial_asyncio_fast.create_serial_connection", new_callable=AsyncMock): await api.send_command("test_command") # This would not raise an error, but wouldn't send either diff --git a/tests/test_api_extended.py b/tests/test_api_extended.py index 47c9347..5475a76 100644 --- a/tests/test_api_extended.py +++ b/tests/test_api_extended.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from homeassistant.core import HomeAssistant @@ -26,6 +26,8 @@ async def test_api_control_blind_up(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.control_blind("10", CMD_UP) @@ -44,6 +46,8 @@ async def test_api_control_blind_down(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.control_blind("11", CMD_DOWN) @@ -61,6 +65,8 @@ async def test_api_control_blind_stop(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.control_blind("12", CMD_STOP) @@ -70,6 +76,185 @@ async def test_api_control_blind_stop(hass: HomeAssistant) -> None: assert CMD_STOP.encode() in call_args +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("device_enum", "expected_enum"), + [("8", "08"), ("08", "08"), ("0d", "0D")], +) +async def test_api_control_blind_preserves_two_digit_enum( + hass: HomeAssistant, device_enum: str, expected_enum: str +) -> None: + """Test command payloads retain leading-zero hexadecimal enum slots.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" + + assert await api.control_blind(device_enum, CMD_UP) is True + + payload = mock_transport.write.call_args.args[0] + assert payload.startswith(f"ss{expected_enum}9".encode()) + + +@pytest.mark.asyncio +async def test_developer_transmit_logs_payload_write_and_ack( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test diagnostic commands visibly log payload, write, and ACK results.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" + + with caplog.at_level("WARNING"): + assert await api.control_blind( + "10", CMD_UP, device_id="F2B8D5", source="developer_tools" + ) + api._handle_message("t1") + api._handle_message("t0") + + assert "Blind transmit payload source=developer_tools" in caplog.text + assert "payload=ss109010000" in caplog.text + assert "Serial write attempt source=developer_tools" in caplog.text + assert "Serial write succeeded source=developer_tools" in caplog.text + assert "result=written" in caplog.text + assert "ACK start response=t1 source=developer_tools" in caplog.text + assert "ACK complete response=t0 source=developer_tools" in caplog.text + assert "result=completed" in caplog.text + assert "stick_ack_only=True" in caplog.text + assert "motor_result=unknown" in caplog.text + + +@pytest.mark.asyncio +async def test_teach_motor_sends_60_then_40_and_waits_for_each_ack( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test motor teach sends 60 then 40 without claiming motor success.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" + + async def _complete_transmit(_: str) -> bool: + api._handle_message("t0") + return True + + api._wait_for_transmitter_idle = AsyncMock(side_effect=_complete_transmit) + + with caplog.at_level("WARNING"): + assert await api.teach_motor("0d", device_id="F2B8D5", source="developer_tools") + + assert [call.args[0] for call in mock_transport.write.call_args_list] == [ + b"ss0D9600000\r\n", + b"ss0D9400000\r\n", + ] + assert api._wait_for_transmitter_idle.await_args_list == [ + call("finishing motor teach phase teach_60"), + call("finishing motor teach phase finish_40"), + ] + assert "teach_payload=ss0D9600000" in caplog.text + assert "finish_payload=ss0D9400000" in caplog.text + assert "phases=60_then_40" in caplog.text + assert "stick_ack_only=True" in caplog.text + assert "motor_authorization=unverified" in caplog.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("payload", "expected"), + [ + ("ss109010000", b"ss109010000\r\n"), + ("SS0d9000000", b"ss0D9000000\r\n"), + ], +) +async def test_raw_transmit_preserves_exact_protocol_slots( + hass: HomeAssistant, payload: str, expected: bytes +) -> None: + """Test raw RF payloads preserve enum, repeat, command, and padding slots.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" + api._wait_for_transmitter_idle = AsyncMock(return_value=True) + + assert await api.send_raw_transmit(payload) + + mock_transport.write.assert_called_once_with(expected) + api._wait_for_transmitter_idle.assert_awaited_once_with("finishing raw RF transmit") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + ["ss10901000", "xx109010000", "ss10901G0000", "ss1090100000"], +) +async def test_raw_transmit_rejects_malformed_payloads( + hass: HomeAssistant, payload: str +) -> None: + """Test raw sending rejects short, long, non-hex, and wrong-prefix values.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" + + with pytest.raises(ValueError, match="exactly 'ss' plus 9"): + await api.send_raw_transmit(payload) + + mock_transport.write.assert_not_called() + + +@pytest.mark.asyncio +async def test_raw_transmit_timeout_latches_busy_state(hass: HomeAssistant) -> None: + """Test a missing raw-transmit t0 is reported and requires recovery.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" + api._wait_for_transmitter_idle = AsyncMock(return_value=False) + + assert not await api.send_raw_transmit("ss109010000") + + assert api.busy_latched is True + + +@pytest.mark.asyncio +async def test_developer_transmit_logs_write_exception( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test a diagnostic serial write exception is visible with its payload.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + mock_transport.write.side_effect = OSError("USB write failed") + api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" + + with caplog.at_level("WARNING"): + assert not await api.control_blind( + "0D", CMD_STOP, device_id="F2B8D5", source="developer_tools" + ) + + assert "Serial write failed source=developer_tools" in caplog.text + assert "payload=ss0D9000000" in caplog.text + assert "USB write failed" in caplog.text + assert "result=failed" in caplog.text + assert api._transmit_lock.locked() is False + assert api._transmit_busy is False + + @pytest.mark.asyncio async def test_api_control_blind_invalid_action(hass: HomeAssistant) -> None: """Test control blind with invalid action.""" @@ -78,6 +263,8 @@ async def test_api_control_blind_invalid_action(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.control_blind("10", "99") @@ -93,6 +280,8 @@ async def test_api_led_on(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.led_on() @@ -109,6 +298,8 @@ async def test_api_led_off(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.led_off() @@ -125,6 +316,8 @@ async def test_api_led_blink_valid_count(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.led_blink(5) @@ -141,6 +334,8 @@ async def test_api_led_blink_invalid_count(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.led_blink(10) # Invalid - should be 1-9 @@ -156,6 +351,8 @@ async def test_api_set_upper_endpoint(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.set_upper_endpoint("10") @@ -172,6 +369,8 @@ async def test_api_set_lower_endpoint(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.set_lower_endpoint("10") @@ -188,6 +387,8 @@ async def test_api_manual_up(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.manual_up("10") @@ -204,6 +405,8 @@ async def test_api_manual_down(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.manual_down("10") @@ -220,6 +423,8 @@ async def test_api_allow_pairing_on_device(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.allow_pairing_on_device("10") @@ -236,6 +441,8 @@ async def test_api_echo_on(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.echo_on() @@ -250,6 +457,8 @@ async def test_api_echo_off(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.echo_off() @@ -264,6 +473,8 @@ async def test_api_enter_bootloader_mode(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.enter_bootloader_mode() @@ -278,6 +489,8 @@ async def test_api_enter_initial_mode(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.enter_initial_mode() @@ -292,6 +505,8 @@ async def test_api_reboot_stick(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" await api.reboot_stick() @@ -340,6 +555,8 @@ async def test_api_verify_device_success(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" with patch("asyncio.wait_for", new_callable=AsyncMock) as mock_wait: mock_wait.return_value = True @@ -356,6 +573,8 @@ async def test_api_verify_device_timeout(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" with patch("asyncio.wait_for", new_callable=AsyncMock) as mock_wait: mock_wait.side_effect = TimeoutError() @@ -385,6 +604,8 @@ async def test_api_get_device_id_success(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" with patch("asyncio.wait_for", new_callable=AsyncMock) as mock_wait: mock_wait.return_value = "ABC123DEF" @@ -401,6 +622,8 @@ async def test_api_get_device_id_timeout(hass: HomeAssistant) -> None: mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" with patch("asyncio.wait_for", new_callable=AsyncMock) as mock_wait: mock_wait.side_effect = TimeoutError() @@ -429,6 +652,8 @@ async def test_api_disconnect_with_retry_task(hass: HomeAssistant) -> None: mock_transport = MagicMock() api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" # Create a mock retry task mock_retry_task = MagicMock() @@ -574,7 +799,7 @@ def test_protocol_connection_lost() -> None: protocol.connection_lost(None) - api.update_connection_status.assert_called_once_with(False) + api.handle_connection_lost.assert_called_once_with(protocol, None) def test_protocol_connection_lost_with_exception() -> None: @@ -586,7 +811,36 @@ def test_protocol_connection_lost_with_exception() -> None: exc = Exception("Connection error") protocol.connection_lost(exc) - api.update_connection_status.assert_called_once_with(False) + api.handle_connection_lost.assert_called_once_with(protocol, exc) + + +def test_api_connection_loss_clears_state_and_schedules_reconnect( + hass: HomeAssistant, +) -> None: + """Test a live protocol loss clears stale state and requests reconnect.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + protocol = SchellenbergProtocol(api._handle_message, api) + transport = MagicMock() + api._protocol = protocol + api._transport = transport + api._is_connected = True + api._device_mode = "listening" + api._transmitter_active = True + api._transmitter_idle.clear() + + with ( + patch.object(api, "_schedule_reconnect") as schedule_reconnect, + patch("custom_components.schellenberg_usb.api.async_dispatcher_send"), + ): + api.handle_connection_lost(protocol, None) + + assert api._transport is None + assert api._protocol is None + assert api.is_connected is False + assert api.device_mode is None + assert api.transmitter_active is False + assert api._transmitter_idle.is_set() is True + schedule_reconnect.assert_called_once_with() @pytest.mark.asyncio @@ -604,16 +858,37 @@ async def test_api_pair_device_already_pairing(hass: HomeAssistant) -> None: @pytest.mark.asyncio async def test_api_pair_device_success(hass: HomeAssistant) -> None: - """Test successful device pairing.""" + """Test pairing waits for transmit completion and exits pairing mode.""" api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + api._is_connected = True + api._device_mode = "listening" - with patch("asyncio.wait_for", new_callable=AsyncMock) as mock_wait: - mock_wait.return_value = "device_abc123" + async def _complete_transmit(_: str) -> bool: + api._handle_message("t0") + return True + + with ( + patch("asyncio.wait_for", new=AsyncMock(return_value="device_abc123")), + patch.object( + api, + "_wait_for_transmitter_idle", + new=AsyncMock(side_effect=_complete_transmit), + ), + patch("custom_components.schellenberg_usb.api.asyncio.sleep", new=AsyncMock()), + ): result = await api.pair_device_and_wait() assert result == ("device_abc123", "10") - mock_transport.write.assert_called() + assert [call.args[0] for call in mock_transport.write.call_args_list] == [ + b"sp\r\n", + b"ss109600000\r\n", + b"ss109400000\r\n", + b"sp\r\n", + ] + assert api.pairing_active is False + assert api.device_mode == "listening" + assert api.transmit_ready is True diff --git a/tests/test_api_messages.py b/tests/test_api_messages.py index 082492e..c057a92 100644 --- a/tests/test_api_messages.py +++ b/tests/test_api_messages.py @@ -2,12 +2,16 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.core import HomeAssistant -from custom_components.schellenberg_usb.api import SchellenbergUsbApi +from custom_components.schellenberg_usb.api import ( + TRANSMIT_MAX_RETRIES, + SchellenbergUsbApi, +) @pytest.mark.asyncio @@ -27,6 +31,20 @@ async def test_handle_message_device_verification_response(hass: HomeAssistant) mock_send.assert_called_once() +@pytest.mark.asyncio +async def test_handle_message_device_verification_listening_mode( + hass: HomeAssistant, +) -> None: + """Test B:2 is retained as the transmit-capable listening mode.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api._verify_future = hass.loop.create_future() + + with patch("custom_components.schellenberg_usb.api.async_dispatcher_send"): + api._handle_message("RFTU_V20 F:20180510_DFBD B:2") + + assert api._device_mode == "listening" + + @pytest.mark.asyncio async def test_handle_message_device_verification_bootloader_mode( hass: HomeAssistant, @@ -74,39 +92,143 @@ async def test_handle_message_device_verification_no_boot_mode( @pytest.mark.asyncio async def test_handle_message_transmit_ack_t1(hass: HomeAssistant) -> None: - """Test handling transmit acknowledgment t1.""" + """Test t1 starts RF transmission but does not mark it completed.""" api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api._pending_retry_command = "ss089010000" - # Should not raise any errors api._handle_message("t1") + assert api.transmitter_active is True + assert api._transmitter_idle.is_set() is False + assert api._pending_retry_command == "ss089010000" + @pytest.mark.asyncio async def test_handle_message_transmit_ack_t0(hass: HomeAssistant) -> None: - """Test handling transmit acknowledgment t0.""" + """Test t0 marks RF transmission complete and clears pending state.""" api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api._pending_retry_command = "ss0D9020000" + api._transmitter_active = True + api._transmitter_idle.clear() - # Should not raise any errors api._handle_message("t0") + assert api.transmitter_active is False + assert api._transmitter_idle.is_set() is True + assert api._pending_retry_command is None + @pytest.mark.asyncio -async def test_handle_message_transmit_error_with_pending_retry( +async def test_busy_burst_keeps_existing_retry_task( hass: HomeAssistant, ) -> None: - """Test handling transmit error with pending retry command.""" + """Test duplicate busy responses cannot postpone a scheduled retry forever.""" api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") - api._pending_retry_command = "test_command" + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + api._transport = mock_transport + payload = "ss089010000" + await api.send_command(payload) + + release_retry = asyncio.Event() + real_sleep = asyncio.sleep + + async def _wait_for_release(_: float) -> None: + await release_retry.wait() + + with patch( + "custom_components.schellenberg_usb.api.asyncio.sleep", + side_effect=_wait_for_release, + ): + api._handle_message("tE") + first_retry = api._retry_task + assert first_retry is not None + await real_sleep(0) + + api._handle_message("tE") + assert api._retry_task is first_retry + assert not first_retry.cancelled() + + api._handle_message("t0") + await real_sleep(0) + release_retry.set() + await first_retry + + assert mock_transport.write.call_count == 2 + + +@pytest.mark.asyncio +async def test_busy_retry_stops_after_max_attempts(hass: HomeAssistant) -> None: + """Test repeated busy/idle responses cannot create an infinite retry loop.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + api._transport = mock_transport + payload = "ss0D9020000" + await api.send_command(payload) + + with patch( + "custom_components.schellenberg_usb.api.asyncio.sleep", + new_callable=AsyncMock, + ): + for _ in range(TRANSMIT_MAX_RETRIES): + api._handle_message("tE") + retry_task = api._retry_task + assert retry_task is not None + api._handle_message("t0") + await retry_task + + api._handle_message("tE") + + assert mock_transport.write.call_count == TRANSMIT_MAX_RETRIES + 1 + assert api._pending_retry_command is None + assert api._retry_task is None + assert api._transmit_busy is False + assert api._transmit_lock.locked() is False + assert api.busy_latched is True + +@pytest.mark.asyncio +async def test_busy_without_idle_times_out_and_requires_reset( + hass: HomeAssistant, +) -> None: + """Test a missing t0 abandons the command without scheduling forever.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") mock_transport = MagicMock() mock_transport.is_closing = MagicMock(return_value=False) api._transport = mock_transport + await api.send_command("ss089010000") - with patch("asyncio.create_task") as mock_create_task: + with patch.object( + api, "_wait_for_transmitter_idle", new=AsyncMock(return_value=False) + ): api._handle_message("tE") + retry_task = api._retry_task + assert retry_task is not None + await retry_task - # Should schedule a retry task - mock_create_task.assert_called_once() + assert mock_transport.write.call_count == 1 + assert api._retry_task is None + assert api._pending_retry_command is None + assert api.busy_latched is True + + +@pytest.mark.asyncio +async def test_serial_write_failure_releases_transmit_state( + hass: HomeAssistant, +) -> None: + """Test write errors always release the transmit lock and busy marker.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + mock_transport = MagicMock() + mock_transport.is_closing = MagicMock(return_value=False) + mock_transport.write.side_effect = OSError("serial failure") + api._transport = mock_transport + + assert await api.send_command("ss089010000") is False + + assert api._transmit_busy is False + assert api._transmit_lock.locked() is False + assert api._pending_retry_command is None @pytest.mark.asyncio @@ -147,11 +269,306 @@ async def test_handle_message_device_event_registered_device( # Format: ssXXYYYYYYZZZZCCPPRR where XX=enum, YYYYYY=device_id, CC=command api._handle_message("ss10ABC123ZZZZ01PP00") - # Should dispatch event to device - mock_send.assert_called_once() - call_args = mock_send.call_args[0] - assert "schellenberg_usb_device_event_ABC123" in call_args[1] - assert call_args[2] == "01" # command + # Calibration receives the ID-only signal and the cover receives an exact signal. + assert mock_send.call_count == 2 + assert mock_send.call_args_list[0].args == ( + hass, + "schellenberg_usb_device_event_ABC123", + "01", + ) + assert mock_send.call_args_list[1].args == ( + hass, + "schellenberg_usb_device_event_ABC123_10", + "01", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status_enum", ["08", "0D"]) +async def test_handle_message_preserves_leading_zero_status_enum( + hass: HomeAssistant, + status_enum: str, +) -> None: + """Test leading-zero status enums are normalized and matched exactly.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api.register_entity( + "3720B8", + status_enum.removeprefix("0"), + "Sitting room", + command_device_id="F2B8D5", + command_enum="08", + ) + + with patch( + "custom_components.schellenberg_usb.api.async_dispatcher_send" + ) as mock_send: + api._handle_message(f"ss{status_enum}3720B8ZZZZ01PP00") + + assert ("3720B8", status_enum) in api._registered_entity_keys + assert mock_send.call_count == 2 + assert mock_send.call_args_list[1].args == ( + hass, + f"schellenberg_usb_device_event_3720B8_{status_enum}", + "01", + ) + last_received = api.get_last_received("3720B8", status_enum.removeprefix("0")) + assert last_received is not None + assert last_received["enum"] == status_enum + + +@pytest.mark.asyncio +async def test_primary_and_secondary_status_identities_both_match( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test primary movement and opaque secondary frames match one cover.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api.register_entity( + "3720B8", + "08", + "Sitting room", + command_device_id="F2B8D5", + command_enum="10", + secondary_status_identities=[{"device_id": "F2B8D5", "enum": "23"}], + ) + + with ( + caplog.at_level("DEBUG"), + patch( + "custom_components.schellenberg_usb.api.async_dispatcher_send" + ) as mock_send, + ): + api._handle_message("ss083720B8ZZZZ01PP00") + primary = api.get_last_received("3720B8", "08") + api._handle_message("ss083720B8ZZZZE1PP00") + api._handle_message("ss23F2B8D5ZZZZC1PP00") + secondary = api.get_last_received("F2B8D5", "23") + + assert primary is not None + assert primary["matched"] is True + assert primary["identity_role"] == "primary" + assert primary["interpreted_command"] == "open" + assert primary["position_tracking"] is True + assert secondary is not None + assert secondary["matched"] is True + assert secondary["identity_role"] == "secondary" + assert secondary["interpreted_command"] == "unknown" + assert secondary["position_tracking"] is False + newest = api.get_last_received_for_identities((("3720B8", "08"), ("F2B8D5", "23"))) + assert newest is not None + assert newest["device_id"] == "F2B8D5" + assert newest["command"] == "C1" + assert "matched=True entity=Sitting room" in caplog.text + assert "identity_role=secondary interpreted=unknown" in caplog.text + assert mock_send.call_args_list[-1].args == ( + hass, + "schellenberg_usb_device_event_F2B8D5_23", + "C1", + ) + + +def test_phase_labelled_capture_selects_calibration_primary_and_secondary( + hass: HomeAssistant, +) -> None: + """Test calibration capture reuses recognized frames instead of command ID.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api.start_status_frame_capture(phase="opening") + + api._handle_message("ss083720B8ZZZZ01PP00") + api._handle_message("ss2306C5C0ZZZZE1PP00") + api._handle_message("ss083720B8ZZZZ00PP00") + api.set_status_frame_capture_phase("closing") + api._handle_message("ss083720B8ZZZZ02PP00") + api._handle_message("ss2306C5C0ZZZZE2PP00") + api._handle_message("ss083720B8ZZZZ00PP00") + + result = api.finish_status_frame_capture(end_reason="completed") + + assert result["primary"]["device_id"] == "3720B8" + assert result["primary"]["enum"] == "08" + assert result["primary"]["commands"] == ["01", "00", "02"] + assert [(item["device_id"], item["enum"]) for item in result["secondary"]] == [ + ("06C5C0", "23") + ] + assert result["unknown_commands"] == [ + {"device_id": "06C5C0", "enum": "23", "commands": ["E1", "E2"]} + ] + assert [frame["phase"] for frame in result["frames"]] == [ + "opening", + "opening", + "opening_endstop", + "closing", + "closing", + "closing_endstop", + ] + assert result["end_reason"] == "completed" + assert result["position_tracking_available"] is True + recent = api.get_recent_raw_frames(limit=2) + assert [(frame["device_id"], frame["enum"]) for frame in recent] == [ + ("06C5C0", "23"), + ("3720B8", "08"), + ] + assert all(frame["phase"] == "closing" for frame in recent) + + +def test_capture_does_not_promote_unknown_command_identity( + hass: HomeAssistant, +) -> None: + """Test E/C/A-family frames remain secondary when no 00/01/02 exists.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api.start_status_frame_capture(phase="opening") + api._handle_message("ss1106C5C0ZZZZE1PP00") + api._handle_message("ss1106C5C0ZZZZC2PP00") + + result = api.finish_status_frame_capture(end_reason="completed_without_status") + + assert result["primary"] is None + assert result["position_tracking_available"] is False + assert [(item["device_id"], item["enum"]) for item in result["secondary"]] == [ + ("06C5C0", "11") + ] + + +def test_position_update_diagnostics_are_kept_per_command_identity( + hass: HomeAssistant, +) -> None: + """Test cover position provenance can be recorded and retrieved.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + + api.record_position_update( + "f2b8d5", + source="primary status 3720B8/08 command 01", + direction="opening", + previous_position=40, + new_position=44, + status="estimated", + position_source="primary status", + confirmed_since_restart=True, + ) + + update = api.get_last_position_update("F2B8D5") + assert update is not None + assert update["source"] == "primary status 3720B8/08 command 01" + assert update["direction"] == "opening" + assert update["previous_position"] == 40 + assert update["new_position"] == 44 + assert update["status"] == "estimated" + assert update["position_source"] == "primary status" + assert update["confirmed_since_restart"] is True + assert api.get_last_position_update("ABCDEF") is None + + +def test_manual_position_sync_dispatches_and_retains_last_confirmation( + hass: HomeAssistant, +) -> None: + """Test manual position corrections reach the live cover and remain diagnostic.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api.register_entity( + "3720B8", + "08", + "Sitting room", + command_device_id="F2B8D5", + command_enum="10", + ) + + with patch( + "custom_components.schellenberg_usb.api.async_dispatcher_send" + ) as mock_send: + assert api.manual_sync_position("f2b8d5", 42) is True + + mock_send.assert_called_once_with( + hass, + "schellenberg_usb_manual_position_sync_F2B8D5", + 42, + ) + + api.record_position_update( + "F2B8D5", + source="Developer Tools manual position sync", + direction="manual", + previous_position=40, + new_position=42, + status="confirmed/manual", + ) + manual_update = api.get_last_manual_position_sync("f2b8d5") + assert manual_update is not None + assert manual_update["new_position"] == 42 + assert manual_update["status"] == "confirmed/manual" + + api.record_position_update( + "F2B8D5", + source="primary status 3720B8/08 command 01", + direction="opening", + previous_position=42, + new_position=45, + status="estimated", + ) + assert api.get_last_position_update("F2B8D5")["new_position"] == 45 + assert api.get_last_manual_position_sync("F2B8D5") == manual_update + + +def test_manual_position_sync_rejects_invalid_or_unknown_targets( + hass: HomeAssistant, +) -> None: + """Test manual correction is bounded and requires a registered live cover.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + + with patch( + "custom_components.schellenberg_usb.api.async_dispatcher_send" + ) as mock_send: + assert api.manual_sync_position("ABCDEF", 50) is False + with pytest.raises(ValueError, match="between 0 and 100"): + api.manual_sync_position("ABCDEF", 101) + + mock_send.assert_not_called() + + +@pytest.mark.asyncio +async def test_unmatched_frames_are_not_warnings_when_a_cover_is_registered( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test ambient unmatched RF frames are debug-only once covers exist.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api.register_entity("3720B8", "08", "Sitting room") + + with caplog.at_level("WARNING"): + api._handle_message("ss23ABCDEFZZZZC1PP00") + + assert "no cover has this status identity" not in caplog.text + last = api.get_last_received("ABCDEF", "23") + assert last is not None + assert last["matched"] is False + assert last["interpreted_command"] == "unknown" + + +@pytest.mark.asyncio +async def test_handle_message_requires_exact_status_pair( + hass: HomeAssistant, +) -> None: + """Test a matching ID with another enum does not reach the cover.""" + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api.register_entity( + "3720B8", + "08", + "Sitting room", + command_device_id="F2B8D5", + command_enum="23", + ) + + with patch( + "custom_components.schellenberg_usb.api.async_dispatcher_send" + ) as mock_send: + api._handle_message("ss133720B8ZZZZ01PP00") + + mock_send.assert_called_once_with( + hass, + "schellenberg_usb_device_event_3720B8", + "01", + ) + last_received = api.get_last_received("3720B8", "13") + assert last_received is not None + assert last_received["device_id"] == "3720B8" + assert last_received["enum"] == "13" + assert last_received["command"] == "01" @pytest.mark.asyncio diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..2dde39d --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,1308 @@ +"""Tests for Schellenberg USB blind subentry flows.""" + +from __future__ import annotations + +from types import MappingProxyType +from uuid import UUID +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest +from homeassistant.config_entries import ( + ConfigEntries, + ConfigSubentry, + ConfigSubentryFlow, + SOURCE_RECONFIGURE, + SOURCE_USER, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.schellenberg_usb.config_flow import ( + DEVELOPER_TOOLS_MENU_OPTIONS, + SchellenbergPairingSubentryFlow, +) +from custom_components.schellenberg_usb.const import ( + CONF_BLIND_ID, + CMD_DOWN, + CMD_STOP, + CMD_UP, + CONF_CLOSE_TIME, + CONF_CLOSE_TIME_SECONDS, + CONF_COMMAND_DEVICE_ID, + CONF_COMMAND_ENUM, + CONF_DEVICE_ENUM, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_INVERT_DIRECTION, + CONF_LAST_CALIBRATION, + CONF_OPEN_TIME, + CONF_OPEN_TIME_SECONDS, + CONF_SECONDARY_STATUS_IDENTITIES, + CONF_STATUS_DEVICE_ID, + CONF_STATUS_ENUM, + CONF_STATUS_IDENTITY_SOURCE, + DOMAIN, + STATUS_IDENTITY_SOURCE_CALIBRATION, + STATUS_IDENTITY_SOURCE_MANUAL, + STATUS_IDENTITY_SOURCE_REMOTE_DISCOVERY, + STATUS_IDENTITY_SOURCE_UNKNOWN, + SUBENTRY_TYPE_BLIND, +) +from custom_components.schellenberg_usb.cover import SchellenbergCover +from custom_components.schellenberg_usb.options_flow_calibration import ( + CalibrationFlowHandler, +) + + +def _create_flow() -> SchellenbergPairingSubentryFlow: + """Create a subentry flow with user source context.""" + flow = SchellenbergPairingSubentryFlow() + flow.context = {"source": SOURCE_USER} + return flow + + +@pytest.mark.asyncio +async def test_blind_subentry_flow_shows_setup_method_menu() -> None: + """Test that legacy, hybrid, and manual setup remain available.""" + result = await _create_flow().async_step_user() + + assert result["type"] is FlowResultType.MENU + assert result["menu_options"] == ["pair_test", "pair_device", "manual"] + + +@pytest.mark.asyncio +async def test_all_blind_subentry_navigation_menus_expose_expected_options() -> None: + """Test every static subentry navigation menu uses the translated keys.""" + flow = _create_flow() + + manual_next = await flow.async_step_manual_next() + reconfigure = await flow.async_step_reconfigure() + flow._pairing_workflow = "hybrid" + test_success = await flow.async_step_did_motor_move({"motor_moved": True}) + + assert manual_next["menu_options"] == ["test_motor", "save_manual"] + assert reconfigure["menu_options"] == [ + "edit", + "test_existing", + "developer_tools", + "calibrate", + ] + assert test_success["menu_options"] == ["calibration_close", "manual_times"] + + +@pytest.mark.asyncio +async def test_legacy_pairing_path_is_unchanged() -> None: + """Test selecting legacy pairing still reaches naming and calibration.""" + flow = _create_flow() + api = MagicMock() + api.pair_device_and_wait = AsyncMock(return_value=("3720B8", "08")) + hub_entry = MagicMock(runtime_data=api) + + form = await flow.async_step_pair_device() + assert form["step_id"] == "pair_device" + + with patch.object(flow, "_get_entry", return_value=hub_entry): + result = await flow.async_step_pair_device({}) + + assert result["step_id"] == "name_device" + assert flow._pairing_workflow == "legacy" + assert flow._pending_status_device_id is None + assert flow._pending_status_enum is None + assert flow._pending_status_identity_source == STATUS_IDENTITY_SOURCE_UNKNOWN + + +@pytest.mark.asyncio +async def test_hybrid_pairing_reaches_command_test() -> None: + """Test hybrid pairing names the device before command testing.""" + flow = _create_flow() + api = MagicMock() + api.pair_device_and_wait = AsyncMock(return_value=("F2B8D5", "23")) + hub_entry = MagicMock(runtime_data=api) + + with patch.object(flow, "_get_entry", return_value=hub_entry): + await flow.async_step_pair_test({}) + result = await flow.async_step_name_device({CONF_DEVICE_NAME: "Sitting room"}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "test_motor" + assert result["description_placeholders"] == { + "device_id": "F2B8D5", + "device_enum": "23", + } + + +@pytest.mark.asyncio +async def test_manual_setup_stores_separate_command_and_status_identity() -> None: + """Test manual setup stores split identities while retaining legacy keys.""" + flow = _create_flow() + hub_entry = MagicMock(subentries=MappingProxyType({})) + + with patch.object(flow, "_get_entry", return_value=hub_entry): + result = await flow.async_step_manual( + { + CONF_DEVICE_NAME: "Sitting room", + CONF_DEVICE_ID: "f2b8d5", + CONF_DEVICE_ENUM: "23", + CONF_STATUS_DEVICE_ID: "3720b8", + CONF_STATUS_ENUM: "08", + CONF_SECONDARY_STATUS_IDENTITIES: "F2B8D5/23\nABCDEF/0d", + CONF_OPEN_TIME_SECONDS: 25.06, + CONF_CLOSE_TIME_SECONDS: 23.05, + CONF_INVERT_DIRECTION: False, + } + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "manual_next" + + result = await flow.async_step_save_manual() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sitting room" + saved_data = dict(result["data"]) + assert str(UUID(saved_data.pop(CONF_BLIND_ID))) + assert saved_data == { + CONF_DEVICE_ID: "F2B8D5", + CONF_DEVICE_ENUM: "23", + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "23", + CONF_STATUS_DEVICE_ID: "3720B8", + CONF_STATUS_ENUM: "08", + CONF_STATUS_IDENTITY_SOURCE: STATUS_IDENTITY_SOURCE_MANUAL, + CONF_SECONDARY_STATUS_IDENTITIES: [ + {"device_id": "F2B8D5", "enum": "23"}, + {"device_id": "ABCDEF", "enum": "0D"}, + ], + CONF_OPEN_TIME: 25.06, + CONF_CLOSE_TIME: 23.05, + CONF_INVERT_DIRECTION: False, + } + assert result["unique_id"] == "F2B8D5" + + +@pytest.mark.asyncio +async def test_manual_subentry_persists_through_storage_reload( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test manual add is committed by HA and survives config-entry hydration.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"serial_port": "/dev/ttyUSB0"}, + title="Schellenberg USB", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_BLIND), + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"next_step_id": "manual"} + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_DEVICE_NAME: "Sitting room door", + CONF_DEVICE_ID: "F2B8D5", + CONF_DEVICE_ENUM: "23", + CONF_STATUS_DEVICE_ID: "3720B8", + CONF_STATUS_ENUM: "08", + CONF_SECONDARY_STATUS_IDENTITIES: "F2B8D5/23", + CONF_OPEN_TIME_SECONDS: 25.06, + CONF_CLOSE_TIME_SECONDS: 23.05, + CONF_INVERT_DIRECTION: False, + }, + ) + await hass.config_entries.subentries.async_configure( + result["flow_id"], {"next_step_id": "save_manual"} + ) + saved_subentry_id = next(iter(entry.subentries)) + + await hass.config_entries._store.async_save(hass.config_entries._data_to_save()) + restarted_config_entries = ConfigEntries(hass, {}) + await restarted_config_entries.async_initialize() + restored = restarted_config_entries.async_get_entry(entry.entry_id) + + assert restored is not None + assert len(restored.subentries) == 1 + subentry = next(iter(restored.subentries.values())) + assert subentry.subentry_id == saved_subentry_id + assert subentry.subentry_type == SUBENTRY_TYPE_BLIND + assert subentry.title == "Sitting room door" + assert subentry.unique_id == "F2B8D5" + assert subentry.data[CONF_STATUS_DEVICE_ID] == "3720B8" + assert subentry.data[CONF_SECONDARY_STATUS_IDENTITIES] == [ + {"device_id": "F2B8D5", "enum": "23"} + ] + assert subentry.data[CONF_OPEN_TIME] == 25.06 + + +@pytest.mark.asyncio +async def test_manual_setup_validates_protocol_values() -> None: + """Test validation of command/status identities and travel times.""" + flow = _create_flow() + hub_entry = MagicMock(subentries=MappingProxyType({})) + + with patch.object(flow, "_get_entry", return_value=hub_entry): + result = await flow.async_step_manual( + { + CONF_DEVICE_NAME: " ", + CONF_DEVICE_ID: "not-hex", + CONF_DEVICE_ENUM: "123", + CONF_STATUS_DEVICE_ID: "also-bad", + CONF_STATUS_ENUM: "x", + CONF_SECONDARY_STATUS_IDENTITIES: "not-an-identity", + CONF_OPEN_TIME_SECONDS: 0, + CONF_CLOSE_TIME_SECONDS: -1, + } + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_DEVICE_NAME: "required", + CONF_DEVICE_ID: "invalid_device_id", + CONF_DEVICE_ENUM: "invalid_device_enum", + CONF_STATUS_DEVICE_ID: "invalid_device_id", + CONF_STATUS_ENUM: "invalid_device_enum", + CONF_SECONDARY_STATUS_IDENTITIES: "invalid_status_identities", + CONF_OPEN_TIME_SECONDS: "invalid_travel_time", + CONF_CLOSE_TIME_SECONDS: "invalid_travel_time", + } + + +@pytest.mark.asyncio +async def test_short_motor_command_sends_open_then_stop() -> None: + """Test the hybrid command sequence and its diagnostic context.""" + flow = _create_flow() + flow._pending_device_id = "F2B8D5" + flow._pending_device_enum = "23" + api = MagicMock() + api.control_blind = AsyncMock(return_value=True) + hub_entry = MagicMock(runtime_data=api) + + with ( + patch.object(flow, "_get_entry", return_value=hub_entry), + patch("custom_components.schellenberg_usb.config_flow.asyncio.sleep") as sleep, + ): + result = await flow.async_step_test_motor({}) + + assert result["step_id"] == "did_motor_move" + api.control_blind.assert_has_awaits( + [ + call("23", CMD_UP, device_id="F2B8D5"), + call("23", CMD_STOP, device_id="F2B8D5"), + ] + ) + sleep.assert_awaited_once_with(0.75) + + +@pytest.mark.asyncio +async def test_edit_updates_protocol_data_without_unique_id_change() -> None: + """Test editing updates subentry data without replacing the subentry.""" + flow = _create_flow() + subentry = MagicMock( + subentry_id="sub1", + title="Sitting room", + unique_id="F2B8D5", + data={ + CONF_DEVICE_ID: "F2B8D5", + CONF_DEVICE_ENUM: "23", + CONF_OPEN_TIME: 25.06, + CONF_CLOSE_TIME: 23.05, + }, + ) + entry = MagicMock(subentries=MappingProxyType({"sub1": subentry})) + expected_result = {"type": FlowResultType.ABORT} + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + patch.object( + flow, "async_update_and_abort", return_value=expected_result + ) as update, + ): + result = await flow.async_step_edit( + { + CONF_DEVICE_NAME: "Door", + CONF_DEVICE_ID: "f2b8d5", + CONF_DEVICE_ENUM: "13", + CONF_STATUS_DEVICE_ID: "3720b8", + CONF_STATUS_ENUM: "08", + CONF_SECONDARY_STATUS_IDENTITIES: "F2B8D5/23", + CONF_OPEN_TIME_SECONDS: 25.06, + CONF_CLOSE_TIME_SECONDS: 23.05, + CONF_INVERT_DIRECTION: True, + } + ) + + assert result is expected_result + _, kwargs = update.call_args + assert kwargs["title"] == "Door" + assert kwargs["data"][CONF_COMMAND_DEVICE_ID] == "F2B8D5" + assert kwargs["data"][CONF_COMMAND_ENUM] == "13" + assert kwargs["data"][CONF_STATUS_DEVICE_ID] == "3720B8" + assert kwargs["data"][CONF_STATUS_ENUM] == "08" + assert kwargs["data"][CONF_SECONDARY_STATUS_IDENTITIES] == [ + {"device_id": "F2B8D5", "enum": "23"} + ] + assert kwargs["data"][CONF_INVERT_DIRECTION] is True + assert "unique_id" not in kwargs + + +@pytest.mark.asyncio +async def test_developer_tools_show_last_frame_and_send_selected_target() -> None: + """Test the native diagnostic view and its direct command actions.""" + flow = _create_flow() + subentry = MagicMock( + title="Sitting room door", + data={ + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "23", + CONF_STATUS_DEVICE_ID: "3720B8", + CONF_STATUS_ENUM: "08", + CONF_STATUS_IDENTITY_SOURCE: STATUS_IDENTITY_SOURCE_CALIBRATION, + CONF_LAST_CALIBRATION: { + "completed_at": "2026-07-04T12:00:55+02:00", + "end_reason": "completed", + "frames": [ + { + "device_id": "3720B8", + "enum": "08", + "command": "00", + "time": "12:00:25", + "phase": "opening_endstop", + } + ], + "groups": [ + { + "device_id": "3720B8", + "enum": "08", + "commands": ["01", "00", "02"], + } + ], + }, + CONF_SECONDARY_STATUS_IDENTITIES: [{"device_id": "F2B8D5", "enum": "23"}], + CONF_OPEN_TIME: 25.06, + CONF_CLOSE_TIME: 23.05, + CONF_INVERT_DIRECTION: True, + }, + ) + api = MagicMock() + api.get_last_received_for_identities.return_value = { + "device_id": "F2B8D5", + "enum": "23", + "command": "C1", + "time": "17:32:14", + "identity_role": "secondary", + "interpreted_command": "unknown", + "position_tracking": False, + } + api.get_last_primary_tracking_frame.return_value = { + "device_id": "3720B8", + "enum": "08", + "command": "01", + "time": "17:31:50", + "identity_role": "primary", + "interpreted_command": "open", + "position_tracking": True, + } + api.get_last_secondary_frame.return_value = { + "device_id": "F2B8D5", + "enum": "23", + "command": "C1", + "time": "17:32:14", + "identity_role": "secondary", + "interpreted_command": "unknown", + "position_tracking": False, + } + api.get_last_position_update.return_value = { + "source": "primary status 3720B8/08 command 01", + "position_source": "primary status", + "confirmed_since_restart": True, + "direction": "opening", + "previous_position": 40, + "new_position": 44, + "status": "estimated", + "time": "17:32:15", + } + api.get_last_manual_position_sync.return_value = { + "source": "Developer Tools manual position sync", + "direction": "manual", + "previous_position": 0, + "new_position": 40, + "status": "confirmed/manual", + "time": "17:30:00", + } + api.control_blind = AsyncMock(return_value=True) + api.reset_and_reconnect = AsyncMock(return_value=True) + api.is_connected = True + api.device_mode = "listening" + api.transmit_ready = True + api.transmit_block_reason = None + api.pairing_active = False + api.transmitter_active = False + api.busy_latched = False + entry = MagicMock(runtime_data=api) + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + ): + result = await flow.async_step_developer_tools() + command_result = await flow.async_step_test_open() + reset_result = await flow.async_step_reset_stick() + copy_result = await flow.async_step_copy_diagnostics() + + placeholders = result["description_placeholders"] + assert placeholders is not None + assert placeholders["selected_blind"] == "Sitting room door" + assert placeholders["last_device_id"] == "F2B8D5" + assert placeholders["last_identity_role"] == "secondary" + assert placeholders["last_interpretation"] == "unknown" + assert placeholders["last_position_tracking"] == "False" + assert placeholders["primary_last_device_id"] == "3720B8" + assert placeholders["primary_last_command"] == "01" + assert placeholders["primary_last_interpretation"] == "open" + assert placeholders["secondary_last_device_id"] == "F2B8D5" + assert placeholders["secondary_last_command"] == "C1" + assert placeholders["position_source"] == "primary status" + assert placeholders["position_direction"] == "opening" + assert placeholders["position_previous"] == "40%" + assert placeholders["position_new"] == "44%" + assert placeholders["position_status"] == "estimated" + assert placeholders["current_position"] == "44%" + assert placeholders["last_manual_sync_time"] == "17:30:00" + assert placeholders["position_confidence"] == "estimated" + assert placeholders["position_confirmed_since_restart"] == "Yes" + assert placeholders["primary_status_device_id"] == "3720B8" + assert placeholders["primary_status_enum"] == "08" + assert placeholders["status_identity_source"] == ( + "automatically discovered during calibration" + ) + assert placeholders["last_calibration_time"] == ("2026-07-04T12:00:55+02:00") + assert placeholders["calibration_end_reason"] == "completed" + assert "opening_endstop" in placeholders["calibration_frames"] + assert "3720B8/08: 01,00,02" in placeholders["calibration_candidates"] + assert placeholders["secondary_status_identities"] == "F2B8D5/23" + assert placeholders["command_device_id"] == "F2B8D5" + assert placeholders["command_enum"] == "23" + assert result["menu_options"] == DEVELOPER_TOOLS_MENU_OPTIONS + api.control_blind.assert_awaited_once_with( + "23", CMD_DOWN, device_id="F2B8D5", source="developer_tools" + ) + api.reset_and_reconnect.assert_awaited_once_with() + command_placeholders = command_result["description_placeholders"] + assert command_placeholders is not None + assert "written successfully" in command_placeholders["result"] + reset_placeholders = reset_result["description_placeholders"] + assert reset_placeholders is not None + assert "ready for transmit" in reset_placeholders["result"] + schema = copy_result["data_schema"] + assert schema is not None + diagnostics = schema({})["diagnostics"] + assert "Selected blind: Sitting room door" in diagnostics + assert "Configured primary status identity:" in diagnostics + assert "Source: automatically discovered during calibration" in diagnostics + assert "Last calibration run:" in diagnostics + assert "End reason: completed" in diagnostics + assert "phase=opening_endstop" in diagnostics + assert "Device ID: 3720B8" in diagnostics + assert "Configured secondary status identities:" in diagnostics + assert "F2B8D5/23" in diagnostics + assert "Identity role: secondary" in diagnostics + assert "Interpretation: unknown" in diagnostics + assert "Last primary tracking frame:" in diagnostics + assert "Last secondary frame:" in diagnostics + assert "Last position update:" in diagnostics + assert "Previous position: 40" in diagnostics + assert "New position: 44" in diagnostics + assert "Status: estimated" in diagnostics + assert "Current estimated position: 44" in diagnostics + assert "Last manual sync time: 17:30:00" in diagnostics + assert "Source: primary status" in diagnostics + assert "Details: primary status 3720B8/08 command 01" in diagnostics + assert "Confidence: estimated" in diagnostics + assert "Confirmed since restart: Yes" in diagnostics + assert "Mode: listening" in diagnostics + assert "Ready: True" in diagnostics + assert "t1/t0 confirm only" in diagnostics + assert "Motor reception and movement remain unverified" in diagnostics + + +@pytest.mark.asyncio +async def test_developer_tools_show_restored_startup_confidence() -> None: + """Test restored HA position is visibly unconfirmed after restart.""" + flow = _create_flow() + subentry = MagicMock( + title="Garden", + data={ + CONF_COMMAND_DEVICE_ID: "ABC123", + CONF_COMMAND_ENUM: "10", + CONF_STATUS_IDENTITY_SOURCE: STATUS_IDENTITY_SOURCE_UNKNOWN, + }, + ) + api = MagicMock() + api.get_last_received_for_identities.return_value = None + api.get_last_primary_tracking_frame.return_value = None + api.get_last_secondary_frame.return_value = None + api.get_last_position_update.return_value = { + "source": "restored HA state", + "position_source": "restored HA state", + "confirmed_since_restart": False, + "direction": "idle", + "previous_position": None, + "new_position": 75, + "status": "restored / estimated / not confirmed since restart", + "time": "08:00:00", + } + api.get_last_manual_position_sync.return_value = None + api.is_connected = True + api.device_mode = "listening" + api.transmit_ready = True + api.pairing_active = False + api.transmitter_active = False + api.busy_latched = False + entry = MagicMock(runtime_data=api) + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + ): + result = await flow.async_step_developer_tools() + copy_result = await flow.async_step_copy_diagnostics() + + placeholders = result["description_placeholders"] + assert placeholders["current_position"] == "75%" + assert placeholders["position_source"] == "restored HA state" + assert placeholders["position_confidence"] == ( + "restored / estimated / not confirmed since restart" + ) + assert placeholders["position_confirmed_since_restart"] == "No" + + diagnostics = copy_result["data_schema"]({})["diagnostics"] + assert "Current estimated position: 75" in diagnostics + assert "Source: restored HA state" in diagnostics + assert ( + "Confidence: restored / estimated / not confirmed since restart" in diagnostics + ) + assert "Confirmed since restart: No" in diagnostics + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("step_method", "expected_position"), + [ + ("async_step_set_position_open", 100), + ("async_step_set_position_closed", 0), + ], +) +async def test_developer_position_endpoint_actions_update_live_cover( + step_method: str, + expected_position: int, +) -> None: + """Test fully-open and fully-closed actions dispatch exact positions.""" + flow = _create_flow() + subentry = MagicMock( + title="Sitting room door", + data={ + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "10", + CONF_STATUS_DEVICE_ID: "3720B8", + CONF_STATUS_ENUM: "08", + }, + ) + api = MagicMock() + api.get_last_received_for_identities.return_value = None + api.get_last_primary_tracking_frame.return_value = None + api.get_last_secondary_frame.return_value = None + api.get_last_position_update.return_value = None + api.get_last_manual_position_sync.return_value = None + api.manual_sync_position.return_value = True + api.is_connected = True + api.device_mode = "listening" + api.transmit_ready = True + api.busy_latched = False + entry = MagicMock(runtime_data=api) + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + ): + result = await getattr(flow, step_method)() + + api.manual_sync_position.assert_called_once_with("F2B8D5", expected_position) + assert result["step_id"] == "developer_tools" + placeholders = result["description_placeholders"] + assert placeholders is not None + assert f"confirmed at {expected_position}%" in placeholders["result"] + + +@pytest.mark.asyncio +async def test_developer_manual_position_form_uses_current_position_and_submits() -> ( + None +): + """Test the numeric manual-sync form defaults to and applies tracked position.""" + flow = _create_flow() + subentry = MagicMock( + title="Sitting room door", + data={ + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "10", + CONF_STATUS_DEVICE_ID: "3720B8", + CONF_STATUS_ENUM: "08", + }, + ) + api = MagicMock() + api.get_last_received_for_identities.return_value = None + api.get_last_primary_tracking_frame.return_value = None + api.get_last_secondary_frame.return_value = None + api.get_last_position_update.return_value = { + "source": "restored Home Assistant state", + "direction": "idle", + "previous_position": None, + "new_position": 37, + "status": "estimated", + "time": "18:00:00", + } + api.get_last_manual_position_sync.return_value = None + api.manual_sync_position.return_value = True + api.is_connected = True + api.device_mode = "listening" + api.transmit_ready = True + api.busy_latched = False + entry = MagicMock(runtime_data=api) + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + ): + form = await flow.async_step_set_position_manual() + result = await flow.async_step_set_position_manual({"position": 63}) + + assert form["step_id"] == "set_position_manual" + assert form["data_schema"]({})["position"] == 37 + assert form["description_placeholders"] == { + "selected_blind": "Sitting room door", + "current_position": "37%", + } + api.manual_sync_position.assert_called_once_with("F2B8D5", 63) + assert result["step_id"] == "developer_tools" + assert "confirmed at 63%" in result["description_placeholders"]["result"] + + +@pytest.mark.asyncio +async def test_original_remote_discovery_saves_primary_secondary_and_provenance() -> ( + None +): + """Test guided discovery updates an existing blind without transmit fallback.""" + flow = _create_flow() + subentry = MagicMock( + title="Garden", + data={ + CONF_COMMAND_DEVICE_ID: "06C5C0", + CONF_COMMAND_ENUM: "11", + CONF_STATUS_IDENTITY_SOURCE: STATUS_IDENTITY_SOURCE_UNKNOWN, + CONF_OPEN_TIME: 24.86, + CONF_CLOSE_TIME: 22.59, + }, + ) + entry = MagicMock() + result_data = { + "primary": { + "device_id": "3720B8", + "enum": "08", + "commands": ["01", "00", "02"], + "timestamps": ["12:00:01", "12:00:02", "12:00:03"], + }, + "secondary": [ + { + "device_id": "06C5C0", + "enum": "23", + "commands": ["E1", "E2"], + "timestamps": ["12:00:01", "12:00:03"], + } + ], + "unknown_commands": [ + {"device_id": "06C5C0", "enum": "23", "commands": ["E1", "E2"]} + ], + "frames": [{"device_id": "3720B8", "enum": "08", "command": "01"}], + "position_tracking_available": True, + } + api = MagicMock() + api.async_discover_status_identities = AsyncMock(return_value=result_data) + entry.runtime_data = api + expected = {"type": FlowResultType.ABORT} + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + patch.object(flow, "async_update_and_abort", return_value=expected) as update, + ): + form = await flow.async_step_discover_status() + confirmation = await flow.async_step_discover_status({}) + result = await flow.async_step_confirm_status_discovery({}) + + assert form["step_id"] == "discover_status" + assert confirmation["step_id"] == "confirm_status_discovery" + placeholders = confirmation["description_placeholders"] + assert placeholders["command_identity"] == "06C5C0/11" + assert placeholders["primary_identity"] == "3720B8/08" + assert placeholders["primary_commands"] == "01, 00, 02" + assert "06C5C0/23" in placeholders["secondary_identities"] + assert result is expected + data = update.call_args.kwargs["data"] + assert data[CONF_STATUS_DEVICE_ID] == "3720B8" + assert data[CONF_STATUS_ENUM] == "08" + assert data[CONF_STATUS_IDENTITY_SOURCE] == ( + STATUS_IDENTITY_SOURCE_REMOTE_DISCOVERY + ) + assert data[CONF_SECONDARY_STATUS_IDENTITIES] == [ + {"device_id": "06C5C0", "enum": "23"} + ] + + +@pytest.mark.asyncio +async def test_original_remote_discovery_keeps_status_unknown_when_unrecognized() -> ( + None +): + """Test unknown-only frames never turn command identity into primary status.""" + flow = _create_flow() + flow._pending_device_name = "Garden" + flow._pending_device_id = "06C5C0" + flow._pending_device_enum = "11" + flow._pending_open_time = 24.86 + flow._pending_close_time = 22.59 + api = MagicMock() + api.async_discover_status_identities = AsyncMock( + return_value={ + "primary": None, + "secondary": [ + { + "device_id": "06C5C0", + "enum": "23", + "commands": ["E1"], + "timestamps": ["12:00:01"], + } + ], + "unknown_commands": [ + {"device_id": "06C5C0", "enum": "23", "commands": ["E1"]} + ], + "frames": [{"device_id": "06C5C0", "enum": "23", "command": "E1"}], + "position_tracking_available": False, + } + ) + entry = MagicMock(runtime_data=api) + + with patch.object(flow, "_get_entry", return_value=entry): + confirmation = await flow.async_step_discover_status({}) + result = await flow.async_step_confirm_status_discovery({}) + + assert ( + "No remote/status tracking identity" + in confirmation["description_placeholders"]["position_tracking"] + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert CONF_STATUS_DEVICE_ID not in result["data"] + assert CONF_STATUS_ENUM not in result["data"] + assert result["data"][CONF_STATUS_IDENTITY_SOURCE] == ( + STATUS_IDENTITY_SOURCE_UNKNOWN + ) + assert result["data"][CONF_SECONDARY_STATUS_IDENTITIES] == [ + {"device_id": "06C5C0", "enum": "23"} + ] + + +@pytest.mark.asyncio +async def test_developer_teach_motor_sends_60_then_open_and_stop() -> None: + """Test guided teach-in uses the API and follows with a movement test.""" + flow = _create_flow() + subentry = MagicMock( + title="Sitting room door", + data={ + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "0D", + CONF_STATUS_DEVICE_ID: "F2B8D5", + CONF_STATUS_ENUM: "23", + CONF_INVERT_DIRECTION: False, + }, + ) + api = MagicMock() + api.get_last_received_for_identities.return_value = None + api.get_last_primary_tracking_frame.return_value = None + api.get_last_secondary_frame.return_value = None + api.get_last_position_update.return_value = None + api.teach_motor = AsyncMock(return_value=True) + api.control_blind = AsyncMock(return_value=True) + api.is_connected = True + api.device_mode = "listening" + api.transmit_ready = True + api.transmit_block_reason = None + api.pairing_active = False + api.transmitter_active = False + api.busy_latched = False + entry = MagicMock(runtime_data=api) + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + patch( + "custom_components.schellenberg_usb.config_flow.asyncio.sleep", + new_callable=AsyncMock, + ) as delay, + ): + form = await flow.async_step_teach_motor() + result = await flow.async_step_teach_motor({}) + + assert form["step_id"] == "teach_motor" + api.teach_motor.assert_awaited_once_with( + "0D", device_id="F2B8D5", source="developer_tools" + ) + api.control_blind.assert_has_awaits( + [ + call( + "0D", + CMD_UP, + device_id="F2B8D5", + source="developer_tools", + ), + call( + "0D", + CMD_STOP, + device_id="F2B8D5", + source="developer_tools", + ), + ] + ) + delay.assert_awaited_once() + assert result["step_id"] == "developer_tools" + placeholders = result["description_placeholders"] + assert placeholders is not None + assert "Stick ACKs confirm only" in placeholders["result"] + + +@pytest.mark.asyncio +async def test_developer_raw_command_validates_and_uses_api() -> None: + """Test the raw RF form supplies an exact packet and invokes the API.""" + flow = _create_flow() + subentry = MagicMock( + title="Sitting room door", + data={ + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "10", + CONF_STATUS_DEVICE_ID: "F2B8D5", + CONF_STATUS_ENUM: "23", + }, + ) + api = MagicMock() + api.get_last_received_for_identities.return_value = None + api.get_last_primary_tracking_frame.return_value = None + api.get_last_secondary_frame.return_value = None + api.get_last_position_update.return_value = None + api.send_raw_transmit = AsyncMock(return_value=True) + api.is_connected = True + api.device_mode = "listening" + api.transmit_ready = True + api.pairing_active = False + api.transmitter_active = False + api.busy_latched = False + entry = MagicMock(runtime_data=api) + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + ): + form = await flow.async_step_send_raw_command() + result = await flow.async_step_send_raw_command({"payload": "ss109010000"}) + + assert form["step_id"] == "send_raw_command" + assert form["data_schema"]({})["payload"] == "ss109010000" + api.send_raw_transmit.assert_awaited_once_with( + "ss109010000", source="developer_tools" + ) + assert result["step_id"] == "developer_tools" + placeholders = result["description_placeholders"] + assert placeholders is not None + assert "do not confirm motor movement" in placeholders["result"] + + +@pytest.mark.asyncio +async def test_developer_raw_command_shows_validation_error() -> None: + """Test malformed raw packets remain on the form with a field error.""" + flow = _create_flow() + subentry = MagicMock( + title="Blind", + data={ + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "08", + }, + ) + api = MagicMock() + api.send_raw_transmit = AsyncMock(side_effect=ValueError("invalid")) + entry = MagicMock(runtime_data=api) + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + ): + result = await flow.async_step_send_raw_command({"payload": "ss08901000"}) + + assert result["step_id"] == "send_raw_command" + assert result["errors"] == {"payload": "invalid_raw_payload"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("step_id", "requested_command", "protocol_action"), + [ + ("test_open", "open", CMD_UP), + ("test_close", "close", CMD_DOWN), + ("test_stop", "stop", CMD_STOP), + ], +) +async def test_developer_menu_navigation_dispatches_every_command( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, + step_id: str, + requested_command: str, + protocol_action: str, +) -> None: + """Test actual HA menu navigation invokes every Developer Tools action.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"serial_port": "/dev/ttyUSB0"}, + title="Schellenberg USB", + ) + entry.add_to_hass(hass) + subentry = ConfigSubentry( + data=MappingProxyType( + { + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "10", + CONF_STATUS_DEVICE_ID: "F2B8D5", + CONF_STATUS_ENUM: "23", + CONF_INVERT_DIRECTION: False, + } + ), + subentry_type=SUBENTRY_TYPE_BLIND, + title="Sitting_room_door", + unique_id="F2B8D5", + ) + hass.config_entries.async_add_subentry(entry, subentry) + api = MagicMock() + api.get_last_received_for_identities.return_value = None + api.get_last_primary_tracking_frame.return_value = None + api.get_last_secondary_frame.return_value = None + api.get_last_position_update.return_value = None + api.control_blind = AsyncMock(return_value=True) + api.is_connected = True + api.device_mode = "listening" + api.transmit_ready = True + api.transmit_block_reason = None + api.pairing_active = False + api.transmitter_active = False + api.busy_latched = False + entry.runtime_data = api + + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_BLIND), + context={ + "source": SOURCE_RECONFIGURE, + "subentry_id": subentry.subentry_id, + }, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"next_step_id": "developer_tools"} + ) + assert result["step_id"] == "developer_tools" + + with caplog.at_level("WARNING"): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"next_step_id": step_id} + ) + + assert result["step_id"] == "developer_tools" + api.control_blind.assert_awaited_once_with( + "10", + protocol_action, + device_id="F2B8D5", + source="developer_tools", + ) + assert "Developer Tools command clicked" in caplog.text + assert "selected_blind=Sitting_room_door" in caplog.text + assert f"command_requested={requested_command}" in caplog.text + assert "command_device_id=F2B8D5" in caplog.text + assert "command_enum=10" in caplog.text + assert "status_device_id=F2B8D5" in caplog.text + assert "status_enum=23" in caplog.text + assert "stick_connected=True" in caplog.text + assert "stick_mode=listening" in caplog.text + assert "stick_ready=True" in caplog.text + assert "pairing=False" in caplog.text + assert "transmitter_active=False" in caplog.text + assert "busy_latched=False" in caplog.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("step_method", "cover_method", "protocol_action"), + [ + ("async_step_test_open", "async_open_cover", CMD_UP), + ("async_step_test_close", "async_close_cover", CMD_DOWN), + ("async_step_test_stop", "async_stop_cover", CMD_STOP), + ], +) +async def test_developer_and_cover_actions_share_control_blind_path( + step_method: str, cover_method: str, protocol_action: str +) -> None: + """Test Developer Tools and cover actions invoke the same API command path.""" + flow = _create_flow() + subentry = MagicMock( + title="Sitting_room_door", + data={ + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "10", + CONF_STATUS_DEVICE_ID: "F2B8D5", + CONF_STATUS_ENUM: "23", + CONF_INVERT_DIRECTION: False, + }, + ) + api = MagicMock() + api.get_last_received_for_identities.return_value = None + api.get_last_primary_tracking_frame.return_value = None + api.get_last_secondary_frame.return_value = None + api.get_last_position_update.return_value = None + api.control_blind = AsyncMock(return_value=True) + api.is_connected = True + api.device_mode = "listening" + api.transmit_ready = True + api.transmit_block_reason = None + api.pairing_active = False + api.transmitter_active = False + api.busy_latched = False + entry = MagicMock(runtime_data=api) + cover = SchellenbergCover( + api=api, + device_id="F2B8D5", + device_enum="10", + device_name="Sitting_room_door", + command_device_id="F2B8D5", + status_device_id="F2B8D5", + status_enum="23", + ) + cover._attr_current_cover_position = 50 + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + ): + await getattr(flow, step_method)() + developer_call = api.control_blind.await_args + api.control_blind.reset_mock() + + with ( + patch.object(cover, "_start_position_tracking"), + patch.object(cover, "_stop_position_tracking"), + patch.object(cover, "async_write_ha_state"), + ): + await getattr(cover, cover_method)() + cover_call = api.control_blind.await_args + + assert developer_call.args == cover_call.args == ("10", protocol_action) + assert developer_call.kwargs == { + "device_id": "F2B8D5", + "source": "developer_tools", + } + assert cover_call.kwargs == {"device_id": "F2B8D5"} + + +@pytest.mark.asyncio +async def test_developer_command_is_blocked_when_stick_is_not_ready() -> None: + """Test Developer Tools does not queue commands in a non-ready state.""" + flow = _create_flow() + subentry = MagicMock( + title="Blind", + data={ + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: "08", + CONF_STATUS_DEVICE_ID: "3720B8", + CONF_STATUS_ENUM: "0D", + }, + ) + api = MagicMock() + api.get_last_received_for_identities.return_value = None + api.get_last_primary_tracking_frame.return_value = None + api.get_last_secondary_frame.return_value = None + api.get_last_position_update.return_value = None + api.control_blind = AsyncMock() + api.is_connected = True + api.device_mode = "pairing" + api.transmit_ready = False + api.transmit_block_reason = "pairing is active" + api.pairing_active = True + api.transmitter_active = False + api.busy_latched = True + entry = MagicMock(runtime_data=api) + + with ( + patch.object(flow, "_get_entry", return_value=entry), + patch.object(flow, "_get_reconfigure_subentry", return_value=subentry), + ): + result = await flow.async_step_test_stop() + + api.control_blind.assert_not_awaited() + placeholders = result["description_placeholders"] + assert placeholders is not None + assert "command blocked" in placeholders["result"] + assert "Reset stick / reconnect serial" in placeholders["result"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("command_enum", "status_enum"), + [("23", "08"), ("08", "0D"), ("0D", "08")], +) +async def test_pairing_persists_calibrated_protocol_data( + command_enum: str, status_enum: str +) -> None: + """Test paired subentries retain identities and measured times.""" + flow = MagicMock(spec=ConfigSubentryFlow) + handler = CalibrationFlowHandler(flow) + handler.set_selected_device( + { + "id": "3720B8", + "entity_id": "F2B8D5", + "name": "Sitting room", + "enum": status_enum, + } + ) + handler.enable_subentry_creation( + device_id="F2B8D5", + blind_id="11111111-1111-4111-8111-111111111111", + device_enum=command_enum, + device_name="Sitting room", + status_device_id="3720B8", + status_enum=status_enum, + ) + handler._open_time = 25.064 + handler._close_time = 23.054 + + with patch.object(handler, "_save_calibration_data", new=AsyncMock()): + await handler.async_step_calibration_complete({}) + + flow.async_create_entry.assert_called_once_with( + title="Sitting room", + data={ + CONF_DEVICE_ID: "F2B8D5", + CONF_BLIND_ID: "11111111-1111-4111-8111-111111111111", + CONF_DEVICE_ENUM: command_enum, + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: command_enum, + CONF_STATUS_DEVICE_ID: "3720B8", + CONF_STATUS_ENUM: status_enum, + CONF_SECONDARY_STATUS_IDENTITIES: [], + CONF_OPEN_TIME: 25.06, + CONF_CLOSE_TIME: 23.05, + CONF_INVERT_DIRECTION: False, + }, + unique_id="F2B8D5", + ) + + +@pytest.mark.asyncio +async def test_calibration_candidates_are_persisted_with_frame_diagnostics() -> None: + """Test calibration-derived primary, secondary, phases, and provenance persist.""" + flow = MagicMock(spec=ConfigSubentryFlow) + handler = CalibrationFlowHandler(flow) + handler.set_selected_device( + {"id": "06C5C0", "entity_id": "06C5C0", "name": "Garden", "enum": "11"} + ) + handler.enable_subentry_creation( + device_id="06C5C0", + device_enum="11", + device_name="Garden", + status_identity_source=STATUS_IDENTITY_SOURCE_UNKNOWN, + ) + handler._open_time = 24.864 + handler._close_time = 22.594 + handler._calibration_discovery_result = { + "primary": { + "device_id": "3720B8", + "enum": "08", + "commands": ["01", "00", "02"], + "timestamps": ["12:00:01", "12:00:25", "12:00:30"], + }, + "secondary": [ + { + "device_id": "06C5C0", + "enum": "23", + "commands": ["E1", "E2"], + "timestamps": ["12:00:01", "12:00:30"], + } + ], + "groups": [], + "unknown_commands": [ + {"device_id": "06C5C0", "enum": "23", "commands": ["E1", "E2"]} + ], + "position_tracking_available": True, + "frames": [ + { + "device_id": "3720B8", + "enum": "08", + "command": "00", + "time": "12:00:25", + "phase": "opening_endstop", + } + ], + "started_at": "2026-07-04T12:00:00+02:00", + "completed_at": "2026-07-04T12:00:55+02:00", + "end_reason": "completed", + } + handler._apply_calibration_status_candidates() + + with patch.object(handler, "_save_calibration_data", new=AsyncMock()): + await handler.async_step_calibration_complete({}) + + data = flow.async_create_entry.call_args.kwargs["data"] + assert data[CONF_STATUS_DEVICE_ID] == "3720B8" + assert data[CONF_STATUS_ENUM] == "08" + assert data[CONF_STATUS_IDENTITY_SOURCE] == STATUS_IDENTITY_SOURCE_CALIBRATION + assert data[CONF_SECONDARY_STATUS_IDENTITIES] == [ + {"device_id": "06C5C0", "enum": "23"} + ] + assert data[CONF_LAST_CALIBRATION]["end_reason"] == "completed" + assert data[CONF_LAST_CALIBRATION]["frames"][0]["phase"] == ("opening_endstop") + assert data[CONF_OPEN_TIME] == 24.86 + assert data[CONF_CLOSE_TIME] == 22.59 + + +@pytest.mark.asyncio +async def test_reconfigure_persists_calibrated_travel_times() -> None: + """Test recalibration updates the existing blind subentry.""" + flow = MagicMock(spec=ConfigSubentryFlow) + entry = MagicMock() + subentry = MagicMock() + flow._get_entry.return_value = entry + flow._get_reconfigure_subentry.return_value = subentry + expected_result = {"type": FlowResultType.ABORT} + flow.async_update_and_abort.return_value = expected_result + handler = CalibrationFlowHandler(flow) + handler.set_selected_device( + {"id": "3720B8", "entity_id": "F2B8D5", "name": "Door", "enum": "08"} + ) + handler._open_time = 25.678 + handler._close_time = 23.456 + + with patch.object(handler, "_save_calibration_data", new=AsyncMock()): + result = await handler.async_step_calibration_complete({}) + + assert result is expected_result + flow.async_update_and_abort.assert_called_once_with( + entry, + subentry, + data_updates={ + CONF_OPEN_TIME: 25.68, + CONF_CLOSE_TIME: 23.46, + }, + ) diff --git a/tests/test_const.py b/tests/test_const.py index fc5f584..c1bf04f 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -12,7 +12,12 @@ CMD_STOP, CMD_UP, CONF_CLOSE_TIME, + CONF_CLOSE_TIME_SECONDS, + CONF_DEVICE_ENUM, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, CONF_OPEN_TIME, + CONF_OPEN_TIME_SECONDS, CONF_SERIAL_PORT, DATA_API_INSTANCE, DATA_UNSUB_DISPATCHER, @@ -45,6 +50,11 @@ def test_configuration_constants() -> None: """Test configuration constants.""" assert CONF_SERIAL_PORT == "serial_port" assert CONF_OPEN_TIME == "open_time" + assert CONF_DEVICE_NAME == "device_name" + assert CONF_DEVICE_ID == "device_id" + assert CONF_DEVICE_ENUM == "device_enum" + assert CONF_OPEN_TIME_SECONDS == "open_time_seconds" + assert CONF_CLOSE_TIME_SECONDS == "close_time_seconds" assert CONF_CLOSE_TIME == "close_time" diff --git a/tests/test_cover.py b/tests/test_cover.py index 5bfd954..33acb31 100644 --- a/tests/test_cover.py +++ b/tests/test_cover.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from types import MappingProxyType from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -9,18 +10,30 @@ import pytest from homeassistant.components.cover import ATTR_POSITION from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.helpers import device_registry as dr from custom_components.schellenberg_usb.api import SchellenbergUsbApi from custom_components.schellenberg_usb.const import ( + CONF_BLIND_ID, CONF_CLOSE_TIME, + CONF_COMMAND_DEVICE_ID, + CONF_COMMAND_ENUM, + CONF_DEVICE_ENUM, + CONF_DEVICE_ID, CONF_OPEN_TIME, + CONF_SECONDARY_STATUS_IDENTITIES, CONF_SERIAL_PORT, + CONF_STATUS_DEVICE_ID, + CONF_STATUS_ENUM, DOMAIN, EVENT_STARTED_MOVING_DOWN, EVENT_STARTED_MOVING_UP, EVENT_STOPPED, + STATUS_IDENTITY_SOURCE_UNKNOWN, + SUBENTRY_TYPE_BLIND, ) from custom_components.schellenberg_usb.cover import ( DEFAULT_TRAVEL_TIME, @@ -28,6 +41,8 @@ async_setup_entry, ) +TEST_BLIND_ID = "11111111-1111-4111-8111-111111111111" + def _async_mock(value: Any) -> AsyncMock: """Cast helper for AsyncMock assertions.""" @@ -57,7 +72,9 @@ def mock_config_entry(hass: HomeAssistant) -> ConfigEntry: # Create a real subentry dict instead of MagicMock to avoid serialization issues subentry = MagicMock() subentry.subentry_id = "sub1" + subentry.subentry_type = SUBENTRY_TYPE_BLIND subentry.data = { + CONF_BLIND_ID: TEST_BLIND_ID, "device_id": "ABC123", "device_enum": "01", "device_name": "Test Cover", @@ -116,6 +133,110 @@ async def test_async_setup_entry_creates_covers( assert entities[0]._device_enum == "01" +@pytest.mark.asyncio +async def test_setup_migrates_legacy_entity_registry_unique_id( + hass: HomeAssistant, + mock_config_entry: ConfigEntry, + mock_api: SchellenbergUsbApi, +) -> None: + """Test registry migration preserves the existing entity ID.""" + mock_config_entry.runtime_data = mock_api + registry = er.async_get(hass) + legacy = registry.async_get_or_create( + "cover", + DOMAIN, + "schellenberg_ABC123", + config_entry=mock_config_entry, + config_subentry_id="sub1", + suggested_object_id="extension_0", + ) + add_entities = MagicMock() + + await async_setup_entry(hass, mock_config_entry, add_entities) + + new_unique_id = f"{DOMAIN}_blind_{TEST_BLIND_ID}" + assert registry.async_get_entity_id("cover", DOMAIN, new_unique_id) == ( + legacy.entity_id + ) + assert registry.async_get_entity_id("cover", DOMAIN, "schellenberg_ABC123") is None + assert add_entities.call_args.args[0][0].unique_id == new_unique_id + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("command_enum", "status_enum"), + [("23", "08"), ("08", "0D"), ("0D", "08")], +) +async def test_setup_restores_manual_cover_from_persisted_subentry( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, + command_enum: str, + status_enum: str, +) -> None: + """Test a stored manual blind is recreated during platform setup.""" + entry = ConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title="Schellenberg USB", + data={CONF_SERIAL_PORT: "/dev/ttyUSB0"}, + options={}, + source="user", + unique_id="/dev/ttyUSB0", + discovery_keys=MappingProxyType({}), + subentries_data=[ + { + "subentry_id": "manual_blind", + "subentry_type": SUBENTRY_TYPE_BLIND, + "title": "Sitting room door", + "unique_id": "F2B8D5", + "data": { + CONF_BLIND_ID: TEST_BLIND_ID, + CONF_DEVICE_ID: "F2B8D5", + CONF_DEVICE_ENUM: "23", + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: command_enum, + CONF_STATUS_DEVICE_ID: "3720B8", + CONF_STATUS_ENUM: status_enum, + CONF_SECONDARY_STATUS_IDENTITIES: [ + {"device_id": "F2B8D5", "enum": "23"} + ], + CONF_OPEN_TIME: 25.06, + CONF_CLOSE_TIME: 23.05, + }, + } + ], + ) + entry.runtime_data = mock_api + hass.config_entries._entries[entry.entry_id] = entry + add_entities = MagicMock() + + await async_setup_entry(hass, entry, add_entities) + + _magic_mock(mock_api.register_entity).assert_called_once_with( + "3720B8", + status_enum, + "Sitting room door", + command_device_id="F2B8D5", + command_enum=command_enum, + secondary_status_identities=(("F2B8D5", "23"),), + ) + + add_entities.assert_called_once() + assert add_entities.call_args.kwargs == {"config_subentry_id": "manual_blind"} + cover = add_entities.call_args.args[0][0] + assert isinstance(cover, SchellenbergCover) + assert cover.name is None + assert cover._device_name == "Sitting room door" + assert cover.unique_id == f"{DOMAIN}_blind_{TEST_BLIND_ID}" + assert cover._command_enum == command_enum + assert cover._status_device_id == "3720B8" + assert cover._status_enum == status_enum + assert cover._secondary_status_identities == (("F2B8D5", "23"),) + assert cover._travel_time_open == 25.06 + assert cover._travel_time_close == 23.05 + + @pytest.mark.asyncio async def test_cover_initialization( hass: HomeAssistant, @@ -129,17 +250,50 @@ async def test_cover_initialization( device_name="Test Cover", device_data=None, config_entry_id="test_entry", + blind_id=TEST_BLIND_ID, ) assert cover._device_id == "ABC123" assert cover._device_enum == "01" - assert cover.unique_id == "schellenberg_ABC123" - assert cover.name == "Test Cover" + assert cover.unique_id == f"{DOMAIN}_blind_{TEST_BLIND_ID}" + assert cover.name is None + assert cover._device_name == "Test Cover" assert cover._attr_current_cover_position is None assert cover._travel_time_open == DEFAULT_TRAVEL_TIME assert cover._travel_time_close == DEFAULT_TRAVEL_TIME +def test_cover_unique_id_is_stable_after_rename( + mock_api: SchellenbergUsbApi, +) -> None: + """Test a friendly-name change cannot change registry identity.""" + original = SchellenbergCover( + api=mock_api, + device_id="ABC123", + device_enum="01", + device_name="Extension 0", + blind_id=TEST_BLIND_ID, + ) + renamed = SchellenbergCover( + api=mock_api, + device_id="ABC123", + device_enum="01", + device_name="Garden blind", + blind_id=TEST_BLIND_ID, + ) + other_blind = SchellenbergCover( + api=mock_api, + device_id="DEF456", + device_enum="02", + device_name="Other blind", + blind_id="22222222-2222-4222-8222-222222222222", + ) + + assert original.unique_id == renamed.unique_id + assert original._device_name != renamed._device_name + assert other_blind.unique_id != original.unique_id + + @pytest.mark.asyncio async def test_cover_initialization_with_calibration( hass: HomeAssistant, @@ -235,7 +389,76 @@ async def test_cover_async_open_cover( assert cover._attr_is_opening is True assert cover._attr_is_closing is False - _async_mock(mock_api.control_blind).assert_called_once_with("01", "01") + _async_mock(mock_api.control_blind).assert_called_once_with( + "01", "01", device_id="ABC123" + ) + + +@pytest.mark.asyncio +async def test_cover_uses_split_identity_and_inverted_direction( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, +) -> None: + """Test commands use command identity while status direction is inverted.""" + cover = SchellenbergCover( + api=mock_api, + device_id="STABLE1", + device_enum="23", + device_name="Sitting room", + command_device_id="F2B8D5", + status_device_id="3720B8", + status_enum="08", + invert_direction=True, + ) + cover.hass = hass + cover._attr_current_cover_position = 0 + + with ( + patch.object(cover, "_start_position_tracking"), + patch.object(cover, "async_write_ha_state"), + ): + await cover.async_open_cover() + cover._handle_event(EVENT_STARTED_MOVING_DOWN) + + _async_mock(mock_api.control_blind).assert_awaited_once_with( + "23", "02", device_id="F2B8D5" + ) + assert cover._attr_is_opening is True + assert cover._attr_is_closing is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("event", ["C1", "C2", "C3"]) +async def test_unknown_secondary_commands_do_not_change_position_tracking( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, + event: str, +) -> None: + """Test opaque secondary commands leave movement and position unchanged.""" + cover = SchellenbergCover( + api=mock_api, + device_id="F2B8D5", + device_enum="10", + device_name="Sitting room", + status_device_id="3720B8", + status_enum="08", + secondary_status_identities=(("F2B8D5", "23"),), + ) + cover.hass = hass + cover._attr_current_cover_position = 50 + + with ( + patch.object(cover, "_start_position_tracking") as start_tracking, + patch.object(cover, "async_write_ha_state"), + ): + cover._handle_event(event) + + start_tracking.assert_not_called() + assert cover._attr_current_cover_position == 50 + assert cover._attr_is_opening is False + assert cover._attr_is_closing is False + assert cover._move_start_time is None + _magic_mock(mock_api.record_position_update).assert_not_called() @pytest.mark.asyncio @@ -259,7 +482,9 @@ async def test_cover_async_close_cover( assert cover._attr_is_opening is False assert cover._attr_is_closing is True - _async_mock(mock_api.control_blind).assert_called_once_with("01", "02") + _async_mock(mock_api.control_blind).assert_called_once_with( + "01", "02", device_id="ABC123" + ) @pytest.mark.asyncio @@ -278,13 +503,17 @@ async def test_cover_async_stop_cover( cover._attr_is_opening = True cover._attr_current_cover_position = 50 - with patch.object(cover, "_stop_position_tracking"): - with patch.object(cover, "async_write_ha_state"): - await cover.async_stop_cover() + with ( + patch.object(cover, "_async_cancel_position_tracking", new_callable=AsyncMock), + patch.object(cover, "async_write_ha_state"), + ): + await cover.async_stop_cover() assert cover._attr_is_opening is False assert cover._attr_is_closing is False - _async_mock(mock_api.control_blind).assert_called_once_with("01", "00") + _async_mock(mock_api.control_blind).assert_called_once_with( + "01", "00", device_id="ABC123" + ) @pytest.mark.asyncio @@ -379,6 +608,19 @@ async def test_cover_restore_position( assert cover._attr_current_cover_position == 75 assert cover._attr_is_closed is False + assert cover._position_source_kind == "restored HA state" + assert cover._position_confirmed_since_restart is False + _async_mock(mock_api.control_blind).assert_not_awaited() + _magic_mock(mock_api.record_position_update).assert_called_once_with( + "ABC123", + source="restored HA state", + direction="idle", + previous_position=None, + new_position=75, + position_source="restored HA state", + confirmed_since_restart=False, + status="restored / estimated / not confirmed since restart", + ) @pytest.mark.asyncio @@ -451,6 +693,17 @@ async def test_cover_handle_started_moving_up( assert cover._attr_is_opening is True assert cover._attr_is_closing is False assert cover._move_start_position == 0 + assert cover._position_confirmed_since_restart is True + _magic_mock(mock_api.record_position_update).assert_called_once_with( + "ABC123", + source="primary status ABC123/01 command 01", + direction="opening", + previous_position=0, + new_position=0, + position_source="primary status", + confirmed_since_restart=True, + status="confirmed", + ) @pytest.mark.asyncio @@ -523,11 +776,18 @@ async def test_cover_update_position_opening( cover._attr_current_cover_position = 0 cover._move_start_position = 0 cover._move_start_time = time.monotonic() - 10.0 # Simulating 10 seconds elapsed + cover._position_update_source = "primary status ABC123/01 command 01" cover._update_position() # After 10 seconds of 20 second travel time, should be at 50% assert 45 <= cover._attr_current_cover_position <= 55 # Allow some tolerance + _, kwargs = _magic_mock(mock_api.record_position_update).call_args + assert kwargs["source"] == "primary status ABC123/01 command 01" + assert kwargs["direction"] == "opening" + assert kwargs["previous_position"] == 0 + assert 45 <= kwargs["new_position"] <= 55 + assert kwargs["status"] == "estimated" @pytest.mark.asyncio @@ -606,6 +866,179 @@ async def test_cover_calibration_different_device( assert cover._attr_current_cover_position == 50 +@pytest.mark.parametrize("position", [0, 42, 100]) +def test_manual_position_sync_updates_cover_and_stops_estimator( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, + position: int, +) -> None: + """Test a manual sync immediately becomes the cover's confirmed state.""" + cover = SchellenbergCover( + api=mock_api, + device_id="stable-id", + device_enum="10", + device_name="Test Cover", + command_device_id="F2B8D5", + status_device_id="3720B8", + status_enum="08", + ) + cover.hass = hass + cover._attr_current_cover_position = 50 + cover._attr_is_opening = True + cover._attr_is_closing = False + cover._move_start_time = 123.0 + cover._move_start_position = 50 + cover._target_position = 75 + _magic_mock(mock_api.record_position_update).reset_mock() + + with ( + patch.object(cover, "_stop_position_tracking") as stop_tracking, + patch.object(cover, "async_write_ha_state") as write_state, + ): + cover._handle_manual_position_sync(position) + + stop_tracking.assert_called_once_with() + write_state.assert_called_once_with() + assert cover._attr_current_cover_position == position + assert cover._attr_is_closed is (position == 0) + assert cover._attr_is_opening is False + assert cover._attr_is_closing is False + assert cover._move_start_time is None + assert cover._move_start_position is None + assert cover._target_position is None + _magic_mock(mock_api.record_position_update).assert_called_once_with( + "F2B8D5", + source="Developer Tools manual position sync", + direction="manual", + previous_position=50, + new_position=position, + position_source="manual sync", + confirmed_since_restart=True, + status="confirmed/manual", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("command", "restored_position", "travel_time_key", "direction", "endpoint"), + [ + ("open", 75, CONF_OPEN_TIME, "opening", 100), + ("close", 25, CONF_CLOSE_TIME, "closing", 0), + ], +) +async def test_full_travel_after_restore_resyncs_endpoint( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, + command: str, + restored_position: int, + travel_time_key: str, + direction: str, + endpoint: int, +) -> None: + """Test a complete first movement anchors a restored startup estimate.""" + import time + + travel_time = 20.0 + cover = SchellenbergCover( + api=mock_api, + device_id="ABC123", + device_enum="01", + device_name="Test Cover", + device_data={travel_time_key: travel_time}, + ) + cover.hass = hass + last_state = State( + "cover.test_cover", + "open", + {"current_position": restored_position}, + ) + + with ( + patch.object(cover, "async_get_last_state", return_value=last_state), + patch("custom_components.schellenberg_usb.cover.async_dispatcher_connect"), + patch.object(cover, "async_write_ha_state"), + ): + await cover.async_added_to_hass() + + _magic_mock(mock_api.record_position_update).reset_mock() + with ( + patch.object(cover, "_start_position_tracking"), + patch.object(cover, "async_write_ha_state"), + ): + if command == "open": + await cover.async_open_cover() + else: + await cover.async_close_cover() + + assert cover._full_travel_resync_direction == direction + cover._move_start_time = time.monotonic() - travel_time - 0.1 + + with ( + patch( + "custom_components.schellenberg_usb.cover.asyncio.sleep", + new=AsyncMock(return_value=None), + ), + patch.object(cover, "async_write_ha_state"), + ): + await cover._async_position_update_loop() + + assert cover._attr_current_cover_position == endpoint + assert cover._position_source_kind == "HA command" + assert cover._position_confirmed_since_restart is True + assert cover._full_travel_resync_direction is None + _, kwargs = _magic_mock(mock_api.record_position_update).call_args + assert kwargs == { + "source": f"Home Assistant {command} command", + "direction": direction, + "previous_position": restored_position, + "new_position": endpoint, + "position_source": "HA command", + "confirmed_since_restart": True, + "status": "estimated from full travel", + } + + +@pytest.mark.asyncio +async def test_unknown_status_does_not_alias_command_identity( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, +) -> None: + """Test a controllable cover loads without registering transmit ID as status.""" + cover = SchellenbergCover( + api=mock_api, + device_id="06C5C0", + device_enum="11", + device_name="Garden", + command_device_id="06C5C0", + status_identity_source=STATUS_IDENTITY_SOURCE_UNKNOWN, + ) + cover.hass = hass + + with ( + patch.object(cover, "async_get_last_state", return_value=None), + patch( + "custom_components.schellenberg_usb.cover.async_dispatcher_connect" + ) as dispatcher_connect, + patch.object(cover, "async_write_ha_state"), + ): + await cover.async_added_to_hass() + + assert cover._status_device_id is None + assert cover._status_enum is None + _magic_mock(mock_api.register_entity).assert_called_once_with( + None, + None, + "Garden", + command_device_id="06C5C0", + command_enum="11", + secondary_status_identities=(), + ) + assert not any( + str(call.args[1]).startswith("schellenberg_usb_device_event_") + for call in dispatcher_connect.call_args_list + ) + + @pytest.mark.asyncio async def test_cover_registers_with_api( hass: HomeAssistant, @@ -620,20 +1053,48 @@ async def test_cover_registers_with_api( ) cover.hass = hass - with patch.object(cover, "async_get_last_state", return_value=None): - with patch("custom_components.schellenberg_usb.cover.async_dispatcher_connect"): - with patch.object(cover, "async_write_ha_state"): - await cover.async_added_to_hass() + with ( + patch.object(cover, "async_get_last_state", return_value=None), + patch( + "custom_components.schellenberg_usb.cover.async_dispatcher_connect" + ) as dispatcher_connect, + patch.object(cover, "async_write_ha_state"), + ): + await cover.async_added_to_hass() + + assert any( + call.args[1] == "schellenberg_usb_manual_position_sync_ABC123" + and call.args[2] == cover._handle_manual_position_sync + for call in dispatcher_connect.call_args_list + ) + _magic_mock(mock_api.register_entity).assert_called_once_with( + "ABC123", + "01", + "Test Cover", + command_device_id="ABC123", + command_enum="01", + secondary_status_identities=(), + ) - _magic_mock(mock_api.register_entity).assert_called_once_with("ABC123", "01") + +def _pending_position_update_tasks() -> list[asyncio.Task[Any]]: + """Return live Schellenberg cover position tasks except this test task.""" + current_task = asyncio.current_task() + return [ + task + for task in asyncio.all_tasks() + if task is not current_task + and not task.done() + and task.get_name().startswith(f"{DOMAIN} position update") + ] @pytest.mark.asyncio -async def test_cover_will_remove_from_hass( +async def test_position_update_loop_cancelled_on_entity_removal( hass: HomeAssistant, mock_api: SchellenbergUsbApi, ) -> None: - """Test cover cleanup on removal.""" + """Test entity removal awaits cancellation of a sleeping position loop.""" cover = SchellenbergCover( api=mock_api, device_id="ABC123", @@ -641,7 +1102,150 @@ async def test_cover_will_remove_from_hass( device_name="Test Cover", ) cover.hass = hass + cover._attr_current_cover_position = 20 + cover._attr_is_opening = True + cover._move_start_position = 20 + cover._move_start_time = 0.0 + cover._start_position_tracking() + task = cover._position_update_task + assert task is not None + + await cover.async_will_remove_from_hass() + + assert task.done() + assert task.cancelled() + assert cover._position_update_task is None - with patch.object(cover, "_stop_position_tracking") as mock_stop: - await cover.async_will_remove_from_hass() - mock_stop.assert_called_once() + +@pytest.mark.asyncio +async def test_no_pending_position_tasks_after_config_entry_unload( + hass: HomeAssistant, + mock_config_entry: ConfigEntry, + mock_api: SchellenbergUsbApi, +) -> None: + """Test config-entry lifecycle cancellation leaves no coroutine pending.""" + cover = SchellenbergCover( + api=mock_api, + device_id="ABC123", + device_enum="01", + device_name="Test Cover", + config_entry_id=mock_config_entry.entry_id, + ) + cover.hass = hass + cover._attr_current_cover_position = 50 + cover._start_position_tracking() + task = cover._position_update_task + assert task is not None + + await mock_config_entry._async_process_on_unload(hass) + + assert task.done() + assert task.cancelled() + assert cover._position_update_task is None + assert _pending_position_update_tasks() == [] + + +@pytest.mark.asyncio +async def test_cancelling_position_loop_during_sleep_exits_cleanly( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, +) -> None: + """Test cancellation interrupts the loop while it is inside asyncio.sleep.""" + cover = SchellenbergCover( + api=mock_api, + device_id="ABC123", + device_enum="01", + device_name="Test Cover", + ) + cover.hass = hass + sleep_started = asyncio.Event() + + async def _sleep_until_cancelled(_delay: float) -> None: + sleep_started.set() + await asyncio.Future() + + with patch( + "custom_components.schellenberg_usb.cover.asyncio.sleep", + new=_sleep_until_cancelled, + ): + cover._start_position_tracking() + task = cover._position_update_task + assert task is not None + await sleep_started.wait() + await cover._async_cancel_position_tracking("test cancellation during sleep") + + assert task.done() + assert task.cancelled() + assert cover._position_update_task is None + + +@pytest.mark.asyncio +async def test_repeated_commands_replace_loop_without_duplicates( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, +) -> None: + """Test repeated Open/Close/Stop commands keep at most one active loop.""" + cover = SchellenbergCover( + api=mock_api, + device_id="ABC123", + device_enum="01", + device_name="Test Cover", + ) + cover.hass = hass + cover._attr_current_cover_position = 50 + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + first_task = cover._position_update_task + assert first_task is not None + + cover._handle_event(EVENT_STARTED_MOVING_UP) + assert cover._position_update_task is first_task + assert len(_pending_position_update_tasks()) == 1 + + await cover.async_close_cover() + second_task = cover._position_update_task + assert second_task is not None + assert second_task is not first_task + assert first_task.cancelling() + assert len(_pending_position_update_tasks()) == 1 + + await cover.async_stop_cover() + + await asyncio.gather(first_task, second_task, return_exceptions=True) + assert cover._position_update_task is None + assert _pending_position_update_tasks() == [] + + +@pytest.mark.asyncio +async def test_home_assistant_stop_cancels_position_loop_before_final_write( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the HA stop listener awaits the position loop without warnings.""" + cover = SchellenbergCover( + api=mock_api, + device_id="ABC123", + device_enum="01", + device_name="Test Cover", + ) + cover.hass = hass + with ( + patch.object(cover, "async_get_last_state", return_value=None), + patch("custom_components.schellenberg_usb.cover.async_dispatcher_connect"), + patch.object(cover, "async_write_ha_state"), + ): + await cover.async_added_to_hass() + + cover._start_position_tracking() + task = cover._position_update_task + assert task is not None + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert task.done() + assert cover._position_update_task is None + assert _pending_position_update_tasks() == [] + assert "still running after final writes shutdown stage" not in caplog.text diff --git a/tests/test_init.py b/tests/test_init.py index 9f7b44f..6dbec7b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -3,18 +3,26 @@ from __future__ import annotations from types import MappingProxyType +from uuid import UUID from unittest.mock import AsyncMock, patch import pytest -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from custom_components.schellenberg_usb.api import SchellenbergUsbApi from custom_components.schellenberg_usb.const import ( + CMD_UP, + CONF_BLIND_ID, + CONF_COMMAND, + CONF_DEVICE_ID, + CONF_ENUM, CONF_SERIAL_PORT, DOMAIN, PLATFORMS, + SERVICE_TEST_COMMAND, + SUBENTRY_TYPE_BLIND, ) @@ -40,6 +48,73 @@ def mock_config_entry(hass: HomeAssistant) -> ConfigEntry: return entry +def test_legacy_blind_id_is_backfilled_once( + hass: HomeAssistant, mock_config_entry: ConfigEntry +) -> None: + """Test a legacy blind gets one persisted UUID that never changes.""" + from custom_components.schellenberg_usb import _async_backfill_blind_ids + + legacy = ConfigSubentry( + data=MappingProxyType({CONF_DEVICE_ID: "ABC123"}), + subentry_type=SUBENTRY_TYPE_BLIND, + title="Legacy blind", + unique_id="ABC123", + ) + hass.config_entries.async_add_subentry(mock_config_entry, legacy) + + assert _async_backfill_blind_ids(hass, mock_config_entry) + saved_blind_id = mock_config_entry.subentries[legacy.subentry_id].data[ + CONF_BLIND_ID + ] + assert str(UUID(saved_blind_id)) == saved_blind_id + + assert not _async_backfill_blind_ids(hass, mock_config_entry) + assert ( + mock_config_entry.subentries[legacy.subentry_id].data[CONF_BLIND_ID] + == saved_blind_id + ) + + +def test_backfill_replaces_duplicate_and_missing_blind_ids( + hass: HomeAssistant, mock_config_entry: ConfigEntry +) -> None: + """Test every blind receives a different valid registry UUID.""" + from custom_components.schellenberg_usb import _async_backfill_blind_ids + + duplicate_id = "11111111-1111-4111-8111-111111111111" + first = ConfigSubentry( + data=MappingProxyType({CONF_DEVICE_ID: "ABC123", CONF_BLIND_ID: duplicate_id}), + subentry_type=SUBENTRY_TYPE_BLIND, + title="First blind", + unique_id="ABC123", + ) + second = ConfigSubentry( + data=MappingProxyType({CONF_DEVICE_ID: "DEF456", CONF_BLIND_ID: duplicate_id}), + subentry_type=SUBENTRY_TYPE_BLIND, + title="Second blind", + unique_id="DEF456", + ) + third = ConfigSubentry( + data=MappingProxyType({CONF_DEVICE_ID: "789ABC"}), + subentry_type=SUBENTRY_TYPE_BLIND, + title="Third blind", + unique_id="789ABC", + ) + for subentry in (first, second, third): + hass.config_entries.async_add_subentry(mock_config_entry, subentry) + + assert _async_backfill_blind_ids(hass, mock_config_entry) + saved_ids = [ + mock_config_entry.subentries[subentry.subentry_id].data[CONF_BLIND_ID] + for subentry in (first, second, third) + ] + + assert saved_ids[0] == duplicate_id + assert len(set(saved_ids)) == 3 + assert all(str(UUID(blind_id)) == blind_id for blind_id in saved_ids) + assert not _async_backfill_blind_ids(hass, mock_config_entry) + + @pytest.mark.asyncio async def test_async_setup_entry_basic( hass: HomeAssistant, mock_config_entry: ConfigEntry @@ -62,6 +137,69 @@ async def test_async_setup_entry_basic( assert isinstance(mock_config_entry.runtime_data, SchellenbergUsbApi) +@pytest.mark.asyncio +async def test_subentry_change_listener_reloads_once_and_is_removed_on_unload( + hass: HomeAssistant, mock_config_entry: ConfigEntry +) -> None: + """Test subentry reload listeners do not accumulate across reloads.""" + from custom_components.schellenberg_usb import async_setup_entry + + with ( + patch.object(SchellenbergUsbApi, "connect", new_callable=AsyncMock), + patch.object( + hass.config_entries, "async_forward_entry_setups", new_callable=AsyncMock + ), + patch.object( + hass.config_entries, "async_reload", new_callable=AsyncMock + ) as reload_entry, + ): + await async_setup_entry(hass, mock_config_entry) + hass.config_entries.async_add_subentry( + mock_config_entry, + ConfigSubentry( + data=MappingProxyType({CONF_DEVICE_ID: "F2B8D5"}), + subentry_type=SUBENTRY_TYPE_BLIND, + title="Sitting room door", + unique_id="F2B8D5", + ), + ) + await hass.async_block_till_done() + + reload_entry.assert_awaited_once_with(mock_config_entry.entry_id) + assert len(mock_config_entry.update_listeners) == 1 + await mock_config_entry._async_process_on_unload(hass) + + assert mock_config_entry.update_listeners == [] + + +@pytest.mark.asyncio +async def test_async_setup_registers_test_command_service( + hass: HomeAssistant, mock_config_entry: ConfigEntry +) -> None: + """Test the diagnostic service validates and forwards a command.""" + from custom_components.schellenberg_usb import async_setup + + api = SchellenbergUsbApi(hass, "/dev/ttyUSB0") + api.control_blind = AsyncMock(return_value=True) # type: ignore[method-assign] + mock_config_entry.runtime_data = api + + assert await async_setup(hass, {}) + await hass.services.async_call( + DOMAIN, + SERVICE_TEST_COMMAND, + { + CONF_DEVICE_ID: "f2b8d5", + CONF_ENUM: "23", + CONF_COMMAND: "open", + }, + blocking=True, + ) + + api.control_blind.assert_awaited_once_with( # type: ignore[attr-defined] + "23", CMD_UP, device_id="F2B8D5", source="service" + ) + + @pytest.mark.asyncio async def test_async_setup_entry_creates_hub_device( hass: HomeAssistant, mock_config_entry: ConfigEntry diff --git a/tests/test_translations.py b/tests/test_translations.py new file mode 100644 index 0000000..8c2339b --- /dev/null +++ b/tests/test_translations.py @@ -0,0 +1,73 @@ +"""Test config-flow menu translation coverage.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from custom_components.schellenberg_usb.config_flow import ( + DEVELOPER_TOOLS_MENU_OPTIONS, +) + +COMPONENT_DIR = Path(__file__).parents[1] / "custom_components" / "schellenberg_usb" +EXPECTED_MENU_OPTIONS = { + "user": {"pair_test", "pair_device", "manual"}, + "manual_next": {"test_motor", "save_manual"}, + "test_success": {"calibration_close", "manual_times"}, + "reconfigure": {"edit", "test_existing", "developer_tools", "calibrate"}, + "developer_tools": set(DEVELOPER_TOOLS_MENU_OPTIONS), +} + + +def _load_json(path: Path) -> dict[str, Any]: + """Load one integration translation document.""" + return json.loads(path.read_text(encoding="utf-8")) + + +def _leaf_paths(value: Any, prefix: str = "") -> set[str]: + """Return all leaf paths in a nested translation document.""" + if not isinstance(value, dict): + return {prefix} + return { + leaf + for key, child in value.items() + for leaf in _leaf_paths(child, f"{prefix}.{key}" if prefix else key) + } + + +def test_developer_tools_runtime_fallback_labels_are_non_empty() -> None: + """Every runtime Developer Tools action has a visible fallback label.""" + assert DEVELOPER_TOOLS_MENU_OPTIONS["reset_stick"] == ( + "Reset stick / reconnect serial" + ) + assert all( + isinstance(label, str) and label.strip() + for label in DEVELOPER_TOOLS_MENU_OPTIONS.values() + ) + + +def test_all_config_subentry_menu_options_have_labels() -> None: + """Every runtime menu option must have a non-empty label in every locale.""" + paths = [ + COMPONENT_DIR / "strings.json", + *sorted((COMPONENT_DIR / "translations").glob("*.json")), + ] + + for path in paths: + steps = _load_json(path)["config_subentries"]["blind"]["step"] + for step_id, expected_options in EXPECTED_MENU_OPTIONS.items(): + labels = steps[step_id]["menu_options"] + assert set(labels) == expected_options, f"{path.name}: {step_id}" + assert all( + isinstance(label, str) and label.strip() for label in labels.values() + ), f"{path.name}: {step_id} contains a blank label" + + +def test_locale_files_cover_all_source_translation_keys() -> None: + """Partial locale files must not hide newer English flow labels.""" + source_paths = _leaf_paths(_load_json(COMPONENT_DIR / "strings.json")) + + for path in sorted((COMPONENT_DIR / "translations").glob("*.json")): + missing = source_paths - _leaf_paths(_load_json(path)) + assert not missing, f"{path.name} is missing: {sorted(missing)}" diff --git a/uv.lock b/uv.lock index a1259e5..aa894c1 100644 --- a/uv.lock +++ b/uv.lock @@ -2469,15 +2469,15 @@ wheels = [ ] [[package]] -name = "pyserial-asyncio" -version = "0.6" +name = "pyserial-asyncio-fast" +version = "0.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyserial" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/9a/8477699dcbc1882ea51dcff4d3c25aa3f2063ed8f7d7a849fd8f610506b6/pyserial-asyncio-0.6.tar.gz", hash = "sha256:b6032923e05e9d75ec17a5af9a98429c46d2839adfaf80604d52e0faacd7a32f", size = 31322, upload-time = "2021-09-30T22:29:02.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d1/6c444e0f6b886345a7993d358c6734ccc440521cdca4999601e86f111708/pyserial_asyncio_fast-0.16.tar.gz", hash = "sha256:fd52643380406739d777014b0aea0873d756b542eb62f7556567239cec007115", size = 32696, upload-time = "2025-03-27T02:35:20.624Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/24/c820cf15f87f7b164e83710c1852d4f900d9793961579e5ef64189bc0c10/pyserial_asyncio-0.6-py3-none-any.whl", hash = "sha256:de9337922619421b62b9b1a84048634b3ac520e1d690a674ed246a2af7ce1fc5", size = 7594, upload-time = "2021-09-30T22:29:00.12Z" }, + { url = "https://files.pythonhosted.org/packages/1b/19/f76987bad313bb2dabf21914c1ec7441a1e846f05764f9948f1ccc2640a8/pyserial_asyncio_fast-0.16-py3-none-any.whl", hash = "sha256:88939d94e341a04c0c8bc3c1ed4e874439cb5a1e21ccfb0fd7315a8e45df1687", size = 9729, upload-time = "2025-03-27T02:35:19.062Z" }, ] [[package]] @@ -2938,7 +2938,7 @@ name = "schellenber-usb" version = "1.0.0" source = { virtual = "." } dependencies = [ - { name = "pyserial-asyncio" }, + { name = "pyserial-asyncio-fast" }, ] [package.dev-dependencies] @@ -2978,7 +2978,7 @@ test = [ ] [package.metadata] -requires-dist = [{ name = "pyserial-asyncio", specifier = "==0.6" }] +requires-dist = [{ name = "pyserial-asyncio-fast", specifier = "==0.16" }] [package.metadata.requires-dev] dev = [