Skip to content

Commit 2c97090

Browse files
kingpanther13claude
andcommitted
fix: resolve merge conflicts and fix create_timeout_error context merge order
Address review comment from sergeykad: reverse context merge order in create_timeout_error so explicit parameters (operation, timeout_seconds) always take precedence over caller-provided context dict. Resolve 3 merge conflicts with upstream/master: - tools_config_helpers.py: import formatting + raise_tool_error - tools_search.py: imports, search error handling, new ha_get_states tool - test_tools_voice_assistant.py: import ordering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2 parents 3f692f6 + 934ad1c commit 2c97090

93 files changed

Lines changed: 3562 additions & 1491 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/e2e-tests.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ permissions:
2020
env:
2121
PYTHON_VERSION: "3.13"
2222
UV_CACHE_DIR: /tmp/.uv-cache
23+
# renovate: datasource=docker depName=ghcr.io/home-assistant/home-assistant
24+
HA_IMAGE_GHCR: "ghcr.io/home-assistant/home-assistant:2026.1.3"
2325

2426
jobs:
2527
# Comprehensive E2E validation for main branch
@@ -33,6 +35,8 @@ jobs:
3335

3436
- name: Set up Docker Buildx
3537
uses: docker/setup-buildx-action@v3
38+
with:
39+
cache-binary: true
3640

3741
- name: Install uv
3842
uses: astral-sh/setup-uv@v7
@@ -45,6 +49,37 @@ jobs:
4549
- name: Install dependencies
4650
run: uv sync --all-extras --dev
4751

52+
- name: Cache HA Docker image
53+
id: cache-ha-image
54+
uses: actions/cache@v4
55+
with:
56+
path: /tmp/ha-image.tar
57+
key: ha-image-${{ env.HA_IMAGE_GHCR }}-${{ runner.arch }}
58+
59+
- name: Load cached HA image
60+
if: steps.cache-ha-image.outputs.cache-hit == 'true'
61+
run: docker load -i /tmp/ha-image.tar
62+
63+
- name: Pull HA image (GHCR → Docker Hub fallback)
64+
if: steps.cache-ha-image.outputs.cache-hit != 'true'
65+
run: |
66+
HA_VERSION="${HA_IMAGE_GHCR##*:}"
67+
HA_IMAGE_DOCKERHUB="homeassistant/home-assistant:${HA_VERSION}"
68+
for registry in "$HA_IMAGE_GHCR" "$HA_IMAGE_DOCKERHUB"; do
69+
echo "Trying $registry..."
70+
if docker pull "$registry"; then
71+
if [ "$registry" != "$HA_IMAGE_GHCR" ]; then
72+
docker tag "$registry" "$HA_IMAGE_GHCR"
73+
fi
74+
docker save "$HA_IMAGE_GHCR" -o /tmp/ha-image.tar
75+
echo "Pulled and cached from $registry"
76+
exit 0
77+
fi
78+
echo "Failed to pull from $registry, trying next..."
79+
sleep 15
80+
done
81+
echo "All registries failed" && exit 1
82+
4883
- name: Run full E2E test suite
4984
run: |
5085
echo "🚀 Running full E2E test suite with 3 workers..."

.github/workflows/pr.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,43 @@ on:
1010
env:
1111
PYTHON_VERSION: "3.13"
1212
UV_CACHE_DIR: /tmp/.uv-cache
13+
# renovate: datasource=docker depName=ghcr.io/home-assistant/home-assistant
14+
HA_IMAGE_GHCR: "ghcr.io/home-assistant/home-assistant:2026.1.3"
1315

1416
jobs:
17+
lint:
18+
name: Ruff Lint
19+
runs-on: ubuntu-latest
20+
container:
21+
image: ghcr.io/astral-sh/uv:0.9.30-python3.13-bookworm-slim
22+
timeout-minutes: 5
23+
24+
steps:
25+
- uses: actions/checkout@v6
26+
27+
- name: Install dependencies
28+
run: uv sync --dev
29+
30+
- name: Run ruff check
31+
run: uv run ruff check src/ tests/
32+
33+
# Fast unit tests (no Docker, no HA instance needed)
34+
unit-tests:
35+
name: Unit Tests
36+
runs-on: ubuntu-latest
37+
container:
38+
image: ghcr.io/astral-sh/uv:0.9.30-python3.13-bookworm-slim
39+
timeout-minutes: 5
40+
41+
steps:
42+
- uses: actions/checkout@v6
43+
44+
- name: Install dependencies
45+
run: uv sync --all-extras --dev
46+
47+
- name: Run unit tests
48+
run: uv run pytest tests/src/unit/ -n auto --tb=short -v
49+
1550
# Comprehensive E2E validation for all PRs
1651
e2e-validation:
1752
name: E2E Validation (${{ matrix.os }})
@@ -34,6 +69,8 @@ jobs:
3469

3570
- name: Set up Docker Buildx
3671
uses: docker/setup-buildx-action@v3
72+
with:
73+
cache-binary: true
3774

3875
- name: Install uv
3976
uses: astral-sh/setup-uv@v7
@@ -46,6 +83,37 @@ jobs:
4683
- name: Install dependencies
4784
run: uv sync --all-extras --dev
4885

86+
- name: Cache HA Docker image
87+
id: cache-ha-image
88+
uses: actions/cache@v4
89+
with:
90+
path: /tmp/ha-image.tar
91+
key: ha-image-${{ env.HA_IMAGE_GHCR }}-${{ runner.arch }}
92+
93+
- name: Load cached HA image
94+
if: steps.cache-ha-image.outputs.cache-hit == 'true'
95+
run: docker load -i /tmp/ha-image.tar
96+
97+
- name: Pull HA image (GHCR → Docker Hub fallback)
98+
if: steps.cache-ha-image.outputs.cache-hit != 'true'
99+
run: |
100+
HA_VERSION="${HA_IMAGE_GHCR##*:}"
101+
HA_IMAGE_DOCKERHUB="homeassistant/home-assistant:${HA_VERSION}"
102+
for registry in "$HA_IMAGE_GHCR" "$HA_IMAGE_DOCKERHUB"; do
103+
echo "Trying $registry..."
104+
if docker pull "$registry"; then
105+
if [ "$registry" != "$HA_IMAGE_GHCR" ]; then
106+
docker tag "$registry" "$HA_IMAGE_GHCR"
107+
fi
108+
docker save "$HA_IMAGE_GHCR" -o /tmp/ha-image.tar
109+
echo "Pulled and cached from $registry"
110+
exit 0
111+
fi
112+
echo "Failed to pull from $registry, trying next..."
113+
sleep 15
114+
done
115+
echo "All registries failed" && exit 1
116+
49117
- name: Run full E2E test suite
50118
run: |
51119
echo "🚀 Running full E2E test suite with ${{ matrix.pytest_workers }} workers..."
@@ -68,6 +136,8 @@ jobs:
68136

69137
- name: Set up Docker Buildx
70138
uses: docker/setup-buildx-action@v3
139+
with:
140+
cache-binary: true
71141

72142
- name: Install uv
73143
uses: astral-sh/setup-uv@v7

.pre-commit-config.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
repos:
2+
- repo: https://github.com/astral-sh/ruff-pre-commit
3+
rev: v0.15.0
4+
hooks:
5+
- id: ruff
6+
args: [--fix]
7+
- repo: local
8+
hooks:
9+
- id: unit-tests
10+
name: unit tests
11+
entry: uv run pytest tests/src/unit/ -n auto -m "not slow" --tb=short -q
12+
language: system
13+
pass_filenames: false
14+
files: ^(src/|tests/|pyproject\.toml)

AGENTS.md

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,20 @@ Balance improvement against regression risk. Consider:
437437
| **Tests exist, quality is low** | Improve test quality if it's straightforward (better assertions, clearer names, remove duplication) |
438438
| **Code quality is really low** | Open an issue describing the technical debt instead of fixing it inline |
439439

440+
### Test Coverage Requirements
441+
442+
**When tests ARE required:**
443+
- New MCP tools in `src/ha_mcp/tools/` without any E2E tests
444+
- Tools that previously had NO tests — add E2E tests even if not part of current PR
445+
- Core functionality changes in `client/`, `server.py`, or `errors.py` without coverage
446+
- Bug fixes without regression tests
447+
448+
**When tests may NOT be required:**
449+
- Refactoring with existing comprehensive test coverage
450+
- Documentation-only changes (`*.md` files)
451+
- Minor parameter additions to well-tested tools
452+
- Internal utilities already covered by E2E tests
453+
440454
**Examples:**
441455

442456
```python
@@ -598,21 +612,48 @@ def register_<domain>_tools(mcp, client, **kwargs):
598612
```
599613

600614
### Safety Annotations
601-
| Annotation | Use For |
602-
|------------|---------|
603-
| `readOnlyHint: True` | No side effects |
604-
| `idempotentHint: True` | Safe to retry |
605-
| `destructiveHint: True` | Deletes data |
615+
| Annotation | Default | Use For |
616+
|------------|---------|--------|
617+
| `readOnlyHint: True` | `False` | Tool does not modify its environment |
618+
| `destructiveHint: True` | `True` | Tool may perform destructive updates (only meaningful when `readOnlyHint` is false). Set to `False` for non-destructive writes (e.g., creating a record) |
619+
| `idempotentHint: True` | `False` | Repeated calls with same args have no additional effect (only meaningful when `readOnlyHint` is false) |
606620

607621
### Error Handling
608-
Use structured errors from `errors.py`:
622+
623+
**Always use the dedicated error functions** from `errors.py` and `helpers.py`. Never construct raw error dicts manually — the helpers ensure consistent structure, error codes, and suggestions across all tools.
624+
625+
**Domain-specific errors** (`errors.py`) — use these when the error type is known:
609626
```python
610-
from ..errors import create_error_response, ErrorCode
611-
return create_error_response(
612-
code=ErrorCode.ENTITY_NOT_FOUND,
613-
message="Entity not found",
614-
suggestions=["Use ha_search_entities() to find valid IDs"]
615-
)
627+
from ..errors import create_entity_not_found_error, create_validation_error, create_service_error
628+
629+
# Entity lookup failures (404 / not found)
630+
return create_entity_not_found_error(entity_id, details=str(e))
631+
632+
# Invalid parameters
633+
return create_validation_error("Invalid format", parameter="entity_ids", details=str(e))
634+
635+
# Service call failures
636+
return create_service_error(domain, service, message=f"Service call failed: {e}", details=str(e))
637+
```
638+
639+
Available helpers: `create_entity_not_found_error`, `create_connection_error`, `create_auth_error`, `create_service_error`, `create_validation_error`, `create_config_error`, `create_timeout_error`, `create_resource_not_found_error`, and the generic `create_error_response`.
640+
641+
**Catch-all exception handler** (`helpers.py`) — use in `except Exception` blocks:
642+
```python
643+
from .helpers import exception_to_structured_error
644+
645+
except Exception as e:
646+
return exception_to_structured_error(e, context={"entity_id": entity_id})
647+
```
648+
649+
**Pattern for tools**: Use `exception_to_structured_error` as the catch-all — it already classifies 404s, auth errors, timeouts, etc. based on exception type and message. Pass `context={"entity_id": ...}` so it can produce `ENTITY_NOT_FOUND` for 404 errors automatically. No manual 404 string matching needed:
650+
```python
651+
try:
652+
result = await client.get_entity_state(entity_id)
653+
return await add_timezone_metadata(client, result)
654+
except Exception as e:
655+
error_response = exception_to_structured_error(e, context={"entity_id": entity_id})
656+
return await add_timezone_metadata(client, error_response)
616657
```
617658

618659
### Return Values

CONTRIBUTING.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ Thank you for your interest in contributing!
66

77
1. **Fork and clone** the repository
88
2. **Install**: `uv sync --group dev`
9-
3. **Test**: `uv run pytest tests/src/e2e/ -v` (requires Docker)
10-
4. **Make changes** and commit
11-
5. **Open Pull Request**
9+
3. **Install hooks**: `uv run pre-commit install`
10+
4. **Test**: `uv run pytest tests/src/e2e/ -v` (requires Docker)
11+
5. **Make changes** and commit
12+
6. **Open Pull Request**
1213

1314
## 🧪 Testing
1415

@@ -20,6 +21,7 @@ See **[tests/README.md](tests/README.md)**.
2021
```bash
2122
cp .env.example .env # Edit with your HA details
2223
uv sync --group dev
24+
uv run pre-commit install # Install pre-commit hooks
2325
```
2426

2527
**Code quality:**
@@ -29,6 +31,8 @@ uv run ruff check --fix src/ tests/ # Lint
2931
uv run mypy src/ # Type check
3032
```
3133

34+
On every commit, a `pre-commit` hook runs `ruff check --fix` to auto-fix and catch lint violations. The **Ruff Lint** CI job also enforces this on pull requests.
35+
3236
## 📋 Guidelines
3337

3438
- **Code**: Follow existing patterns, add type hints, test new features

README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,14 +192,22 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
192192

193193
## 👥 Contributors
194194

195-
- **[@julienld](https://github.com/julienld)** — Project maintainer & core contributor.
196-
- **[@kingbear2](https://github.com/kingbear2)** — Windows UV setup guide.
197-
- **[@sergeykad](https://github.com/sergeykad)** — Dashboard card-level CRUD operations, better changelogs and removed the dependency to textdistance/numpy.
198-
- **[@konradwalsh](https://github.com/konradwalsh)** — Financial support via [GitHub Sponsors](https://github.com/sponsors/julienld). Thank you! ☕
199-
- **[@cj-elevate](https://github.com/cj-elevate)** — Integration & entity management tools (enable/disable/delete).
200-
- **[@kingpanther13](https://github.com/kingpanther13)** — Dev channel documentation, bulk control validation, OAuth 2.1 docs, tool consolidation, error handling improvements, and native solutions guidance.
195+
### Maintainers
196+
197+
- **[@julienld](https://github.com/julienld)** — Project creator & core maintainer.
198+
- **[@sergeykad](https://github.com/sergeykad)** — Dashboard CRUD, search pagination, `__main__` security refactor, pre-commit hooks & CI lint, addon Docker fixes, `.gitattributes` enforcement, human-readable log timestamps, and removed the textdistance/numpy dependency.
199+
- **[@kingpanther13](https://github.com/kingpanther13)** — Dev channel documentation, bulk control validation, OAuth 2.1 docs, tool consolidation, error handling improvements, native solutions guidance, default dashboard editing fix, and search response optimization.
200+
201+
### Contributors
202+
203+
- **[@airlabno](https://github.com/airlabno)** — Support for `data` field in schedule time blocks.
204+
- **[@ryphez](https://github.com/ryphez)** — Codex Desktop UI MCP quick setup guide.
201205
- **[@Danm72](https://github.com/Danm72)** — Entity registry tools (`ha_set_entity`, `ha_get_entity`) for managing entity properties.
202206
- **[@Raygooo](https://github.com/Raygooo)** — SOCKS proxy support.
207+
- **[@cj-elevate](https://github.com/cj-elevate)** — Integration & entity management tools (enable/disable/delete).
208+
- **[@maxperron](https://github.com/maxperron)** — Beta testing.
209+
- **[@kingbear2](https://github.com/kingbear2)** — Windows UV setup guide.
210+
- **[@konradwalsh](https://github.com/konradwalsh)** — Financial support via [GitHub Sponsors](https://github.com/sponsors/julienld). Thank you! ☕
203211

204212
---
205213

custom_components/ha_mcp_tools/__init__.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616

1717
import voluptuous as vol
1818
from homeassistant.config_entries import ConfigEntry
19-
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
19+
from homeassistant.core import (
20+
HomeAssistant,
21+
ServiceCall,
22+
ServiceResponse,
23+
SupportsResponse,
24+
)
2025
from homeassistant.helpers import config_validation as cv
2126

2227
from .const import ALLOWED_READ_DIRS, ALLOWED_WRITE_DIRS, DOMAIN
@@ -137,12 +142,7 @@ def _is_path_allowed_for_read(config_dir: Path, rel_path: str) -> bool:
137142
return True
138143

139144
# Check for custom_components/**/*.py pattern
140-
if fnmatch.fnmatch(normalized, "custom_components/*/*.py"):
141-
return True
142-
if fnmatch.fnmatch(normalized, "custom_components/**/*.py"):
143-
return True
144-
145-
return False
145+
return fnmatch.fnmatch(normalized, "custom_components/**/*.py")
146146

147147

148148
def _mask_secrets_content(content: str) -> str:
@@ -296,7 +296,7 @@ async def handle_read_file(call: ServiceCall) -> ServiceResponse:
296296
content = await hass.async_add_executor_job(target_file.read_text)
297297

298298
# Apply special handling for specific files
299-
normalized = os.path.normpath(rel_path)
299+
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
300300

301301
# Mask secrets.yaml
302302
if normalized == "secrets.yaml":

homeassistant-addon/start.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def main() -> int:
135135
# Import and run MCP server directly
136136
try:
137137
log_info("Importing ha_mcp module...")
138-
from ha_mcp.__main__ import mcp, _get_timestamped_uvicorn_log_config
138+
from ha_mcp.__main__ import _get_timestamped_uvicorn_log_config, mcp
139139

140140
log_info("Starting MCP server...")
141141
mcp.run(

0 commit comments

Comments
 (0)