diff --git a/.github/ISSUE_TEMPLATE/device-support.yml b/.github/ISSUE_TEMPLATE/device-support.yml new file mode 100644 index 0000000..a50d6eb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/device-support.yml @@ -0,0 +1,31 @@ +name: Device support / capability gap +description: Report an appliance type that wasn't recognized, or one that's missing some capabilities +labels: ["device-support"] +body: + - type: markdown + attributes: + value: | + Thanks for helping expand device coverage. The most useful thing you can attach here is a diagnostics + download: Settings > Devices & Services > this device > the menu (top right) > Download diagnostics. + It's already redacted of account/network identifiers before it's generated. + - type: input + id: model + attributes: + label: Appliance model + description: The model number or product name, if known (e.g. from a label on the appliance or its manual) + validations: + required: false + - type: textarea + id: description + attributes: + label: What's missing or wrong + description: What entities are missing, or what behavior didn't match what you expected + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics file + description: Drag and drop the downloaded diagnostics JSON file here + validations: + required: false diff --git a/README.md b/README.md index 097e20d..41850cd 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,17 @@ docker-compose.yml / ha_config/ Local HA dev environment --- +## Reporting a capability gap + +If your appliance's type isn't recognized, or it exposes resources this integration doesn't model yet, a Repairs +issue appears under Settings > System > Repairs pointing you at Settings > Devices & Services > this device > +the menu > Download diagnostics. That download is already redacted of account/network identifiers (Bixby login +email, access tokens, device IDs, MAC addresses, serial numbers) before it's generated, so it's safe to attach +directly to a new issue using the linked device-support template. This is the fastest way to help add or expand +support for hardware the maintainers don't have. + +--- + ## Adding a new appliance type 1. Capture the appliance's `/device/0` response to see what resources/fields it exposes. An authenticated `DtlsCoapSession` from `smartthings_local.protocol.dtls_session` GET is enough; `local-tools/probe_device.py` wraps this. diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 1ee81d9..4b6c3ac 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -125,6 +125,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: import cbor2 from smartthings_local.protocol.dtls_session import DtlsCoapSession from .registry.batch import parse_device0_batch + from .registry.by_type import for_device _LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST) try: @@ -170,11 +171,21 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: ) if not serial: serial = f"{host}:{port}" + one_ui_version = ( + resources + .get('/otninformation/vs/0', {}) + .get('swVersionInfo', {}) + .get('oneUiVersion', '') + ) return { "port": port, "serial": serial, "leaf_cert_pem": fullchain_pem, "leaf_key_pem": leaf_key_pem, + "one_ui_version": one_ui_version, + "device_type_recognized": bool( + one_ui_version and for_device(one_ui_version) is not None + ), } except CannotConnect: raise @@ -197,6 +208,20 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._host: str = "" self._ca_cert_pem: str = "" self._ca_key_pem: str = "" + self._pending_info: dict | None = None + + def _create_entry(self, info: dict) -> FlowResult: + return self.async_create_entry( + title=f"Samsung Appliance ({self._host})", + data={ + CONF_HOST: self._host, + CONF_PORT: info["port"], + CONF_CA_CERT_PEM: self._ca_cert_pem, + CONF_CA_KEY_PEM: self._ca_key_pem, + CONF_LEAF_CERT_PEM: info["leaf_cert_pem"], + CONF_LEAF_KEY_PEM: info["leaf_key_pem"], + }, + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -232,17 +257,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: await self.async_set_unique_id(f"localthings_{info['serial']}") self._abort_if_unique_id_configured() - return self.async_create_entry( - title=f"Samsung Appliance ({self._host})", - data={ - CONF_HOST: self._host, - CONF_PORT: info["port"], - CONF_CA_CERT_PEM: self._ca_cert_pem, - CONF_CA_KEY_PEM: self._ca_key_pem, - CONF_LEAF_CERT_PEM: info["leaf_cert_pem"], - CONF_LEAF_KEY_PEM: info["leaf_key_pem"], - }, - ) + if info["device_type_recognized"]: + return self._create_entry(info) + self._pending_info = info + return await self.async_step_confirm_unknown_type() if has_creds: schema = vol.Schema({vol.Required(CONF_HOST): _TEXT}) @@ -261,3 +279,18 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={"reuse_note": reuse_note}, ) + + async def async_step_confirm_unknown_type( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Shown only when the probe already knows the device type is unrecognized.""" + if user_input is not None: + return self._create_entry(self._pending_info) + + return self.async_show_form( + step_id="confirm_unknown_type", + data_schema=vol.Schema({}), + description_placeholders={ + "one_ui_version": self._pending_info["one_ui_version"] or "(none reported)", + }, + ) diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py new file mode 100644 index 0000000..7ed5870 --- /dev/null +++ b/custom_components/localthings/diagnostics.py @@ -0,0 +1,36 @@ +"""Diagnostics support for Local Things. + +Downloadable from Settings > Devices & Services > this integration's +device > the menu > Download diagnostics. This is what the Repairs issue +(raised in coordinator.py when capability coverage is incomplete) points +users at: a redacted snapshot of the device's raw /device/0 state, plus +enough version/coverage metadata to reproduce and diagnose the gap. +""" +from __future__ import annotations + +from importlib.metadata import version as pkg_version +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.loader import async_get_integration + +from .const import DOMAIN +from .coordinator import LocalThingsCoordinator +from .registry.redact import redact_resources + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id] + integration = await async_get_integration(hass, DOMAIN) + + return { + "device_type": coordinator.device_type_name or "unknown", + "one_ui_version": coordinator.one_ui_version, + "unbound_hrefs": sorted(coordinator._unbound_hrefs), + "resources": redact_resources(coordinator.last_resources), + "integration_version": integration.version, + "smartthings_local_version": pkg_version("smartthings-local"), + } diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 3e45d28..0eb9952 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -63,6 +63,10 @@ "ca_cert_pem": "The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.", "ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer." } + }, + "confirm_unknown_type": { + "title": "Appliance type not recognized", + "description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway." } }, "error": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index de9fdb1..0eb9952 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -63,6 +63,10 @@ "ca_cert_pem": "The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.", "ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer." } + }, + "confirm_unknown_type": { + "title": "Appliance type not recognized", + "description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway." } }, "error": { @@ -73,5 +77,11 @@ "abort": { "already_configured": "This device is already configured." } + }, + "issues": { + "device_gap": { + "title": "Incomplete capability coverage for {device_name}", + "description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template." + } } } diff --git a/tests/localthings/conftest.py b/tests/localthings/conftest.py index cc4aca8..295800e 100644 --- a/tests/localthings/conftest.py +++ b/tests/localthings/conftest.py @@ -69,7 +69,7 @@ def fridge_resources(): @pytest.fixture def mock_probe(): - """Patch _probe_and_validate to succeed without a real DTLS connection.""" + """Patch _probe_and_validate to succeed (recognized type) without a real DTLS connection.""" with patch( 'custom_components.localthings.config_flow._probe_and_validate', return_value={ @@ -77,6 +77,25 @@ def mock_probe(): 'serial': MOCK_SERIAL, 'leaf_cert_pem': MOCK_LEAF_CERT_PEM, 'leaf_key_pem': MOCK_LEAF_KEY_PEM, + 'one_ui_version': '7.0 Refrigerator', + 'device_type_recognized': True, + }, + ) as m: + yield m + + +@pytest.fixture +def mock_probe_unknown_type(): + """Patch _probe_and_validate to succeed, but with an unrecognized device type.""" + with patch( + 'custom_components.localthings.config_flow._probe_and_validate', + return_value={ + 'port': MOCK_PORT, + 'serial': MOCK_SERIAL, + 'leaf_cert_pem': MOCK_LEAF_CERT_PEM, + 'leaf_key_pem': MOCK_LEAF_KEY_PEM, + 'one_ui_version': '9.0 Space Heater', + 'device_type_recognized': False, }, ) as m: yield m diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 1c63836..6e696ac 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -75,6 +75,42 @@ async def test_cannot_connect(hass: HomeAssistant) -> None: assert result['errors']['base'] == 'cannot_connect' +async def test_recognized_type_skips_confirmation_step( + hass: HomeAssistant, mock_probe +) -> None: + """A recognized device type creates the entry with no extra step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'user'} + ) + result = await hass.config_entries.flow.async_configure( + result['flow_id'], + {CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM}, + ) + assert result['type'] == FlowResultType.CREATE_ENTRY + + +async def test_unknown_type_shows_confirmation_step( + hass: HomeAssistant, mock_probe_unknown_type +) -> None: + """An unrecognized device type shows a confirmation step before creating the entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'user'} + ) + result = await hass.config_entries.flow.async_configure( + result['flow_id'], + {CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM}, + ) + assert result['type'] == FlowResultType.FORM + assert result['step_id'] == 'confirm_unknown_type' + assert result['description_placeholders']['one_ui_version'] == '9.0 Space Heater' + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}, + ) + assert result['type'] == FlowResultType.CREATE_ENTRY + assert result['data'][CONF_HOST] == MOCK_HOST + + async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None: """Second add of same serial: flow aborts. diff --git a/tests/localthings/test_diagnostics.py b/tests/localthings/test_diagnostics.py new file mode 100644 index 0000000..e345a57 --- /dev/null +++ b/tests/localthings/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics platform.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from custom_components.localthings.const import DOMAIN +from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics +from custom_components.localthings.registry.redact import REDACTED + + +async def test_diagnostics_shape_and_redaction( + hass: HomeAssistant, mock_entry, mock_coordinator_session +) -> None: + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await async_get_config_entry_diagnostics(hass, mock_entry) + + assert diagnostics["device_type"] == 'refrigerator' + assert diagnostics["one_ui_version"] == '7.0 Refrigerator' + assert '/bespoke/vs/0' in diagnostics["unbound_hrefs"] + assert diagnostics["integration_version"] == '0.1.0' + assert diagnostics["smartthings_local_version"] + + resources = diagnostics["resources"] + assert resources['/information/vs/0']['x.com.samsung.da.serialNum'] == REDACTED + assert resources['/wirelessinfo/vs/0']['macaddressWiFi'] == REDACTED + # Ordinary state survives. + assert resources['/status/lock/vs/0']['x.com.samsung.da.ado.devicecontrol'] == 'On'