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
31 changes: 31 additions & 0 deletions .github/workflows/docker-development.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Docker development

on:
push:
branches: [main]
paths:
- "docker/**"
- "tests/test_dev_compose_linux.py"
- ".github/workflows/docker-development.yml"
pull_request:
paths:
- "docker/**"
- "tests/test_dev_compose_linux.py"
- ".github/workflows/docker-development.yml"
workflow_dispatch:

permissions:
contents: read

jobs:
docker:
name: Linux Docker Engine lifecycle
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.13"
- run: pip install -r requirements-dev.txt
- run: python -m pytest tests/test_dev_compose_linux.py -m docker -v
2 changes: 1 addition & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
# sono esclusi di default in locale ma qui girano: -m sovrascrive addopts.
# Coverage (lines + branches) of custom_components/vimar_intercom: the
# threshold is fail_under in pyproject.toml, below it the job fails.
- run: python -m pytest tests -m "not live and not media and not browser" --cov --cov-report=term-missing
- run: python -m pytest tests -m "not live and not media and not browser and not docker" --cov --cov-report=term-missing

media-browser:
# A richiesta (workflow_dispatch): media vero (ffmpeg, aiohttp) e la card in
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ __pycache__/
.ruff_cache/
.coverage
.venv/
.dev-ha/
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,15 @@ package `__init__`. `tests/harness/` is a fake panel on 127.0.0.1 for the SIP en
pip install -r requirements-dev.txt
python -m pytest tests # default run, without the slow e2e tests (~1 min)
python -m pytest tests -m slow # the slow e2e tests (~2 min)
python -m pytest tests -m "not live and not media and not browser" # both, what CI runs
python -m pytest tests -m "not live and not media and not browser and not docker" # both, what CI runs
python -m pytest tests/test_srtp.py # one file
python -m pytest tests -k manifest # by name
python -m pytest tests -m media # real ffmpeg/aiohttp media (needs ffmpeg, aiohttp)
python -m pytest tests -m browser # the card in Chromium/WebKit (needs playwright, aiohttp)
.claude/skills/card-browser-tests/run.sh # the same, in a disposable Playwright container
```

`live`, `media` and `browser` are excluded by default (`addopts` in `pyproject.toml`). Missing
`live`, `media`, `browser` and `docker` are excluded by default (`addopts` in `pyproject.toml`). Missing
tools make `media`/`browser` tests skip. `live` needs the real intercom: see the safety rules.

### Coverage rule (mandatory, enforced in CI)
Expand Down
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ in its description.

## Development setup

For an onboarded, isolated Home Assistant instance using the current checkout,
run `./docker/dev.sh`. See [Docker Compose development](docs/DEVELOPMENT.md).
The unit tests below still run independently of Home Assistant.

```bash
git clone https://github.com/ha-vimar/ha-vimar-intercom
cd ha-vimar-intercom
Expand Down Expand Up @@ -60,7 +64,7 @@ add a test — those are the parts that break silently on other plants.
- `python -m pytest -m slow` runs the SIP end-to-end tests (`tests/test_e2e_sip.py`, `tests/test_e2e_blind.py`,
about two minutes): the real `sip_client`, hub, media and HTTP views against a fake panel on 127.0.0.1
(`tests/harness/`). The default run leaves them out; CI runs them.
- `python -m pytest tests -m "not live and not media and not browser" --cov --cov-report=term-missing` is the
- `python -m pytest tests -m "not live and not media and not browser and not docker" --cov --cov-report=term-missing` is the
default run plus the slow tests, with coverage, as CI runs it: lines and branches of
`custom_components/vimar_intercom`, and it fails below the `fail_under` threshold in `pyproject.toml` (95%). New code comes with tests that keep it there.
- `python -m pytest -m media` adds real media (`tests/test_e2e_media.py`): H.264 from ffmpeg,
Expand Down
3 changes: 3 additions & 0 deletions README.it.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ la rubrica: [Configurazione](https://github.com/ha-vimar/ha-vimar-intercom/blob/

## Supporto e contributi

Per avviare Home Assistant locale per lo sviluppo, con onboarding automatico,
esegui `./docker/dev.sh`. Vedi [sviluppo con Docker Compose](docs/DEVELOPMENT.it.md).

Domande e problemi: [issue](https://github.com/ha-vimar/ha-vimar-intercom/issues). Problemi di sicurezza: in privato, vedi [SECURITY.md](https://github.com/ha-vimar/ha-vimar-intercom/blob/main/SECURITY.md).
I contributi sono benvenuti, vedi [CONTRIBUTING.md](https://github.com/ha-vimar/ha-vimar-intercom/blob/main/CONTRIBUTING.md): mai indovinare comandi SIP (uno
sbagliato può aprire una porta), niente credenziali nel repo, e scrivi su che hardware hai provato.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ the phonebook: [Configuration](https://github.com/ha-vimar/ha-vimar-intercom/blo

## Support and contributing

For a local Home Assistant development instance with automatic onboarding, run
`./docker/dev.sh`. See [Docker Compose development](docs/DEVELOPMENT.md).

Questions and bugs: [issues](https://github.com/ha-vimar/ha-vimar-intercom/issues). Security problems: privately, see [SECURITY.md](https://github.com/ha-vimar/ha-vimar-intercom/blob/main/SECURITY.md).
Contributions are welcome, see [CONTRIBUTING.md](https://github.com/ha-vimar/ha-vimar-intercom/blob/main/CONTRIBUTING.md): never guess SIP commands (a wrong
one can open a door), keep credentials out of the repo, and say which hardware you tested on.
Expand Down
77 changes: 77 additions & 0 deletions docker/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Complete local owner onboarding and verify the installed integration's form."""
import asyncio
import json
import os
from pathlib import Path

import aiohttp

BASE = "http://web"
CLIENT = f"http://localhost:{int(os.environ.get("HA_DEV_PORT", "18123"))}/"


async def bootstrap() -> None:
credentials = json.loads(Path("/state/credentials.json").read_text())
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session:
async def request(method, path, **kwargs):
async with session.request(method, BASE + path, **kwargs) as response:
if response.status >= 400:
raise RuntimeError(f"Development HA {method} {path}: HTTP {response.status}")
body = await response.text()
return json.loads(body) if body else {}

async with session.get(BASE + "/api/onboarding") as response:
steps = await response.json() if response.status == 200 else []
if response.status not in (200, 404):
raise RuntimeError(f"Onboarding status: HTTP {response.status}")
if any(step["step"] == "user" and not step["done"] for step in steps):
result = await request("POST", "/api/onboarding/users", json={
**credentials, "name": "Developer", "client_id": CLIENT, "language": "en",
})
code = result["auth_code"]
else:
flow = await request("POST", "/auth/login_flow", json={
"client_id": CLIENT, "handler": ["homeassistant", None], "redirect_uri": CLIENT,
})
result = await request("POST", "/auth/login_flow/" + flow["flow_id"], json={
**credentials, "client_id": CLIENT,
})
if result["type"] != "create_entry":
raise RuntimeError("Saved development login failed; credentials were not reset.")
code = result["result"]
token = await request("POST", "/auth/token", data={
"grant_type": "authorization_code", "code": code, "client_id": CLIENT,
})
headers = {"Authorization": "Bearer " + token["access_token"]}
entries = await request("GET", "/api/config/config_entries/entry", headers=headers)
configured = any(entry["domain"] == "vimar_intercom" for entry in entries)
if not configured:
# This initial form does not probe SIP. Delete it without submitting
# credentials, so no unfinished flow remains in the user's dashboard.
flow = await request("POST", "/api/config/config_entries/flow", headers=headers,
json={"handler": "vimar_intercom", "show_advanced_options": True})
if flow.get("type") != "form" or flow.get("step_id") != "user":
raise RuntimeError("Vimar setup form did not load.")
await request("DELETE", "/api/config/config_entries/flow/" + flow["flow_id"], headers=headers)
async with session.ws_connect(BASE + "/api/websocket") as ws:
await ws.receive_json()
await ws.send_json({"type": "auth", "access_token": token["access_token"]})
if (await ws.receive_json())["type"] != "auth_ok":
raise RuntimeError("Development frontend WebSocket authentication failed.")
await ws.send_json({"id": 1, "type": "lovelace/config", "url_path": "vimar-dev"})
if not (await ws.receive_json()).get("success"):
raise RuntimeError("Development dashboard did not load.")
# Revoke this short-lived bootstrap session; browser login uses its own.
await request("POST", "/auth/token", data={
"action": "revoke", "token": token["refresh_token"],
})
print("Ready: " + CLIENT + "vimar-dev/development")
print("Username: " + credentials["username"])
print("Password: " + credentials["password"])
print("Credentials saved in .dev-ha/credentials.json (private, ignored by Git).")
print("Existing Vimar configuration retained; bootstrap made no calls." if configured else
"Vimar setup verified. No intercom registration or calls made.")


if __name__ == "__main__":
asyncio.run(bootstrap())
24 changes: 24 additions & 0 deletions docker/configuration.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
homeassistant:
name: Vimar Development
latitude: 0
longitude: 0
elevation: 0
unit_system: metric
time_zone: UTC
country: US

frontend:
config:
stream:
http:
server_port: 8123
lovelace:
dashboards:
vimar-dev:
mode: yaml
title: Vimar Development
icon: mdi:flask-outline
show_in_sidebar: true
filename: ui-lovelace.yaml
logger:
default: warning
32 changes: 32 additions & 0 deletions docker/dev.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Start or update the development instance using the current checkout.
set -euo pipefail
umask 077
repo=$(cd "$(dirname "$0")/.." && pwd)
cd "$repo"
compose=(docker compose --project-directory "$repo/docker" -f "$repo/docker/docker-compose.yaml")
if [[ $# -gt 1 || "${1:-up}" != up && "${1:-up}" != reset ]]; then
printf 'Usage: %s [up|reset]\n' "$0" >&2
exit 2
fi
if [[ -L .dev-ha ]]; then
printf 'Refusing a symlinked .dev-ha directory.\n' >&2
exit 1
fi
if [[ "${1:-up}" == reset ]]; then
"${compose[@]}" down
if [[ -d .dev-ha ]]; then
"${compose[@]}" run --rm --no-deps --entrypoint python seed /dev-tools/reset.py
rmdir .dev-ha
fi
printf 'Development environment reset.\n'
exit 0
fi
mkdir -p .dev-ha
chmod 700 .dev-ha
"${compose[@]}" up -d --force-recreate
if ! "${compose[@]}" wait bootstrap; then
"${compose[@]}" logs --no-log-prefix bootstrap >&2
exit 1
fi
"${compose[@]}" logs --no-log-prefix bootstrap
71 changes: 71 additions & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: vimar-intercom-dev

x-ha-image: &ha-image
# Official Docker Hub mirror, pinned to Home Assistant 2026.10.1.
image: homeassistant/home-assistant@sha256:130241f28d01fa80dfa3b44f7d89781c188d8642ecab9024f079f19f9f82baa0
environment:
TZ: UTC
PYTHONDONTWRITEBYTECODE: "1"

services:
seed:
<<: *ha-image
entrypoint: [python, /dev-tools/seed.py]
network_mode: none
volumes:
- ../.dev-ha:/state
- .:/dev-tools:ro
restart: "no"

homeassistant:
<<: *ha-image
depends_on:
seed:
condition: service_completed_successfully
volumes:
- ../.dev-ha/config:/config
- ../custom_components/vimar_intercom:/config/custom_components/vimar_intercom:ro
networks: [sandbox]
restart: "no"
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8123/', timeout=5)"]
interval: 5s
timeout: 10s
retries: 36
start_period: 20s

web:
image: nginx@sha256:8541484afbc9c8a5a8a99b379568ebbc957f658583ec9448fc43104229c03cf8
depends_on:
homeassistant:
condition: service_healthy
ports:
- "127.0.0.1:${HA_DEV_PORT:-18123}:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks: [sandbox, web]
restart: "no"
healthcheck:
test: [CMD, curl, --fail, --silent, http://localhost/]
interval: 5s
timeout: 10s
retries: 12

bootstrap:
<<: *ha-image
environment:
HA_DEV_PORT: ${HA_DEV_PORT:-18123}
entrypoint: [python, /dev-tools/bootstrap.py]
depends_on:
web:
condition: service_healthy
volumes:
- ../.dev-ha:/state
- .:/dev-tools:ro
networks: [sandbox]
restart: "no"

networks:
sandbox:
internal: true
web:
19 changes: 19 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

server {
listen 80;
server_name localhost;

location / {
proxy_pass http://homeassistant:8123;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For "";
proxy_read_timeout 3600s;
}
}
18 changes: 18 additions & 0 deletions docker/reset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Remove only the mounted development state from a one-shot offline container."""
import shutil
from pathlib import Path


def reset(state: Path) -> None:
if not state.is_mount():
raise ValueError("Reset requires the development state mount.")
for child in state.iterdir():
if child.is_dir() and not child.is_symlink():
shutil.rmtree(child)
else:
child.unlink()
print("Development state cleared.")


if __name__ == "__main__":
reset(Path("/state"))
40 changes: 40 additions & 0 deletions docker/seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Initialize only the private development config before Home Assistant starts."""
import json
import os
import shutil
from pathlib import Path

from homeassistant.components.onboarding import STORAGE_KEY, STORAGE_VERSION
from homeassistant.components.onboarding.const import STEP_USER, STEPS


def seed(state: Path, templates: Path) -> None:
os.umask(0o077)
state.mkdir(mode=0o700, exist_ok=True)
state.chmod(0o700)
config = state / "config"
config.mkdir(mode=0o700, exist_ok=True)
for name in ("configuration.yaml", "ui-lovelace.yaml"):
if not (config / name).exists():
shutil.copyfile(templates / name, config / name)
credentials = state / "credentials.json"
if not credentials.exists():
credentials.write_text(json.dumps({
"username": "developer", "password": "developer",
}) + "\n")
credentials.chmod(0o600)
storage = config / ".storage"
storage.mkdir(mode=0o700, exist_ok=True)
onboarding = storage / STORAGE_KEY
if not onboarding.exists():
# Skip location/analytics/discovery onboarding; the HTTP user endpoint
# still creates the actual owner and hashes the password normally.
onboarding.write_text(json.dumps({
"version": STORAGE_VERSION, "minor_version": 1, "key": STORAGE_KEY,
"data": {"done": [step for step in STEPS if step != STEP_USER]},
}) + "\n")
print("Development configuration ready; no intercom entry or credentials imported.")


if __name__ == "__main__":
seed(Path("/state"), Path("/dev-tools"))
Loading
Loading