From 6531b12c014d82c032ae78e237a1d0845949f877 Mon Sep 17 00:00:00 2001 From: hrabbach Date: Fri, 26 Jun 2026 20:44:07 +0200 Subject: [PATCH] docs: add architecture, getting-started, development, testing, configuration guides; supplement README with v1.3.0 timed-motor flows --- README.md | 97 +++++++++++ docs/ARCHITECTURE.md | 361 ++++++++++++++++++++++++++++++++++++++++ docs/CONFIGURATION.md | 156 +++++++++++++++++ docs/DEVELOPMENT.md | 254 ++++++++++++++++++++++++++++ docs/GETTING-STARTED.md | 122 ++++++++++++++ docs/TESTING.md | 187 +++++++++++++++++++++ 6 files changed, 1177 insertions(+) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/GETTING-STARTED.md create mode 100644 docs/TESTING.md diff --git a/README.md b/README.md index f89618a..46078fd 100644 --- a/README.md +++ b/README.md @@ -127,3 +127,100 @@ To enter pairing mode, refer to your specific remote control or timer switch man > [!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. + +--- + +## Motor Types: Bidirectional vs Timed (Non-Bidirectional) + +As of v1.3.0, the integration distinguishes between two motor types: + +| Type | Description | +|------|-------------| +| **Bidirectional** | Motor sends movement events back to the stick. The integration detects when motion starts and stops — this is the classic mode. All previously paired motors are treated as bidirectional by default. | +| **Timed (non-bidirectional)** | Motor gives no movement feedback. The integration cannot detect when the motor starts or stops moving, so it times runs by measuring the wall-clock duration between button presses instead of waiting for events. | + +You select the motor type once, when adding the device. A timed motor requires a separate calibration flow (described below); a bidirectional motor uses the event-based flow documented in [Step 5: Calibrate your blinds](#step-5-calibrate-your-blinds). + +> [!NOTE] +> Legacy subentries created before v1.3.0 have no mode flag and are treated as bidirectional — existing paired motors are unaffected. + +--- + +## Adding an Already-Paired Motor Manually + +If a motor is already paired (for example it responds to an existing Schellenberg remote) you can add it to Home Assistant without triggering the radio-pairing procedure: + +1. In Home Assistant, go to **Settings > Devices & Services**. +2. Find the **Schellenberg USB** integration and click on it. +3. Click the **+** button to add a device. +4. When the menu appears, choose **Add manually**. +5. Enter the motor's **device enum** — a two-character hex value (e.g. `10`, `11`, `1A`) that identifies the device on the radio bus. +6. Choose the **motor mode**: toggle on for bidirectional, toggle off for timed/non-bidirectional. +7. Optionally provide a friendly name (defaults to `Blind ` if left blank). +8. For **timed motors only**: a second screen asks for the **initial position** (0–100 %). Set this to the motor's current physical position so the integration starts tracking from the right point. Use 100 % if the shutter is currently fully open. +9. Click **Submit** — the device is created immediately with no radio-pairing step. + +> [!TIP] +> The device enum is the two-hex-character address the stick uses to address the motor. If you are unsure of the value, check your previous pairing records or consult the integration logs — each enrolled device is logged with its enum at startup. + +--- + +## Timed Calibration (for Non-Bidirectional Motors) + +> [!IMPORTANT] +> This section describes the **timed calibration flow** for non-bidirectional (timed) motors. It is separate from and additional to the event-based "Calibration Steps" in [Step 5](#step-5-calibrate-your-blinds), which applies only to bidirectional motors. Do not use the event-based flow for a timed motor — it will wait indefinitely for movement events that never arrive. + +Because timed motors send no movement feedback, calibration is driven entirely by button presses: the integration sends a command, you watch the shutter move, and you press a button when it stops. + +#### Prerequisites + +- The shutter must be **fully open** (all the way up) before you start. If it is not, drive it to the top using your physical remote first. + +#### Starting Timed Calibration + +Access timed calibration the same way as regular calibration: + +- **After adding a timed motor**: the timed calibration flow launches automatically once the device is created. +- **Later, from the device page**: click the **Calibrate** gear icon (⚙️) on the device page. + +#### Timed Calibration Steps + +1. **Precondition check**: Confirm the shutter is fully open. Press **Next** when ready — no command is sent at this point. + +2. **Close run**: + - The integration sends a **close** command to the motor. + - Watch the shutter travel all the way down to its physical endstop. Wait until it has fully stopped. + - Press **Next** to record the elapsed time. + - The integration does **not** send a stop command — the motor coasts to its physical endstop naturally. + +3. **Open run**: + - The integration sends an **open** command to the motor. + - Watch the shutter travel all the way up. Wait until it has fully stopped at the top endstop. + - Press **Next** to record the elapsed time. + - Again, no stop command is sent — the motor stops at its physical endstop. + +4. **Confirm**: The integration shows the measured close time and open time. Press **Done** to save, or check **Redo** to discard the measurements and start again from the precondition step. + +#### Guard Limits + +The integration validates each run before accepting it: + +| Condition | Guard | What to do | +|-----------|-------|------------| +| You pressed Next in under **2 seconds** | Rejected (likely a double-press or misfire) | The form re-shows; drive the shutter back to fully open manually and retry | +| You waited more than **120 seconds** | Rejected ("walked away" run) | The form re-shows; drive the shutter back to fully open manually and retry | + +> [!IMPORTANT] +> After a guard error, the shutter position is unknown — it may have stopped mid-travel. Return the shutter to the **fully open** position using your physical remote before pressing Next again. + +--- + +## Driving a Timed Motor to a Position + +Once a timed motor has been calibrated, the position slider in the Home Assistant UI becomes active. Requesting a percentage (e.g., 50 %) causes the integration to: + +1. Determine the direction of travel (open or close) from the current tracked position. +2. Send the appropriate command to the motor. +3. Schedule a stop command after the computed fraction of the full-travel time has elapsed. + +The integration tracks position by dead-reckoning — it uses the calibrated open and close times to estimate where the shutter is. If the motor is ever moved outside of Home Assistant (e.g., by a physical remote), the tracked position will drift until the next full-travel recalibration. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..89bc6d4 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,361 @@ + +# Architecture + +## System Overview + +The Schellenberg USB Integration is a Home Assistant custom component that bridges +Schellenberg roller-shutter motors to the HA platform over a USB Funk-Stick. The stick +connects to the host via a serial port at a fixed 112500 bps baud rate and speaks a +proprietary binary-text protocol. The integration exposes each paired motor as a +`cover` entity with time-based position tracking, the USB stick itself as three `sensor` +entities (connection status, firmware version, operating mode), and an LED switch. All +I/O is asynchronous — there is no polling; entities update via HA's dispatcher mechanism. + +The system is designed around a fundamental hardware constraint: non-bidirectional +("timed") motors send no confirmation of movement. Control and position tracking for +those motors are purely time-based, using `time.monotonic()` and pre-measured travel +times. Bidirectional motors transmit `ss`-prefix frames back to the stick, which the +integration uses for event-driven state updates. + +--- + +## Component Map + +``` +┌─────────────────────────────────────────────────────────┐ +│ Home Assistant UI / REST API │ +└────────────────────┬────────────────────────────────────┘ + │ config_entries / entity platform +┌────────────────────▼────────────────────────────────────┐ +│ __init__.py │ +│ async_setup_entry — creates SchellenbergUsbApi, │ +│ stores in entry.runtime_data, forwards to platforms, │ +│ tracks subentry additions, live-applies hub options │ +└────┬──────────────────────────────────────┬─────────────┘ + │ entry.runtime_data (api) │ async_forward_entry_setups + ▼ ▼ +┌──────────────────┐ ┌─────────────────────────────────┐ +│ api.py │ │ cover.py / sensor.py / switch.py│ +│ SchellenbergUsbApi │ SchellenbergCover │ +│ SchellenbergProtocol │ SchellenbergConnectionSensor │ +│ │ │ SchellenbergVersionSensor │ +│ serial link │ │ SchellenbergModeSensor │ +│ 112500 bps │ │ SchellenbergLedSwitch │ +└────────┬─────────┘ └────────────┬────────────────────┘ + │ async_dispatcher_send │ async_dispatcher_connect + └──────────────┬──────────────────┘ + │ HA dispatcher bus + SIGNAL_DEVICE_EVENT_{device_id} + SIGNAL_STICK_STATUS_UPDATED + SIGNAL_CALIBRATION_COMPLETED +``` + +**Config flow tree:** + +``` +SchellenbergUsbConfigFlow (config_flow.py) +├── async_step_user — manual serial port entry +├── async_step_usb — USB auto-discovery (VID 16C0 / PID 05E1) +└── SchellenbergPairingSubentryFlow + ├── async_step_menu — pair / manual_add choice + ├── async_step_pair — auto-pair via stick + ├── async_step_manual_add — enum + mode entry + ├── async_step_manual_position — initial position for timed motors + ├── async_step_reconfigure — routes by motor type (bidir vs timed) + ├── CalibrationFlowHandler (options_flow_calibration.py) + │ — event-driven calibration for bidirectional motors + └── TimedCalibrationFlowHandler (options_flow_timed_calibration.py) + — button-press timing for non-bidirectional (timed) motors +``` + +--- + +## Component Responsibilities + +| Module | Class / Function | Responsibility | +|---|---|---| +| `api.py` | `SchellenbergUsbApi` | Serial connection lifecycle, command transmission, stick-busy retry queue, device registry (`_registered_devices`), pairing coordination, futures for async serial responses | +| `api.py` | `SchellenbergProtocol` | `asyncio.Protocol` subclass; buffers incoming bytes, splits on `\n`, dispatches complete lines to `SchellenbergUsbApi._handle_message` | +| `__init__.py` | `async_setup_entry` | Creates `SchellenbergUsbApi`, stores it in `entry.runtime_data`, bootstraps hub subentry, forwards to platforms, registers `_on_entry_updated` to detect subentry changes | +| `cover.py` | `SchellenbergCover` | HA `CoverEntity` + `RestoreEntity`; open/close/stop/set-position; position tracking loop (200 ms tick, 1 s HA push); bidirectional vs timed branching; calibration persistence | +| `cover.py` | `_get_cal_store` / `_save_calibration` | HA `Store` wrapper for `.storage/schellenberg_usb_calibration`; shared across all cover entities via `hass.data` | +| `sensor.py` | `SchellenbergConnectionSensor` / `SchellenbergVersionSensor` / `SchellenbergModeSensor` | Expose `api.is_connected`, `api.device_version`, `api.device_mode`; update on `SIGNAL_STICK_STATUS_UPDATED` | +| `switch.py` | `SchellenbergLedSwitch` | LED on/off/blink by delegating to `api.led_on()` / `api.led_off()` | +| `config_flow.py` | `SchellenbergUsbConfigFlow` | Hub config flow: manual serial port entry + USB auto-discovery | +| `config_flow.py` | `SchellenbergPairingSubentryFlow` | Subentry flow for blind devices; delegates calibration steps to handler classes | +| `options_flow.py` | `SchellenbergOptionsFlowHandler` | Hub options: change serial port, toggle `ignore_unknown`; port change triggers reload, toggle is live-applied without reload | +| `options_flow_calibration.py` | `CalibrationFlowHandler` | Event-driven calibration for bidirectional motors: waits for `SIGNAL_DEVICE_EVENT_{id}` start/stop events; emits `SIGNAL_CALIBRATION_COMPLETED` with `final_position=0` | +| `options_flow_timed_calibration.py` | `TimedCalibrationFlowHandler` | Button-press timing calibration for non-bidirectional motors: sends drive command, user presses a form button when motor reaches endstop, records `time.monotonic()` delta; emits `SIGNAL_CALIBRATION_COMPLETED` with `final_position=100` | +| `const.py` | constants | `DOMAIN`, `CMD_*`, `CONF_*`, `SIGNAL_*` strings, `SchellenbergConfigEntry` type alias, calibration guard constants | + +--- + +## Serial Protocol Layer + +### Physical link + +- **Baud rate:** 112500 bps (fixed; not configurable) +- **USB device:** VID 16C0, PID 05E1, manufacturer "van ooijen" (Schellenberg USB Funk-Stick) +- **Framing:** newline-terminated ASCII lines + +### Connection lifecycle (`api.py:SchellenbergUsbApi.connect`) + +1. `serial_asyncio.create_serial_connection` creates a `SchellenbergProtocol` instance. +2. `verify_device()` sends `!?` (`CMD_VERIFY`) and awaits an `RFTU_V*` response via `_verify_future` (timeout: `VERIFY_TIMEOUT` = 5 s). +3. If mode is not `listening`, a lowercase command (`hello`) is sent to enter listening mode (B:2). +4. `get_device_id()` sends `sr` (`CMD_GET_DEVICE_ID`) and awaits an `sr{6-char-id}` response via `_device_id_future`. +5. On `SerialException`/`OSError`, retry is scheduled via `hass.loop.call_later(5, ...)`. + +### Message parsing (`api.py:SchellenbergUsbApi._handle_message`) + +| Prefix | Format | Action | +|---|---|---| +| `RFTU_` | `RFTU_V20 F: B:` | Sets `_device_version`, `_device_mode`; resolves `_verify_future`; fires `SIGNAL_STICK_STATUS_UPDATED` | +| `t1` / `t0` | — | Transmit ACK; ignored | +| `tE` | — | Stick busy; schedules `_retry_command_after_delay()` (100 ms) to resend `_pending_retry_command` | +| `sr{6}` | `sr5D3E7C` | Device ID response; resolves `_device_id_future` | +| `sl{...}` | `sl00BE{6-char-id}...` | Pairing/list response; device ID extracted at `[6:12]`; resolves `_pairing_future` during pairing | +| `ss{...}` | `ss{enum:2}{device_id:6}{incr:4}{cmd:2}{pad:2}{rssi:2}` | Inbound device event; device enum at `[2:4]`, device ID at `[4:10]`, command at `[14:16]`; dispatches `SIGNAL_DEVICE_EVENT_{device_id}` | + +### Outbound command format + +All device control commands use the `CMD_TRANSMIT` prefix (`ss`): + +``` +ss{device_enum:2}{repeat:1}{command:2}{padding:4} +``` + +Example — open blind with enum `10`: +``` +ss109010000 +``` + +Literal command values (from `const.py`): + +| Constant | Value | Meaning | +|---|---|---| +| `CMD_STOP` | `00` | Stop | +| `CMD_UP` | `01` | Open (up) | +| `CMD_DOWN` | `02` | Close (down) | +| `CMD_PAIR` | `60` | Pair with device | +| `CMD_SET_UPPER_ENDPOINT` | `61` | Set upper travel endpoint | +| `CMD_SET_LOWER_ENDPOINT` | `62` | Set lower travel endpoint | +| `CMD_ALLOW_PAIRING` | `40` | Make device accept new remote | +| `CMD_MANUAL_UP` | `41` | Hold-up (button simulation) | +| `CMD_MANUAL_DOWN` | `42` | Hold-down (button simulation) | + +Stick system commands are uppercase with `!` prefix: `!?` (verify), `!B` (bootloader), `!G` (initial), `!R` (reboot). Lowercase commands control the stick itself: `so+`/`so-` (LED on/off), `so1`–`so9` (LED blink), `sr` (get device ID), `sp` (enter pairing mode). + +--- + +## Dispatcher Signal Flow + +The integration uses HA's `async_dispatcher_send` / `async_dispatcher_connect` for decoupled intra-process communication. No external message bus is used. + +### Signals defined in `const.py` + +| Signal | Sender | Receivers | Payload | +|---|---|---|---| +| `SIGNAL_DEVICE_EVENT_{device_id}` | `SchellenbergUsbApi._handle_message` | `SchellenbergCover._handle_event` | `command: str` (e.g., `"01"`, `"02"`, `"00"`) | +| `SIGNAL_STICK_STATUS_UPDATED` | `SchellenbergUsbApi._update_status` | `SchellenbergBaseSensor._handle_status_update`, `SchellenbergCover._handle_status_update` | (no payload) | +| `SIGNAL_CALIBRATION_COMPLETED` | `CalibrationFlowHandler._save_calibration_data`, `TimedCalibrationFlowHandler._emit_calibration_signal` | `SchellenbergCover._handle_calibration_completed` | `device_id, open_time, close_time, final_position` | + +### Signal routing detail + +`SIGNAL_DEVICE_EVENT_{device_id}` is a per-device signal string — the device ID is +embedded in the signal name (`f"{SIGNAL_DEVICE_EVENT}_{device_id}"`). Each +`SchellenbergCover` subscribes on `async_added_to_hass` and unsubscribes via +`async_on_remove`. Timed motor entities subscribe but immediately return without +side-effects when `_is_bidirectional` is `False` (guard in `_handle_event`). + +`SIGNAL_CALIBRATION_COMPLETED` is broadcast to all cover entities; each entity +filters on the `device_id` argument in `_handle_calibration_completed`. + +--- + +## Bidirectional vs Timed Motor Control + +The `CONF_BIDIRECTIONAL` flag (stored in `ConfigSubentry.data`) governs which code +path is active for a given motor. Default is `True` so legacy auto-paired subentries +without the key are treated as bidirectional. + +### Bidirectional motors + +- Transmit inbound `ss`-frame events on movement start (`01`), stop (`00`), close (`02`). +- `SchellenbergCover._handle_event` reacts to these events to set `_attr_is_opening`, + `_attr_is_closing`, start the position-tracking loop, and snap position on stop. +- Calibration uses `CalibrationFlowHandler`, which subscribes to + `SIGNAL_DEVICE_EVENT_{device_id}` to detect movement start and stop events, then + measures elapsed `time.time()` between them. +- `set_cover_position` is always available. + +### Timed (non-bidirectional) motors + +- Produce no inbound frames. The `_handle_event` guard returns immediately without + mutating state. +- Movement is initiated by `async_open_cover` / `async_close_cover` calling + `api.control_blind()`. Position is computed entirely from `time.monotonic()` delta + and the stored travel times. +- `set_cover_position` requires `_is_calibrated` to be `True`; uncalibrated timed + motors ignore the command. +- Restart behaviour: if the last persisted state was `opening`, position snaps to + 100%; if `closing`, position snaps to 0%; idle states restore from `RestoreEntity`. + If no prior state exists, position defaults to 100% (assume open). +- Calibration uses `TimedCalibrationFlowHandler` — event-free, pure form-button + timing (see Calibration section below). + +### Position tracking loop (`cover.py:SchellenbergCover._async_position_update_loop`) + +Both motor types share the same loop once movement starts: + +1. Wakes every 200 ms (`asyncio.sleep(0.2)`). +2. Calls `_update_position()`: `new_pos = start_pos ± (elapsed / travel_time) * 100`. +3. If `_target_position` is set and the computed position reaches it: + - Sends `CMD_STOP` if target is not 0 or 100 (endstops auto-stop). + - Clears all movement state. +4. Reports state to HA every 1 s (every 5 ticks). +5. Terminates when position reaches 0% or 100% without a partial target. + +--- + +## Calibration Persistence + +Calibration data (open and close travel times in seconds) is stored in +`.storage/schellenberg_usb_calibration` via HA's `Store` API. + +### Store structure + +```json +{ + "": { + "": { + "open_time": 25.40, + "close_time": 23.15 + } + } +} +``` + +### Load path (`cover.py:async_setup_entry`) + +1. `_get_cal_store(hass)` initializes a single `Store` instance per HA session + (cached in `hass.data[_HASS_DATA_KEY]`). +2. Calibration data is merged into `device_data` using `setdefault` — subentry data + wins over persisted data; persisted data fills in gaps. +3. `SchellenbergCover.__init__` treats `None` or `0.0` travel times as uncalibrated + and falls back to `DEFAULT_TRAVEL_TIME` (60 s) for the position computation. + `_is_calibrated` is `False` if either time is `None`. + +### Save path + +- **Bidirectional path:** `CalibrationFlowHandler._save_calibration_data` writes to + the legacy `schellenberg_usb_devices` Store and then dispatches + `SIGNAL_CALIBRATION_COMPLETED`. The cover's `_handle_calibration_completed` + callback calls `_save_calibration` to also write to the calibration Store. +- **Timed path:** `TimedCalibrationFlowHandler._emit_calibration_signal` dispatches + `SIGNAL_CALIBRATION_COMPLETED` directly. The cover callback writes to the + calibration Store. + +Both paths pass `(device_id, open_time, close_time, final_position)` on the signal. +`final_position=0` for the bidirectional flow (ends on a close run); +`final_position=100` for the timed flow (ends on an open run, motor at top). + +--- + +## Timed Calibration Flow (`options_flow_timed_calibration.py`) + +The `TimedCalibrationFlowHandler` is used for non-bidirectional motors that cannot +report movement events. It is entered via `async_step_reconfigure` when +`CONF_BIDIRECTIONAL` is `False`. + +**Flow steps:** + +1. `timed_cal_precondition` — Instruction screen; user confirms shutter is fully open. No command sent. +2. `timed_cal_close` — Sends `CMD_DOWN` via `api.control_blind()`, records + `time.monotonic()` before the `await`. Shows a form. On next submit, records elapsed time. + - Rejects `elapsed < CAL_MIN_TRAVEL_TIME` (2 s) as a misfire. + - Rejects `elapsed > CAL_MAX_TRAVEL_TIME` (120 s) as a "walked away" run. +3. `timed_cal_open` — Sends `CMD_UP`, records start time, shows a form. On next submit, + records elapsed open time with the same guards. +4. `timed_cal_confirm` — Shows measured times. User may confirm or redo. On confirm, + emits `SIGNAL_CALIBRATION_COMPLETED` with `final_position=100`. + +No `CMD_STOP` is ever issued by this handler — motors run to their physical endstops. +`time.monotonic()` is captured before each `await` to avoid inflating timing with +coroutine scheduling latency. + +--- + +## Entry Hierarchy and Device Registry + +``` +ConfigEntry (hub) +│ data: {serial_port: "/dev/ttyUSB0"} +│ runtime_data: SchellenbergUsbApi +│ +├── ConfigSubentry (type: "hub") +│ └── Device: "Schellenberg USB Stick" +│ └── Entities: connection sensor, version sensor, mode sensor, LED switch +│ +├── ConfigSubentry (type: "blind", for each paired motor) +│ │ data: {device_id, device_enum, bidirectional, [open_time, close_time], [initial_position]} +│ └── Device: "{device_name}" +│ └── Entity: SchellenbergCover +``` + +The hub subentry is created automatically on first `async_setup_entry` to keep +hub-level entities (sensors, LED switch) grouped under the hub device. Blind +subentries are created by `SchellenbergPairingSubentryFlow` after pairing or manual +add. When subentries change, `_on_entry_updated` in `__init__.py` detects the diff +via `_SETUP_CALLBACKS[entry_id]["subentry_ids"]` and reloads the config entry. + +--- + +## Key Constraints and Anti-Patterns + +### Serial port sanity check is blocking + +`config_flow.py` and `options_flow.py` open the serial port with the blocking +`serial.Serial(port)` call to validate connectivity before creating/updating the +entry. This is intentional (documented with `# NOTE: blocking open used only to +sanity-check connectivity`) but means the HA event loop is briefly blocked during +flow validation. + +### Device enumerators are allocated sequentially + +`api.initialize_next_device_enum()` scans `_registered_devices.values()` for the +highest existing hex enum and adds 1. Enumerators start at `PAIRING_DEVICE_ENUM_START` +(0x10) and are capped at 0xFF with wrap-around. + +### Stick-busy retry + +When the stick responds `tE`, the last command is stored in `_pending_retry_command` +and re-sent after 100 ms. Only one pending retry exists at a time; a new `tE` cancels +any in-flight retry task before scheduling a fresh one. + +### Ignore unknown signals + +The `CONF_IGNORE_UNKNOWN` hub option demotes log lines for unknown device IDs from +`WARNING` to `DEBUG`. It is live-applied to `api.ignore_unknown` without a reload +when the port path is unchanged. + +--- + +## Directory Structure + +``` +custom_components/schellenberg_usb/ +├── __init__.py — integration setup, subentry tracking +├── api.py — serial layer (SchellenbergUsbApi, SchellenbergProtocol) +├── config_flow.py — hub config + blind subentry flows +├── const.py — DOMAIN, CMD_*, CONF_*, SIGNAL_*, type aliases +├── cover.py — SchellenbergCover entity + calibration store helpers +├── options_flow.py — hub options (serial port, ignore_unknown toggle) +├── options_flow_calibration.py — event-driven calibration (bidirectional motors) +├── options_flow_pairing.py — PairingFlowHandler (legacy options-flow helper) +├── options_flow_timed_calibration.py — button-press timing calibration (timed motors) +├── sensor.py — USB stick status sensors +├── switch.py — LED switch entity +├── manifest.json — integration metadata, pyserial-asyncio dependency +└── strings.json / translations/ — UI strings for config/options flows +``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..0cda1e6 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,156 @@ + +# Configuration + +All configuration for the Schellenberg USB integration is done through the Home Assistant UI. There are no YAML configuration files for this integration — setup, options, and per-device calibration are all managed through config flows and subentry flows. + +## Hub Setup + +The hub (primary config entry) is created once via **Settings → Devices & Services → Add Integration → Schellenberg USB**. + +| Field | Key | Type | Required | Default | Description | +|-------|-----|------|----------|---------|-------------| +| Serial port | `serial_port` | string | Yes | `/dev/ttyUSB0` | OS device path for the USB stick (e.g. `/dev/ttyUSB0`, `/dev/ttyACM0`) | + +**USB auto-discovery:** If the stick is plugged in before the integration is added, Home Assistant discovers it automatically using the USB descriptor declared in `manifest.json`: + +| Attribute | Value | +|-----------|-------| +| Vendor ID (VID) | `16C0` | +| Product ID (PID) | `05E1` | +| Manufacturer string | `van ooijen` | + +During auto-discovery the detected port is pre-filled; the user can correct it before confirming. + +**Baud rate:** Fixed at `112500` bps in `api.py`. This value is set by the Schellenberg protocol and is not configurable. + +--- + +## Hub Options + +After the hub is created, open **Settings → Devices & Services → Schellenberg USB → Configure** to access hub options. + +| Field | Key | Type | Default | Description | +|-------|-----|------|---------|-------------| +| Serial port | `serial_port` | string | Current value | Change the OS device path. Triggers an integration reload when changed. | +| Ignore unknown signals | `ignore_unknown` | boolean | `false` | When `true`, frames from unregistered devices are logged at DEBUG instead of WARNING. Applied live without reloading the integration when the port is unchanged. | + +Stored in: `config_entry.options` (Home Assistant config entry options store). + +--- + +## Per-Device (Subentry) Configuration + +Each paired blind motor is a **subentry** under the hub. Subentries are added via **Settings → Devices & Services → Schellenberg USB → Add device**. + +### Add Methods + +| Method | Description | +|--------|-------------| +| **Pair automatically** | Put the stick into pairing mode and wait for a motor to respond over RF. The motor's device ID and enumerator are captured automatically. | +| **Add manually** | Specify a hex device enumerator directly (for motors already paired by other remotes that will never send a pairing event). | + +### Manual-Add Fields + +| Field | Key | Type | Required | Default | Description | +|-------|-----|------|----------|---------|-------------| +| Device enumerator | `device_enum` | 2-char hex string | Yes | — | Hex enumerator assigned to this motor (e.g. `10`, `11`). Must be unique across all blind subentries. Range: `00`–`FF`. | +| Bidirectional | `bidirectional` | boolean | Yes | `true` | `true` = motor reports movement events back (event-based position tracking). `false` = timed/non-bidirectional motor (position computed from calibration times only). | +| Friendly name | `device_name` | string | No | `Blind {device_enum}` | Display name shown in HA. Falls back to `Blind {enum}` if left blank. | + +For **timed (non-bidirectional) motors only**, a second step collects: + +| Field | Key | Type | Required | Default | Description | +|-------|-----|------|----------|---------|-------------| +| Initial position | `initial_position` | integer 0–100 | No | `100` | Starting position percentage (0 = fully closed, 100 = fully open). Used to seed position tracking before first calibration. Clamped to 0–100. | + +### Subentry Data Keys + +All per-device values are persisted in `subentry.data`: + +| Key | Constant | Description | +|-----|----------|-------------| +| `device_id` | `CONF_DEVICE_ID` | Device identifier string | +| `device_enum` | — | 2-char uppercase hex enumerator | +| `bidirectional` | `CONF_BIDIRECTIONAL` | Motor mode flag | +| `initial_position` | `CONF_INITIAL_POSITION` | Seed position (timed motors only) | + +--- + +## Calibration + +Calibration measures the time a motor takes to travel from fully open to fully closed (and back), enabling accurate position tracking. It is accessed via **Settings → Devices & Services → Schellenberg USB → {device} → Configure**. + +The integration routes to one of two calibration flows based on the motor's `bidirectional` flag: + +### Bidirectional Calibration (`options_flow_calibration.py`) + +Used for motors that report movement events back to the stick. + +- The flow listens for `EVENT_STARTED_MOVING_UP` / `EVENT_STARTED_MOVING_DOWN` and `EVENT_STOPPED` dispatcher signals. +- Timing is measured with `time.time()`. +- Flow timeout: `CALIBRATION_TIMEOUT = 300` seconds (5 minutes) per movement phase. +- Calibration data is saved to the HA Store and a `SIGNAL_CALIBRATION_COMPLETED` signal is emitted with `final_position=0` (flow ends on a close run). + +### Timed Calibration (`options_flow_timed_calibration.py`) + +Used for non-bidirectional motors that never report movement events. + +- The flow drives the motor with `CMD_DOWN` / `CMD_UP` commands and timestamps button presses using `time.monotonic()`. +- No stop command is sent — the motor runs to its physical endstop. +- After the close + open runs, a confirm screen shows the measured times. The user can redo the measurement before saving. +- Calibration signal is emitted with `final_position=100` (flow ends with the shutter fully open). + +#### Timed Calibration Guard Bounds + +Both guard thresholds are defined in `const.py` and applied to each run (close and open) independently: + +| Constant | Value | Effect | +|----------|-------|--------| +| `CAL_MIN_TRAVEL_TIME` | `2` seconds | Rejects runs shorter than 2 s as double-press / misfire (`timed_cal_too_short` error). | +| `CAL_MAX_TRAVEL_TIME` | `120` seconds | Rejects runs longer than 120 s as "walked away" runs (`timed_cal_too_long` error). | + +A rejected run resets the elapsed timer and redisplays the same form step — no stop command is sent and no partial data is saved. + +### Calibration Persistence + +Calibration times (`open_time`, `close_time`) are stored in the Home Assistant `.storage/` directory: + +| Detail | Value | +|--------|-------| +| Storage key | `schellenberg_usb_calibration` | +| Storage file | `/.storage/schellenberg_usb_calibration` | +| Store version | `1` | +| Format | JSON — keyed by `config_entry_id` → `device_id` → `{open_time, close_time}` | + +The store is loaded once per HA start and cached in `hass.data`. A corrupt or missing file causes the integration to start with an empty cache (logged at EXCEPTION level); calibration can be re-run at any time to restore values. + +`DEFAULT_TRAVEL_TIME = 60.0` seconds is used as the position-tracking fallback when no calibration data has been stored for a device. + +--- + +## Protocol Constants + +These values are fixed in the source and are not user-configurable: + +| Constant | Value | Description | +|----------|-------|-------------| +| `BAUDRATE` | `112500` bps | Serial baud rate (set in `api.py`) | +| `VERIFY_TIMEOUT` | `5` seconds | Timeout waiting for stick version/mode response | +| `PAIRING_TIMEOUT` | `120` seconds | Timeout waiting for a pairing RF response | +| `PAIRING_DEVICE_ENUM_START` | `0x10` | First enumerator assigned during auto-pairing | +| `DEFAULT_TRAVEL_TIME` | `60.0` seconds | Position fallback when calibration is absent | + +--- + +## Integration Metadata + +Source: `manifest.json` + +| Field | Value | +|-------|-------| +| Domain | `schellenberg_usb` | +| Version | `1.3.0` | +| Integration type | `hub` | +| IoT class | `local_push` | +| Requirement | `pyserial-asyncio==0.6` | +| Config flow | Yes | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..61ae862 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,254 @@ + +# Development Guide + +This guide covers everything needed to contribute code to the Schellenberg USB Integration. +For system architecture see [ARCHITECTURE.md](ARCHITECTURE.md). For test detail see +[TESTING.md](TESTING.md) (generated separately). For environment variable reference see +[CONFIGURATION.md](CONFIGURATION.md). + +## Prerequisites + +| Tool | Version | Purpose | +|------|---------|---------| +| Python | >= 3.13.2 | Runtime and toolchain | +| uv | >= 0.9.5 | Package manager, venv management | +| Git | any | Version control | +| WSL (Windows only) | 2 | Running the test suite (Linux required) | + +On Windows, WSL is required for tests because `homeassistant/runner.py` imports the +Unix-only `fcntl` module. `uv run pytest` natively fails with +`ModuleNotFoundError: No module named 'fcntl'`. Do not try to fix this — it is a +fundamental platform constraint. + +## Getting the Repo + +```bash +git clone https://github.com/hrabbach/schellenberg_usb.git +cd schellenberg_usb +``` + +## Dual Venv Setup (Critical) + +This project uses **two separate virtual environments** that must never be mixed: + +| Venv | Path | Purpose | Where to run | +|------|------|---------|--------------| +| `.venv` | Linux/WSL venv on `/mnt/c` (DrvFs) | pytest and runtime deps | WSL only | +| `.venv-win` | Native Windows venv | ruff, mypy, pre-commit | Windows only | + +### Why two venvs? + +The `.venv` is a Linux venv created and used inside WSL. Running native Windows `uv` +against it corrupts it: Windows `uv` deletes the `bin/` directory, cannot remove the +`lib64` symlink, and leaves the env broken. Keep them strictly separated. + +### Creating the WSL venv + +Run from PowerShell (not Git Bash — see note below): + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users//Coding/schellenberg_usb/.wsl_exec.sh "uv sync --frozen" +``` + +### Creating the Windows venv + +Run from PowerShell or native Windows terminal: + +```powershell +$env:UV_PROJECT_ENVIRONMENT = ".venv-win" +uv sync --group lint +``` + +## Running Tests + +**Tests must run through `.wsl_exec.sh`.** Never invoke `uv run pytest` directly on +Windows — it will either corrupt the venv or produce results from an unrelated checkout. + +### The helper script + +`.wsl_exec.sh` does three things that make the test suite reliable: + +1. Sets `HOME=/home/holgerr` — the Windows-inherited `HOME` is mangled to `C:Users…` + and breaks uv inside WSL. +2. Sets `UV_LINK_MODE=copy` — the `.venv` lives on `/mnt/c` (DrvFs) while the uv cache + is on WSL ext4. Hardlinks cannot span the two filesystems. Without copy mode, `uv run` + re-syncs the entire env on every call, and a killed sync corrupts the venv. +3. `cd`s to the hardcoded project path — so always run tests on the main checkout, not a + git worktree. + +### Run the full suite + +From **PowerShell**: + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv run pytest -p no:cacheprovider -q" +``` + +From **Git Bash**, prefix `MSYS_NO_PATHCONV=1` to prevent MSYS path mangling: + +```bash +MSYS_NO_PATHCONV=1 wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv run pytest -p no:cacheprovider -q" +``` + +### Run a single file or test + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv run pytest tests/test_cover.py -p no:cacheprovider -q" +``` + +### Venv health check + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh ".venv/bin/python -c 'import homeassistant.helpers, pytest'" +``` + +### Repair a corrupted venv + +If `uv run` prints "Resolved/Prepared/Installed N packages" on a repeat run (it should +be instant), the venv is being rebuilt. Stop, check you went through the helper, then: + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv sync --frozen" +``` + +## Linting and Type Checking + +Lint and types run **natively on Windows** using the `.venv-win` venv. + +### ruff (lint + format) + +```powershell +$env:UV_PROJECT_ENVIRONMENT = ".venv-win" +uv run ruff check custom_components/ tests/ +uv run ruff format --check custom_components/ tests/ +``` + +Auto-fix lint violations: + +```powershell +$env:UV_PROJECT_ENVIRONMENT = ".venv-win" +uv run ruff check --fix custom_components/ tests/ +uv run ruff format custom_components/ tests/ +``` + +From Bash (e.g., CI): + +```bash +UV_PROJECT_ENVIRONMENT=.venv-win uv run ruff check custom_components/ tests/ +UV_PROJECT_ENVIRONMENT=.venv-win uv run ruff format --check custom_components/ tests/ +``` + +### mypy + +```powershell +$env:UV_PROJECT_ENVIRONMENT = ".venv-win" +uv run mypy custom_components/ tests/ +``` + +## Quality Gate + +No pre-commit hook is installed (`pre-commit install` was never run), so `git commit` +fires no hooks. Run the following manually before every commit: + +1. `ruff check` — lint (native Windows, `.venv-win`) +2. `ruff format --check` — formatting (native Windows, `.venv-win`) +3. `mypy` — type checking (native Windows, `.venv-win`) +4. `pytest` — test suite (WSL, via `.wsl_exec.sh`) + +All four must pass before pushing. + +## Build Commands + +| Command | Venv | Description | +|---------|------|-------------| +| `uv sync --frozen` | `.venv` (WSL) | Install/repair the WSL test venv | +| `uv sync --group lint` | `.venv-win` (Windows) | Install/repair the Windows lint venv | +| `uv run pytest -p no:cacheprovider -q` | `.venv` via helper | Run full test suite | +| `uv run ruff check custom_components/ tests/` | `.venv-win` | Lint | +| `uv run ruff format --check custom_components/ tests/` | `.venv-win` | Check formatting | +| `uv run ruff check --fix …` | `.venv-win` | Auto-fix lint violations | +| `uv run ruff format …` | `.venv-win` | Auto-format | +| `uv run mypy custom_components/ tests/` | `.venv-win` | Type check | + +## Code Style + +- **Formatter:** ruff-format (configured in `pyproject.toml`) +- **Linter:** ruff (rules configured in `.pre-commit-config.yaml`) +- **Max line length:** 80 characters (`[tool.ruff.lint.pycodestyle]` in `pyproject.toml`) +- **Type checker:** mypy 1.18.2+ + +### Key conventions + +- `from __future__ import annotations` at the top of every module +- `| None` syntax, not `Optional[X]` +- `dict[str, X]` not `Dict[str, X]` +- Full type hints on all parameters and return values (including `-> None`) +- `snake_case` for functions, variables, and module names +- `UPPER_SNAKE_CASE` for constants +- Leading underscore for private attributes and callbacks (`self._transport`, `_handle_message`) +- `async_` prefix for async functions (`async_setup_entry`, `async_dispatcher_connect`) +- `@callback` decorator on synchronous dispatcher callbacks +- `%s` placeholders in log messages, never f-strings (log formatting is lazy) +- Relative imports within the integration: `from .const import DOMAIN` +- Absolute imports for HA framework: `from homeassistant.core import HomeAssistant` + +### Logging + +```python +_LOGGER = logging.getLogger(__name__) + +# Correct — lazy formatting +_LOGGER.debug("Setup entry called for entry: %s", entry.entry_id) + +# Wrong — do not use f-strings in log calls +_LOGGER.debug(f"Setup entry called for entry: {entry.entry_id}") +``` + +## Module Layout + +``` +custom_components/schellenberg_usb/ +├── __init__.py # Integration setup/teardown, subentry tracking +├── api.py # Serial connection, protocol encoding/decoding, +│ # command queue, pairing, device enumeration +├── config_flow.py # Initial hub setup (serial port selection) +├── const.py # Constants, type aliases, dispatcher signal names +├── cover.py # Cover entities; position tracking; calibration +│ # persistence (HA storage) +├── options_flow.py # Hub options (change serial port) +├── options_flow_calibration.py # Manual open/close time measurement flow +├── options_flow_pairing.py # Device pairing workflow and subentry creation +├── options_flow_timed_calibration.py # Timed calibration variant +├── sensor.py # USB stick status sensors +├── switch.py # LED switch entity +├── manifest.json # Integration metadata and version +├── strings.json # UI string keys +└── translations/ # Localized UI strings + +tests/ # pytest test suite (WSL only) +``` + +For a deeper explanation of how these modules interact see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Branch Conventions + +Feature branches follow the pattern `feat/` or `fix/`. +Phase branches use `gsd/phase-NN-` (these are local workflow branches and are never +pushed directly to origin). + +Submit changes as pull requests against `main`. No convention is enforced by tooling — +follow the pattern of existing branches visible in `git branch -a`. + +## PR Process + +- Open a PR against `main` on GitHub. +- Ensure all four quality-gate checks pass locally before requesting review (ruff, ruff + format, mypy, pytest). +- The PR description should explain the motivation for the change and any non-obvious + implementation decisions. +- Reviewers check correctness, HA integration conventions, and test coverage. +- Merge with a merge commit (not squash) to preserve history. + +For releasing a merged PR as a HACS update, bump `manifest.json` version in the PR +(semver: new feature → minor, fix-only → patch, breaking → major), then tag the merge +commit `vX.Y.Z` and push the tag. The release workflow publishes automatically. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md new file mode 100644 index 0000000..2ad9a94 --- /dev/null +++ b/docs/GETTING-STARTED.md @@ -0,0 +1,122 @@ + +# Getting Started + +This guide walks you from a fresh install to a working, calibrated Schellenberg roller-shutter motor in Home Assistant. Read it top-to-bottom the first time; experienced users can jump to the step they need. + +--- + +## Prerequisites + +- **Home Assistant** 2025.1.0 or later +- **Schellenberg USB Funk-Stick** plugged into the machine running Home Assistant (USB VID `16C0` / PID `05E1`, manufacturer string `van ooijen`) +- Serial port access — the HA host user must be able to open the serial device (typically `/dev/ttyUSB0` or `/dev/ttyACM0` on Linux) +- **HACS** installed (for the recommended install path) + +--- + +## Step 1 — Install the integration + +**Via HACS (recommended)** + +1. Open HACS in your Home Assistant sidebar. +2. Go to **Integrations** and click the **+** button. +3. Search for `Schellenberg USB`, select it, and click **Download**. +4. Restart Home Assistant when prompted. + +**Manual install** + +Copy the `custom_components/schellenberg_usb/` folder from this repository into your HA `config/custom_components/` directory, then restart Home Assistant. + +--- + +## Step 2 — Add the integration (hub setup) + +1. Go to **Settings → Devices & Services → Add Integration**. +2. Search for **Schellenberg USB** and select it. +3. If the stick was already plugged in, Home Assistant may have auto-discovered it and will ask you to confirm the serial port. Otherwise, enter the port path (default `/dev/ttyUSB0`) and click **Submit**. + +The hub entry is now created and the stick connects automatically. For serial port options and USB auto-discovery details see [docs/CONFIGURATION.md](CONFIGURATION.md). + +--- + +## Step 3 — Add a motor + +Go to **Settings → Devices & Services**, find **Schellenberg USB**, and click **+ Add device**. A menu offers two paths: + +### Option A — Auto-pair (motor is nearby and reachable) + +1. Choose **Pair automatically**. +2. Put your motor into pairing mode (see [README — Device Pairing Instructions](../README.md#device-pairing-instructions) for button combinations by model). +3. The integration listens for up to 10 seconds. When the motor responds, you are prompted to give it a friendly name. +4. Bidirectional motors proceed straight to calibration (Step 4A). No extra steps needed here. + +### Option B — Manual add (motor is already paired to the stick) + +Use this when the motor was paired by hand before you installed this integration, or when the motor never sends events back (non-bidirectional / timed motors). + +1. Choose **Add manually**. +2. Enter the motor's two-character hexadecimal enum (e.g. `10`, `11`, `12` — check your stick's pairing log or increment from `10` for each motor added). +3. Choose the motor type: + - **Bidirectional** — motor sends movement events back to the stick (most ROLLODRIVE PREMIUM motors). Leave this toggled on. + - **Timed (non-bidirectional)** — motor never confirms movement; drive-to-position relies on button-press timing. Toggle this off. +4. Optionally enter a friendly name; if left blank, the name defaults to `Blind `. +5. For timed motors only: set an **initial position** (0 = fully closed, 100 = fully open) that reflects where the shutter physically is right now. This seeds position tracking until calibration completes. + +The motor appears as a cover entity immediately after this step. + +--- + +## Step 4 — Calibrate + +Calibration records how many seconds the motor takes to travel from fully closed to fully open (and back). Without it, position tracking and drive-to-percentage commands are unavailable. + +> **Note:** Calibration does NOT set motor end-stops. Physical travel limits must be configured on the motor itself using its built-in adjustment features or a Schellenberg remote before you calibrate here. + +### 4A — Bidirectional motors (event-based) + +The integration detects movement automatically — you control the motor with your physical remote during calibration. + +Full step-by-step instructions are in [README — Calibration Steps](../README.md#calibration-steps). + +### 4B — Timed (non-bidirectional) motors (button-press timing) + +The integration drives the motor itself and measures elapsed time between your button presses — no motor events are required. + +1. Open the device page for your motor and click the **Calibrate** (gear) icon. +2. **Precondition step:** Confirm that the shutter is fully open (at the top) before proceeding. +3. **Close run:** The integration sends a close command automatically. Wait until the motor reaches the bottom endstop and stops on its own, then press **Next**. (Valid travel: 2 – 120 seconds.) +4. **Open run:** The integration sends an open command automatically. Wait until the motor reaches the top endstop and stops on its own, then press **Next**. +5. **Confirm:** The measured open and close times are shown. Press **Done** to save, or check **Redo** to repeat the measurements. + +After calibration the shutter position is set to 100 % (fully open), matching where the motor ended up. + +--- + +## Step 5 — Control the motor + +Once calibrated, your motor appears in Home Assistant as a standard cover entity with: + +- **Open / Close / Stop** buttons +- **Position slider** — drag to any percentage; the integration calculates travel time automatically + +From this point the entity works like any other HA cover: use it in automations, dashboards, and voice assistants. + +--- + +## Recalibrating + +If travel times change (motor replaced, mechanical adjustment, etc.), open the motor's device page and click the **Calibrate** gear icon to run calibration again. Existing times are overwritten on confirmation. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Integration not found after install | HA not restarted, or browser cache | Restart HA; clear browser cache | +| "Cannot connect" on serial port | Wrong path or permission denied | Verify the path with `ls /dev/tty*`; add the HA user to the `dialout` group | +| Auto-pair times out | Motor not in pairing mode, or out of range | Move the stick closer; retry pairing mode on the motor | +| Timed calibration rejects "too short" | Submitted before motor reached endstop | Wait for the motor to stop completely before pressing Next | +| Position drifts over time | Calibration times no longer accurate | Recalibrate from the device page | + +For configuration details (serial port, baud rate, subentry data) see [docs/CONFIGURATION.md](CONFIGURATION.md). diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..b5b16a6 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,187 @@ + +# Testing + +This document covers how to run and extend the test suite for the Schellenberg USB integration. See [docs/DEVELOPMENT.md](DEVELOPMENT.md) for the broader local development setup. + +## Why Tests Run in WSL Only + +`homeassistant/runner.py` imports the Unix-only `fcntl` module. On native Windows this produces: + +``` +ModuleNotFoundError: No module named 'fcntl' +``` + +This is not a bug and must not be "fixed." The Home Assistant test infrastructure is Linux-only. All pytest invocations must go through WSL. + +## Test Environment Overview + +| Tool | Where it runs | Venv | +|------|---------------|------| +| `pytest` | WSL/Linux only, via `.wsl_exec.sh` | `.venv` (WSL/Linux venv on `/mnt/c`) | +| `ruff` | Native Windows | `.venv-win` | +| `mypy` | Native Windows | `.venv-win` | + +## Running the Full Test Suite + +### From PowerShell (standard) + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv run pytest -p no:cacheprovider -q" +``` + +Substitute the absolute path to `.wsl_exec.sh` if your checkout is in a different location. + +### From Git Bash (MSYS_NO_PATHCONV required) + +The Git Bash shell applies MSYS path conversion and silently mangles `/mnt/c/...` into `C:/Program Files/Git/mnt/c/...`. The suite will appear to start but tests will never run. Always prefix `MSYS_NO_PATHCONV=1`: + +```bash +MSYS_NO_PATHCONV=1 wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv run pytest -p no:cacheprovider -q" +``` + +### Why `.wsl_exec.sh` Is Mandatory + +The project `.venv` lives on `/mnt/c` (DrvFs) while uv's cache lives on WSL ext4 (`$HOME`). Hardlinks cannot span the two filesystems. Running `uv run pytest` directly causes uv to re-sync the entire environment on every call, and an interrupted re-sync corrupts the venv (symptoms: missing `homeassistant.helpers`, etc.). + +`.wsl_exec.sh` exports `UV_LINK_MODE=copy` so the install completes in one pass and stays stable. It also fixes the WSL `HOME` variable, which is mangled to a Windows path when inherited from the Windows environment. + +**Never invoke `uv` or `pytest` outside `.wsl_exec.sh` against this venv.** + +### Venv Health Check + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh ".venv/bin/python -c 'import homeassistant.helpers, pytest'" +``` + +If this fails, repair the venv: + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv sync --frozen" +``` + +## Running a Single Test File + +Pass the file path as a pytest argument inside the quoted command string: + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv run pytest tests/test_cover.py -p no:cacheprovider -q" +``` + +Run a specific test by node ID: + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv run pytest tests/test_api.py::test_api_initialization -p no:cacheprovider -v" +``` + +## Test Organization + +All tests live in `tests/`. The suite has approximately 207 tests across these files: + +| File | What it covers | +|------|----------------| +| `test_api.py` | `SchellenbergUsbApi` initialization, device registration, connect/disconnect | +| `test_api_extended.py` | Edge cases for the API: retry logic, stick-busy handling, futures, error paths | +| `test_api_messages.py` | Protocol message parsing — frame decoding, device ID extraction, status messages | +| `test_const.py` | Constants and type aliases exported from `const.py` | +| `test_init.py` | `__init__.py` setup/teardown, subentry wiring, platform forwarding | +| `test_init_extended.py` | Edge cases for integration lifecycle: reload, subentry changes, error handling | +| `test_config_flow.py` | Blind subentry manual-add flow, serial port validation, config entry creation | +| `test_options_flow.py` | Hub options flow (`ignore_unknown` toggle, serial port change) | +| `test_cover.py` | `SchellenbergCover` entity: open/close/set position, calibration, position tracking | +| `test_sensor.py` | Sensor entities: stick connection status, firmware version, device mode | +| `test_switch.py` | `SchellenbergLedSwitch` entity: on/off commands, state reporting | +| `test_timed_calibration_flow.py` | Timed calibration flow (Phase 4): happy path, guard conditions (too short/too long) | +| `test_timed_cal_handler_structure.py` | RED-phase structural tests for `TimedCalibrationFlowHandler` interface and guard constants | + +### Shared Fixtures (`conftest.py`) + +- `mock_serial_port` — returns `/dev/ttyUSB0` as a fixture string +- `mock_config_entry_data` — config entry data dict keyed by `CONF_SERIAL_PORT` +- `mock_api` — fully mocked `SchellenbergUsbApi` with `AsyncMock` for async methods +- `mock_storage` — mocked `homeassistant.helpers.storage.Store` +- `mock_serial` — patches `serial.Serial` for config flow validation tests + +### Test File Naming Convention + +Test files follow `test_.py` naming. For extended coverage of a module, a companion `test__extended.py` file is used. Structural or RED-phase tests use `test__structure.py`. + +## Test Configuration + +Configuration lives in `pyproject.toml` under `[tool.pytest.ini_options]`: + +```toml +[tool.pytest.ini_options] +testpaths = ["tests"] +norecursedirs = [".git"] +addopts = """ +-n4 +--strict-markers +--cov=custom_components""" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +``` + +Key settings: +- `-n4` — runs tests in parallel across 4 workers (pytest-xdist) +- `--strict-markers` — unregistered pytest marks cause an error +- `--cov=custom_components` — coverage measured over `custom_components/` +- `asyncio_mode = "auto"` — all `async def` test functions are treated as asyncio tests automatically + +## Coverage + +Coverage is enabled by default via `--cov=custom_components` in `addopts`. After a test run, a summary is printed to the terminal. To generate an HTML report: + +```powershell +wsl -e env -u HOME -u WSLENV bash /mnt/c/Users/holger.rabbach/Coding/schellenberg_usb/.wsl_exec.sh "uv run pytest -p no:cacheprovider --cov=custom_components --cov-report=html" +``` + +The HTML report is written to `htmlcov/index.html`. Coverage configuration: + +```toml +[tool.coverage.run] +source = ["custom_components"] + +[tool.coverage.report] +show_missing = true +``` + +No minimum coverage threshold is configured — coverage is informational only. + +## Lint and Type Checking (Native Windows) + +Ruff and mypy run natively on Windows in a separate `.venv-win` virtual environment. There is no pre-commit hook installed, so these must be run manually as part of the quality gate before committing. + +### Ruff (lint + format) + +```powershell +$env:UV_PROJECT_ENVIRONMENT = ".venv-win" +uv run ruff check custom_components/ tests/ +uv run ruff format --check custom_components/ tests/ +``` + +To auto-fix lint issues: + +```powershell +$env:UV_PROJECT_ENVIRONMENT = ".venv-win" +uv run ruff check --fix custom_components/ tests/ +``` + +Ruff is configured with a max line length of 80 characters (`[tool.ruff.lint.pycodestyle]` in `pyproject.toml`). + +### mypy (static types) + +```powershell +$env:UV_PROJECT_ENVIRONMENT = ".venv-win" +uv run mypy custom_components/ +``` + +mypy targets Python 3.13 and uses `follow_imports = "silent"` and `ignore_missing_imports = true` to avoid noise from untyped third-party dependencies. + +## Writing New Tests + +- Place new test files in `tests/` following the `test_.py` naming convention. +- All `async def` test functions are picked up automatically (`asyncio_mode = "auto"`). +- Use `hass: HomeAssistant` as a fixture parameter — it is provided by `pytest-homeassistant-custom-component`. +- For subentry flows, enter the flow via `hass.config_entries.subentries.async_init(...)`, not `async_step_` directly (the HA framework routes through `async_step_user` first). +- Place shared fixtures in `tests/conftest.py`. +- Patch serial I/O using the `mock_serial` fixture or `unittest.mock.patch("serial.Serial")`.