diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b1efef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + pull_request: + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v5 + with: + cache-dependency-glob: "requirements_dev.txt" + - run: uv venv --python 3.14 .venv && uv pip install --python .venv/bin/python -r requirements_dev.txt + - run: .venv/bin/ruff check . && .venv/bin/ruff format --check . + - run: .venv/bin/pytest -q --cov --cov-report=term-missing + hassfest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: home-assistant/actions/hassfest@master + hacs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: hacs/action@main + with: + category: integration diff --git a/custom_components/ninlight/__init__.py b/custom_components/ninlight/__init__.py new file mode 100644 index 0000000..96636a5 --- /dev/null +++ b/custom_components/ninlight/__init__.py @@ -0,0 +1 @@ +"""The ninlight integration.""" diff --git a/custom_components/ninlight/const.py b/custom_components/ninlight/const.py new file mode 100644 index 0000000..9a181a6 --- /dev/null +++ b/custom_components/ninlight/const.py @@ -0,0 +1,34 @@ +"""Constants for the ninlight integration. Deliberately free of Home Assistant imports.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +DOMAIN: Final = "ninlight" + +# Public default token of the ninlight firmware (CMakeLists.txt of the firmware repo). +DEFAULT_TOKEN: Final = "aabbccddeeff0011223344" + +CONF_SCAN_INTERVAL: Final = "scan_interval" +CONF_LAMP_BASE_URL: Final = "lamp_base_url" + +DEFAULT_SCAN_INTERVAL: Final = 30 +MIN_SCAN_INTERVAL: Final = 10 +MAX_SCAN_INTERVAL: Final = 300 + +MAX_TRANSITION_MS: Final = 60_000 + +FIRMWARE_DIR_NAME: Final = "ninlight_firmware" +CATALOG_SCAN_INTERVAL: Final = timedelta(minutes=5) +SIGNAL_CATALOG_UPDATED: Final = f"{DOMAIN}_catalog_updated" + +FIRMWARE_URL_PREFIX: Final = "/api/ninlight/firmware/" +# len(secrets.token_urlsafe(32)) +FIRMWARE_KEY_LENGTH: Final = 43 +# The lamp rejects fw_update URLs longer than this (nl_api.c: get_string(cmd, "url", 8, 255)). +MAX_LAMP_URL_LENGTH: Final = 255 + +OTA_POLL_INTERVAL_S: Final = 5.0 +# The firmware aborts a stalled download after 10 min; a dead peer can hang ~7 min in TCP. +OTA_TIMEOUT_S: Final = 12 * 60.0 diff --git a/custom_components/ninlight/manifest.json b/custom_components/ninlight/manifest.json new file mode 100644 index 0000000..75207e4 --- /dev/null +++ b/custom_components/ninlight/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "ninlight", + "name": "ninlight", + "codeowners": ["@ninharp"], + "config_flow": true, + "dependencies": ["http"], + "documentation": "https://github.com/ninharp/ha-ninlight", + "integration_type": "device", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/ninharp/ha-ninlight/issues", + "requirements": [], + "version": "0.1.0", + "zeroconf": [{ "type": "_ninlight._tcp.local." }] +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..4d80464 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "ninlight", + "render_readme": true, + "homeassistant": "2026.9.0" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7491918 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[tool.pytest.ini_options] +testpaths = ["tests_unit", "tests"] +asyncio_mode = "auto" +addopts = "-p no:cacheprovider" + +[tool.coverage.run] +source = ["custom_components/ninlight"] +branch = true + +[tool.coverage.report] +fail_under = 95 +show_missing = true + +[tool.ruff] +target-version = "py314" +line-length = 100 +exclude = ["docs/"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "ASYNC"] +# E501: line length is the formatter's job; long literals in test tables stay on one line. +# RUF012: HA entities declare _attr_* lists as plain class attributes by convention. +ignore = ["E501", "RUF012"] + +[tool.ruff.lint.isort] +known-first-party = ["custom_components.ninlight", "tests", "tests_unit"] +force-sort-within-sections = true diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..549572b --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,4 @@ +homeassistant==2026.9.0 +pytest-homeassistant-custom-component==0.13.363 +pytest-cov +ruff>=0.9 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6651e5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +"""Fixtures for tests that run against a Home Assistant instance.""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations: None) -> Generator[None]: + """Load custom_components/ in every Home Assistant test.""" + yield diff --git a/tests_unit/__init__.py b/tests_unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests_unit/test_manifest.py b/tests_unit/test_manifest.py new file mode 100644 index 0000000..5a169a1 --- /dev/null +++ b/tests_unit/test_manifest.py @@ -0,0 +1,20 @@ +"""The manifest must agree with the constants the code relies on.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from custom_components.ninlight.const import DOMAIN + +MANIFEST = Path(__file__).parents[1] / "custom_components" / "ninlight" / "manifest.json" + + +def test_manifest_matches_constants() -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + assert manifest["domain"] == DOMAIN + assert manifest["zeroconf"] == [{"type": "_ninlight._tcp.local."}] + assert manifest["iot_class"] == "local_polling" + assert manifest["config_flow"] is True + assert manifest["requirements"] == [] + assert manifest["dependencies"] == ["http"]