Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 0 additions & 50 deletions .pre-commit-config.yaml

This file was deleted.

80 changes: 80 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Contributing

Thanks for helping improve the Schellenberg USB integration. This document is the
canonical guide to the local quality gate every change must pass before it is merged.

## Development Setup

- **Python 3.13.2+**
- **[uv](https://docs.astral.sh/uv/)** for dependency management (`uv sync` installs the
`dev` group, which pulls in both `test` and `lint`).
- **Windows contributors:** the test suite must run under **WSL2 (Ubuntu)** — see
[Why WSL for tests?](#why-wsl-for-tests) below. Linting and type-checking run natively
on Windows in a separate `.venv-win` environment.

## Quality Gate

There is no pre-commit hook — the gate is run manually. All four checks must pass before a
change is merged. Tests run under Linux/WSL; lint, type-check, and spell-check run natively.

### 1. Tests (Linux / WSL)

On Linux:

```sh
uv run pytest -p no:cacheprovider -q
```

On **Windows**, run the same command inside WSL — the Home Assistant test harness needs a
Linux environment. Keep the venv on the WSL **ext4** filesystem (not `/mnt/c`, which is slow
DrvFs) so uv can hardlink from its cache and sync in seconds. The maintainer wraps this in a
small `.wsl_exec.sh` helper (which sets a clean `HOME`, points `UV_PROJECT_ENVIRONMENT` at an
ext4 venv, serializes calls with `flock`, and `cd`s into the repo) and invokes it as:

```powershell
wsl -e env -u HOME -u WSLENV bash .wsl_exec.sh "uv run --no-sync pytest -p no:cacheprovider -q"
```

`--no-sync` skips uv's implicit env sync on every run; run `uv sync --frozen` explicitly only
when dependencies change.

### 2. Lint (native Windows / `.venv-win`)

```powershell
$env:UV_PROJECT_ENVIRONMENT = ".venv-win"
uv run ruff check custom_components/schellenberg_usb/ tests/
uv run ruff format --check custom_components/schellenberg_usb/ tests/
```

### 3. Type check (native Windows / `.venv-win`)

```powershell
$env:UV_PROJECT_ENVIRONMENT = ".venv-win"
uv run mypy custom_components/schellenberg_usb/ tests/
```

### 4. Spell check (native Windows / `.venv-win`)

```powershell
$env:UV_PROJECT_ENVIRONMENT = ".venv-win"
uv run codespell custom_components/schellenberg_usb/ tests/ README.md CONTRIBUTING.md
```

codespell configuration (the ignore-words list for intentional domain terms) lives in the
`[tool.codespell]` section of `pyproject.toml`. If codespell flags a legitimate term, add
it to `ignore-words-list` there.

## Why WSL for tests?

The Home Assistant test harness imports the Unix-only `fcntl` module
(`homeassistant/runner.py`), so the suite cannot run on native Windows — `uv run pytest`
there fails with `ModuleNotFoundError: No module named 'fcntl'`. Running the tests inside
WSL (or on any Linux/macOS host) avoids this. Lint, type-check, and spell-check have no
such constraint and run natively for speed.

## Commit Conventions

- Keep commits focused — one logical change per commit.
- Use conventional-commit prefixes (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`,
`test:`, `style:`).
- Run the full quality gate before pushing.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,9 @@ Once a timed motor has been calibrated, the position slider in the Home Assistan
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.

---

## Contributing

Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup and the local quality gate (tests, lint, type-check, and spell-check) every change must pass.
5 changes: 2 additions & 3 deletions custom_components/schellenberg_usb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,7 @@ async def _on_entry_updated(
return
new_ignore = updated_entry.options.get(CONF_IGNORE_UNKNOWN, False)
if api_instance.ignore_unknown != new_ignore:
_LOGGER.debug(
"Live-applying ignore_unknown=%s to running API", new_ignore
)
_LOGGER.debug("Live-applying ignore_unknown=%s to running API", new_ignore)
api_instance.ignore_unknown = new_ignore

entry.add_update_listener(_on_entry_updated)
Expand All @@ -175,5 +173,6 @@ async def async_unload_entry(
if unload_ok:
api: SchellenbergUsbApi = entry.runtime_data
await api.disconnect()
_SETUP_CALLBACKS.pop(entry.entry_id, None)

return unload_ok
29 changes: 20 additions & 9 deletions custom_components/schellenberg_usb/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ async def connect(self) -> None:
(
self._transport,
self._protocol,
) = await serial_asyncio.create_serial_connection(
self.hass.loop,
) = await serial_asyncio.create_serial_connection( # type: ignore[assignment]
asyncio.get_running_loop(),
lambda: SchellenbergProtocol(self._handle_message, self),
self.port,
baudrate=112500,
Expand Down Expand Up @@ -151,7 +151,9 @@ async def connect(self) -> None:
)
self._is_connecting = False
# Always retry after 5 seconds
self.hass.loop.call_later(5, lambda: self.hass.create_task(self.connect()))
asyncio.get_running_loop().call_later(
5, lambda: self.hass.async_create_task(self.connect())
)

@callback
def _handle_message(self, message: str) -> None:
Expand Down Expand Up @@ -220,7 +222,10 @@ def _handle_message(self, message: str) -> None:
# 00BE = 2 bytes to ignore (address prefix)
# XXXXXX = 3 bytes device ID (the actual device ID we want)
# Rest = can be ignored
if message.startswith("sl") and len(message) >= 8:
# Guard: slice [6:12] requires len >= 12 (end index = 12). The previous
# >= 8 guard was a defect (Pattern 2 / T-05-04) — on an 8-11 char frame
# the slice silently returns a truncated/empty device_id.
if message.startswith("sl") and len(message) >= 12:
# Extract the device ID: skip "sl" (2 chars) + "00BE" (4 chars) = 6 chars
# Then take the next 6 characters (3 bytes as hex) = 6 chars
device_id = message[6:12]
Expand Down Expand Up @@ -291,8 +296,7 @@ def _handle_message(self, message: str) -> None:
# "Ignore unknown signals" hub option is on — demote the
# unknown-device line to DEBUG to keep logs quiet (SIG-01).
_LOGGER.debug(
"Ignoring signal from unknown device %s "
"(enum=%s, cmd=%s)",
"Ignoring signal from unknown device %s (enum=%s, cmd=%s)",
device_id,
device_enum,
command,
Expand Down Expand Up @@ -376,7 +380,7 @@ async def pair_device_and_wait(self) -> tuple[str, str] | None:
)

# Create a future to wait for device ID first
self._pairing_future = self.hass.loop.create_future()
self._pairing_future = asyncio.get_running_loop().create_future()

try:
# Send sp command to enter pairing/listening mode (like C# does)
Expand Down Expand Up @@ -524,7 +528,7 @@ async def verify_device(self) -> bool:
return False

_LOGGER.debug("Verifying Schellenberg USB device")
self._verify_future = self.hass.loop.create_future()
self._verify_future = asyncio.get_running_loop().create_future()

try:
# Send the verification command
Expand Down Expand Up @@ -695,7 +699,7 @@ async def get_device_id(self) -> str | None:
return None

_LOGGER.debug("Requesting device ID")
self._device_id_future = self.hass.loop.create_future()
self._device_id_future = asyncio.get_running_loop().create_future()

try:
# Send the request command
Expand Down Expand Up @@ -780,3 +784,10 @@ 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)
# Schedule a reconnect attempt so a runtime USB blip recovers
# automatically. Use hass.loop (not asyncio.get_running_loop()) because
# this transport callback can be invoked synchronously — e.g. in tests —
# where no running loop is present; hass.loop is always the correct loop.
self.api.hass.loop.call_later(
5, lambda: self.api.hass.async_create_task(self.api.connect())
)
42 changes: 26 additions & 16 deletions custom_components/schellenberg_usb/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,13 @@ async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowRes
if user_input is not None:
port = user_input[CONF_SERIAL_PORT]
try:
# Quick, blocking sanity check that the port is reachable.
serial_conn = serial.Serial(port)
# Run blocking serial open in the executor to avoid blocking the
# HA event loop (CR-02 — serial.Serial() can block for 100-500ms).
def _open_serial(p: str) -> None:
conn = serial.Serial(p)
conn.close()

serial_conn.close()
await self.hass.async_add_executor_job(_open_serial, port)

# Use the port path as the unique ID when set up manually.
await self.async_set_unique_id(port, raise_on_progress=False)
Expand All @@ -99,8 +102,10 @@ async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowRes
except serial.SerialException:
errors["base"] = "cannot_connect"
_LOGGER.error("Failed to connect to serial port %s", port)
except Exception:
except Exception: # noqa: BLE001
errors["base"] = "unknown"
# HA config-flow must surface 'unknown' to the user rather than
# crashing the flow; broad catch is intentional here (RESEARCH Pitfall 7).
_LOGGER.exception("An unexpected error occurred")

return self._form_schema(errors, default_port="/dev/ttyUSB0")
Expand Down Expand Up @@ -148,8 +153,13 @@ async def async_step_usb_confirm(
if user_input is not None:
port = user_input[CONF_SERIAL_PORT]
try:
serial_conn = serial.Serial(port)
serial_conn.close()
# Run blocking serial open in the executor to avoid blocking the
# HA event loop (CR-02 — serial.Serial() can block for 100-500ms).
def _open_serial(p: str) -> None:
conn = serial.Serial(p)
conn.close()

await self.hass.async_add_executor_job(_open_serial, port)

# unique_id was already set in async_step_usb(), re-assert and create the entry
await self.async_set_unique_id(
Expand All @@ -164,8 +174,10 @@ async def async_step_usb_confirm(
except serial.SerialException:
errors["base"] = "cannot_connect"
_LOGGER.error("Failed to connect to serial port %s", port)
except Exception:
except Exception: # noqa: BLE001
errors["base"] = "unknown"
# HA config-flow must surface 'unknown' to the user rather than
# crashing the flow; broad catch is intentional here (RESEARCH Pitfall 7).
_LOGGER.exception("An unexpected error occurred during USB confirm")

# Mark as confirm-only so the UI shows a simple confirmation experience
Expand Down Expand Up @@ -282,12 +294,8 @@ async def async_step_manual_add(

if not errors:
# Resolve mode — BooleanSelector returns a real Python bool
is_bidirectional: bool = bool(
user_input.get(CONF_BIDIRECTIONAL, True)
)
device_name = (
user_input.get("device_name") or f"Blind {device_enum}"
)
is_bidirectional: bool = bool(user_input.get(CONF_BIDIRECTIONAL, True))
device_name = user_input.get("device_name") or f"Blind {device_enum}"
self._pending_device_enum = device_enum
self._pending_device_name = device_name
self._pending_is_bidirectional = is_bidirectional
Expand All @@ -307,9 +315,7 @@ async def async_step_manual_add(
unique_id=device_enum,
)
# Timed: advance to initial-position step
_LOGGER.debug(
"Timed motor %s: advancing to position step", device_enum
)
_LOGGER.debug("Timed motor %s: advancing to position step", device_enum)
return await self.async_step_manual_position()

return self.async_show_form(
Expand Down Expand Up @@ -477,6 +483,10 @@ async def async_step_reconfigure(
device_enum = subentry.data.get("device_enum")
if not device_id:
return self.async_abort(reason="device_not_found")
if not device_enum:
# WR-13: device_enum=None would produce malformed protocol command
# f"ss{None}9{CMD_DOWN}0000" sent to the USB stick.
return self.async_abort(reason="device_not_found")

# Route by motor type (CTRL-05 zero-regression requirement):
# - bidirectional motors → legacy event-based CalibrationFlowHandler
Expand Down
2 changes: 1 addition & 1 deletion custom_components/schellenberg_usb/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@

# Timed calibration guards (D-08 / D-09)
CAL_MAX_TRAVEL_TIME = 120 # seconds — reject "walked away" runs (D-08)
CAL_MIN_TRAVEL_TIME = 2 # seconds — reject double-press/misfire (D-09)
CAL_MIN_TRAVEL_TIME = 2 # seconds — reject double-press/misfire (D-09)

# Manual-add device mode flag (stored in subentry.data)
CONF_BIDIRECTIONAL = "bidirectional" # bool; False = timed/non-bidirectional
Expand Down
Loading