diff --git a/custom_components/vimar_intercom/options_flow.py b/custom_components/vimar_intercom/options_flow.py index ecfdeda..6e4d948 100644 --- a/custom_components/vimar_intercom/options_flow.py +++ b/custom_components/vimar_intercom/options_flow.py @@ -334,6 +334,7 @@ def __init__(self, config_entry: config_entries.ConfigEntry) -> None: # PICG dichiarato dal citofono stesso (get_info.php?action=nickname). # Resta None quando la rubrica arriva da un file caricato a mano. self._picg_from_rest: str | None = None + self._internal_panel_suggestion = "" self._phonebook_source = "rubrica.db" async def async_step_init( @@ -342,10 +343,62 @@ async def async_step_init( """Menu: impostazioni a mano, rubrica dal citofono, o file rubrica.db.""" return self.async_show_menu( step_id="init", - menu_options=["settings", "homekit", "av_key", "fetch_rubrica", "fetch_rubrica_cloud", + menu_options=["settings", "internal_panel", "homekit", "av_key", "fetch_rubrica", "fetch_rubrica_cloud", "import_rubrica"], ) + async def async_step_internal_panel(self, user_input: dict | None = None) -> FlowResult: + """Propose the declared main indoor monitor; save only after confirmation.""" + current = {**self._entry.data, **self._entry.options} + errors: dict[str, str] = {} + if user_input is not None: + target = str(user_input.get(KEY_INTERNAL_PANEL_TARGET) or "").strip() + if validate.sip_target(target): + return self.async_create_entry( + title="", data={**self._entry.options, KEY_INTERNAL_PANEL_TARGET: target}) + errors[KEY_INTERNAL_PANEL_TARGET] = "invalid_target" + else: + hub = (self.hass.data.get(DOMAIN, {}).get(self._entry.entry_id) or {}).get("hub") + nicks = (hub.stats.get("nicknames") or []) if hub else [] + picg = str(current.get(KEY_PICG_TARGET) or PICG_TARGET).strip() + has_picg = any(str(n.get("role") or "").upper() == rest_client.ROLE_PICG + for n in nicks) + if not has_picg: + if not hub or not hub.registered: + errors["base"] = "internal_panel_unavailable" + elif validate.sip_target(picg): + result = await hub.async_find_picg([picg], sip_timeout=20, reply_wait=10) + nicks = result.get("nicknames") or [] + if not result.get("ok"): + errors["base"] = "internal_panel_unavailable" + elif not nicks and not result.get("reply_after") and not any( + p.get("outcome") == "replied" for p in result.get("probes", []) + ): + errors["base"] = "internal_panel_no_reply" + candidates = {str(n.get("ext") or "").strip() for n in nicks + if str(n.get("role") or "").upper() == rest_client.ROLE_PICG + and validate.sip_target(str(n.get("ext") or "").strip())} + self._internal_panel_suggestion = next(iter(candidates)) if len(candidates) == 1 else "" + if not self._internal_panel_suggestion and not errors: + errors["base"] = "internal_panel_not_found" + suggestion = self._internal_panel_suggestion + if suggestion == str(current.get(KEY_SGA_TARGET) or SGA_TARGET).strip(): + suggestion = "" + return self.async_show_form( + step_id="internal_panel", + data_schema=vol.Schema({vol.Required( + KEY_INTERNAL_PANEL_TARGET, + default=(user_input or {}).get(KEY_INTERNAL_PANEL_TARGET, + suggestion or current.get(KEY_INTERNAL_PANEL_TARGET) + or INTERNAL_PANEL_TARGET), + ): str}), + errors=errors, + description_placeholders={ + "detected": self._internal_panel_suggestion or "—", + "current": str(current.get(KEY_INTERNAL_PANEL_TARGET) or INTERNAL_PANEL_TARGET), + }, + ) + async def async_step_settings( self, user_input: dict | None = None ) -> FlowResult: diff --git a/custom_components/vimar_intercom/strings.json b/custom_components/vimar_intercom/strings.json index 46dee60..42dee75 100644 --- a/custom_components/vimar_intercom/strings.json +++ b/custom_components/vimar_intercom/strings.json @@ -79,6 +79,7 @@ "init": { "title": "Impostazioni Vimar Intercom", "menu_options": { + "internal_panel": "Rileva il posto interno", "settings": "Impostazioni di rete e attuatori", "homekit": "HomeKit", "av_key": "Chiave dello stream /av", @@ -87,6 +88,13 @@ "fetch_rubrica_cloud": "Scarica la rubrica dal cloud Vimar" } }, + "internal_panel": { + "title": "Rileva il posto interno", + "description": "L’impianto dichiara il PICG {detected}. La destinazione attuale di Chiama Casa è {current}. Se il PICG dichiarato è diverso dall’SGA viene proposto come posto interno; altrimenti resta la destinazione attuale, perché il PICG può essere un controllore anziché un monitor. Conferma la destinazione o inserisci l’indirizzo noto di un posto interno. Il salvataggio non avvia una chiamata.", + "data": { + "internal_panel_target": "Indirizzo SIP del posto interno" + } + }, "settings": { "title": "Impostazioni Vimar Intercom", "description": "Rete SIP e attuatori dinamici. Nel campo Attuatori incolla la lista JSON prodotta da tools/parse_rubrica.py, oppure torna al menu e usa \"Importa attuatori da rubrica.db\". {actuators_error}", @@ -207,6 +215,9 @@ "invalid_actuator_target": "Target attuatore non valido ({actuators_error}): usa solo cifre (es. 55001) oppure AUTO.", "rubrica_import_failed": "Importazione fallita. Vedi il dettaglio nella descrizione.", "rubrica_no_actuators": "Nessun attuatore trovato per il GID specificato. Vedi la descrizione.", + "internal_panel_not_found": "La risposta dei nickname non dichiara un PICG valido e univoco. Inserisci manualmente l’indirizzo noto di un posto interno.", + "internal_panel_no_reply": "Non è arrivata una risposta dei nickname dal PICG configurato. Alcuni impianti cloud non rispondono a GET_NICKS anche a un indirizzo esistente. Questo non dimostra che l’indirizzo sia errato; inserisci manualmente l’indirizzo noto di un posto interno.", + "internal_panel_unavailable": "La ricerca dei nickname non è disponibile: l’integrazione potrebbe essere disconnessa o un’altra ricerca potrebbe essere in corso. Riprova quando disponibile o inserisci manualmente l’indirizzo noto di un posto interno.", "invalid_target": "Valore non valido: usa solo cifre (es. 55001).", "invalid_url": "URL non valido: deve iniziare con http:// o https:// e avere un host.", "no_local_proxy": "Manca l'indirizzo del citofono: impostalo in «Impostazioni di rete e attuatori».", diff --git a/custom_components/vimar_intercom/translations/en.json b/custom_components/vimar_intercom/translations/en.json index 2de80d4..27ea7ba 100644 --- a/custom_components/vimar_intercom/translations/en.json +++ b/custom_components/vimar_intercom/translations/en.json @@ -79,6 +79,7 @@ "init": { "title": "Vimar Intercom settings", "menu_options": { + "internal_panel": "Find indoor monitor", "settings": "Network and actuator settings", "homekit": "HomeKit", "av_key": "/av stream key", @@ -87,6 +88,13 @@ "fetch_rubrica_cloud": "Download the phonebook from the Vimar cloud" } }, + "internal_panel": { + "title": "Find indoor monitor", + "description": "The system declares PICG {detected}. The current Call Home destination is {current}. If the declared PICG differs from the SGA, it is proposed as the indoor monitor; otherwise the current destination is kept, because the PICG may be a controller rather than a monitor. Confirm the destination or enter a known indoor monitor address. Saving does not place a call.", + "data": { + "internal_panel_target": "Indoor monitor SIP address" + } + }, "settings": { "title": "Vimar Intercom settings", "description": "SIP network and dynamic actuators. In the Actuators field paste the JSON list produced by tools/parse_rubrica.py, or go back to the menu and use \"Import actuators from rubrica.db\". {actuators_error}", @@ -207,6 +215,9 @@ "invalid_actuator_target": "Invalid actuator target ({actuators_error}): digits only (e.g. 55001) or AUTO.", "rubrica_import_failed": "Import failed. See description above for details.", "rubrica_no_actuators": "No actuators found for the specified GID. See description above.", + "internal_panel_not_found": "The nickname reply does not declare a unique valid PICG. Enter a known indoor monitor address manually.", + "internal_panel_no_reply": "No nickname reply arrived from the configured PICG. Some cloud plants do not answer GET_NICKS even at an existing address. This does not prove the address is wrong; enter a known indoor monitor address manually.", + "internal_panel_unavailable": "The nickname lookup could not run: the integration may be disconnected or another search may be in progress. Retry once it is available, or enter a known indoor monitor address manually.", "invalid_target": "Invalid value: digits only (e.g. 55001).", "invalid_url": "Invalid URL: it must start with http:// or https:// and have a host.", "no_local_proxy": "The intercom address is missing: set it under \"Network and actuator settings\".", diff --git a/custom_components/vimar_intercom/translations/it.json b/custom_components/vimar_intercom/translations/it.json index 46dee60..42dee75 100644 --- a/custom_components/vimar_intercom/translations/it.json +++ b/custom_components/vimar_intercom/translations/it.json @@ -79,6 +79,7 @@ "init": { "title": "Impostazioni Vimar Intercom", "menu_options": { + "internal_panel": "Rileva il posto interno", "settings": "Impostazioni di rete e attuatori", "homekit": "HomeKit", "av_key": "Chiave dello stream /av", @@ -87,6 +88,13 @@ "fetch_rubrica_cloud": "Scarica la rubrica dal cloud Vimar" } }, + "internal_panel": { + "title": "Rileva il posto interno", + "description": "L’impianto dichiara il PICG {detected}. La destinazione attuale di Chiama Casa è {current}. Se il PICG dichiarato è diverso dall’SGA viene proposto come posto interno; altrimenti resta la destinazione attuale, perché il PICG può essere un controllore anziché un monitor. Conferma la destinazione o inserisci l’indirizzo noto di un posto interno. Il salvataggio non avvia una chiamata.", + "data": { + "internal_panel_target": "Indirizzo SIP del posto interno" + } + }, "settings": { "title": "Impostazioni Vimar Intercom", "description": "Rete SIP e attuatori dinamici. Nel campo Attuatori incolla la lista JSON prodotta da tools/parse_rubrica.py, oppure torna al menu e usa \"Importa attuatori da rubrica.db\". {actuators_error}", @@ -207,6 +215,9 @@ "invalid_actuator_target": "Target attuatore non valido ({actuators_error}): usa solo cifre (es. 55001) oppure AUTO.", "rubrica_import_failed": "Importazione fallita. Vedi il dettaglio nella descrizione.", "rubrica_no_actuators": "Nessun attuatore trovato per il GID specificato. Vedi la descrizione.", + "internal_panel_not_found": "La risposta dei nickname non dichiara un PICG valido e univoco. Inserisci manualmente l’indirizzo noto di un posto interno.", + "internal_panel_no_reply": "Non è arrivata una risposta dei nickname dal PICG configurato. Alcuni impianti cloud non rispondono a GET_NICKS anche a un indirizzo esistente. Questo non dimostra che l’indirizzo sia errato; inserisci manualmente l’indirizzo noto di un posto interno.", + "internal_panel_unavailable": "La ricerca dei nickname non è disponibile: l’integrazione potrebbe essere disconnessa o un’altra ricerca potrebbe essere in corso. Riprova quando disponibile o inserisci manualmente l’indirizzo noto di un posto interno.", "invalid_target": "Valore non valido: usa solo cifre (es. 55001).", "invalid_url": "URL non valido: deve iniziare con http:// o https:// e avere un host.", "no_local_proxy": "Manca l'indirizzo del citofono: impostalo in «Impostazioni di rete e attuatori».", diff --git a/docs/CONFIGURATION.it.md b/docs/CONFIGURATION.it.md index 6d0743e..fb6a885 100644 --- a/docs/CONFIGURATION.it.md +++ b/docs/CONFIGURATION.it.md @@ -82,6 +82,10 @@ Impostazioni → Vimar Intercom → **Configura**: L'immagine della camera fra una chiamata e l'altra (l'ultima foto dello squillo) è visibile a ogni utente di Home Assistant che vede l'entità camera; `allowed_users` limita la cronologia degli squilli e i media dal vivo, non l'entità camera. +### Rilevare il posto interno + +Configura → **Rileva il posto interno** propone il posto interno capogruppo dichiarato nella risposta `GET_NICKS` (ruolo `PICG`). Usa i nickname già ricevuti oppure li richiede al PICG configurato, senza cercare altri indirizzi. Mostra la destinazione attuale di **Chiama Casa** e salva quella proposta solo dopo conferma. Non modifica SGA, PICG, targa video o attuatori e non avvia chiamate. Se non trova un PICG univoco, non indovina la destinazione; resta possibile inserire un indirizzo noto a mano. Verificato su un Tab 7S Up 40517, SW 2.1.0203, in cloud; la possibilità di chiamare il capogruppo va verificata sugli altri impianti. Un PICG uguale all’SGA effettivo non viene proposto: si mantiene la destinazione attuale di Chiama Casa. + ### Segreteria C'è un solo switch *Segreteria* (Configurazione, pagina del dispositivo). Acceso, usa il diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 80d5737..93c542e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -82,6 +82,10 @@ Settings → Vimar Intercom → **Configure**: The camera image between calls (the last ring photo) is visible to every Home Assistant user who can see the camera entity; `allowed_users` limits the ring history and live media, not the camera entity. +### Finding the indoor monitor + +Configure → **Find indoor monitor** proposes the main indoor monitor declared in a `GET_NICKS` reply (role `PICG`). It uses previously received nicknames or requests them from the configured PICG, without scanning other addresses. It shows the current **Call Home** destination and saves the proposal only when confirmed. SGA, PICG, camera and actuator settings are preserved, and no call is placed. Without a unique declared PICG it does not guess a destination; a known address can still be entered manually. The mapping was verified on a Tab 7S Up 40517, SW 2.1.0203, over the cloud; whether the main monitor accepts calls needs checking on other plants. A PICG equal to the effective SGA is not proposed: the current Call Home destination is kept. + ### Voicemail There is one *Voicemail* switch (Configuration, device page). Turned on, it uses Home diff --git a/tests/test_config_flow_steps.py b/tests/test_config_flow_steps.py index 24a4560..9218701 100644 --- a/tests/test_config_flow_steps.py +++ b/tests/test_config_flow_steps.py @@ -767,6 +767,137 @@ def test_the_av_key_page_is_translated(of): assert "<" not in json.dumps(page, ensure_ascii=False), name +# The internal monitor is proposed from declared roles, never from numbering. +@pytest.mark.parametrize("configured", ["", "52044"]) +def test_internal_panel_detection_proposes_the_declared_picg_without_saving(of, configured): + hub = types.SimpleNamespace(stats={"nicknames": [ + {"role": "PICG", "ext": "52011", "name": "Main monitor"}, + {"role": "PIM", "ext": "52901", "name": "Mobile client"}, + ]}) + flow = _options_flow(of, options={"internal_panel_target": configured}, + hass_data={"vimar_intercom": {"e1": {"hub": hub}}}) + result = asyncio.run(flow.async_step_internal_panel()) + assert result["type"] == "form" + assert result["description_placeholders"]["detected"] == "52011" + assert flow._entry.options["internal_panel_target"] == configured + saved = asyncio.run(flow.async_step_internal_panel({"internal_panel_target": "52011"})) + assert saved["data"]["internal_panel_target"] == "52011" + + +def test_internal_panel_confirmation_keeps_other_settings(of): + flow = _options_flow(of, options={"picg_target": "52011", "sga_target": "53011", + "camera_target": "54011", "homekit_accessory": True}) + saved = asyncio.run(flow.async_step_internal_panel({"internal_panel_target": "52011"})) + assert saved["data"] == {**flow._entry.options, "internal_panel_target": "52011"} + + +def test_internal_panel_detection_queries_only_the_configured_picg(of): + calls = [] + + async def find(targets, **kwargs): + calls.append(targets) + return {"ok": True, "nicknames": [{"role": "PICG", "ext": "52011", "name": "Main monitor"}]} + + hub = types.SimpleNamespace(stats={}, registered=True, async_find_picg=find) + flow = _options_flow(of, options={"picg_target": "52011"}, + hass_data={"vimar_intercom": {"e1": {"hub": hub}}}) + result = asyncio.run(flow.async_step_internal_panel()) + assert calls == [["52011"]] + assert result["description_placeholders"]["detected"] == "52011" + + +@pytest.mark.parametrize("nicks", [[], [{"role": "PIM", "ext": "52901"}], + [{"role": "PICG", "ext": "invalid"}], + [{"role": "PICG", "ext": "52011"}, + {"role": "PICG", "ext": "52012"}]]) +def test_internal_panel_detection_does_not_guess_missing_or_ambiguous_roles(of, nicks): + hub = types.SimpleNamespace(stats={"nicknames": nicks}, registered=False) + flow = _options_flow(of, hass_data={"vimar_intercom": {"e1": {"hub": hub}}}) + result = asyncio.run(flow.async_step_internal_panel()) + expected = "internal_panel_not_found" if any(n["role"] == "PICG" for n in nicks) else "internal_panel_unavailable" + assert result["errors"] == {"base": expected} + + +def test_internal_panel_detection_without_a_loaded_hub_reports_no_candidate(of): + result = asyncio.run(_options_flow(of).async_step_internal_panel()) + assert result["errors"] == {"base": "internal_panel_unavailable"} + + +def test_internal_panel_confirmation_refuses_invalid_targets(of): + flow = _options_flow(of) + result = asyncio.run(flow.async_step_internal_panel({"internal_panel_target": "not-an-extension"})) + assert result["errors"]["internal_panel_target"] == "invalid_target" + + +@pytest.mark.parametrize("sga", ["", "53011"]) +@pytest.mark.parametrize("configured", ["", "52044"]) +def test_picg_equal_to_effective_sga_keeps_the_current_monitor(of, monkeypatch, sga, configured): + declared = sga or of.SGA_TARGET + hub = types.SimpleNamespace(stats={"nicknames": [{"role": "PICG", "ext": declared}]}) + flow = _options_flow(of, options={"sga_target": sga, "internal_panel_target": configured}, + hass_data={"vimar_intercom": {"e1": {"hub": hub}}}) + defaults = {} + + def required(key, default=None, **_kw): + defaults[key] = default + return key + + monkeypatch.setattr(of.vol, "Required", required) + result = asyncio.run(flow.async_step_internal_panel()) + assert result["errors"] == {} + assert result["description_placeholders"]["detected"] == declared + assert defaults["internal_panel_target"] == (configured or of.INTERNAL_PANEL_TARGET) + assert flow._entry.options["internal_panel_target"] == configured + + +@pytest.mark.parametrize("reply, expected", [ + ({"ok": True, "nicknames": [], "probes": [{"outcome": "no_response"}]}, "internal_panel_no_reply"), + ({"ok": True, "nicknames": [], "probes": [{"outcome": "exists"}]}, "internal_panel_no_reply"), + ({"ok": False, "error": "search already running"}, "internal_panel_unavailable"), + ({"ok": False, "error": "not registered"}, "internal_panel_unavailable"), + ({"ok": True, "nicknames": [{"role": "PIM", "ext": "52901"}]}, "internal_panel_not_found"), + ({"ok": True, "nicknames": [], "reply_after": "52011"}, "internal_panel_not_found"), + ({"ok": True, "nicknames": [], "probes": [{"outcome": "replied"}]}, "internal_panel_not_found"), +]) +def test_internal_monitor_lookup_distinguishes_no_reply_and_unavailability(of, reply, expected): + async def find(targets, **kwargs): + assert targets == ["52011"] + return reply + + hub = types.SimpleNamespace(stats={}, registered=True, async_find_picg=find) + flow = _options_flow(of, options={"picg_target": "52011"}, + hass_data={"vimar_intercom": {"e1": {"hub": hub}}}) + result = asyncio.run(flow.async_step_internal_panel()) + assert result["errors"] == {"base": expected} + saved = asyncio.run(flow.async_step_internal_panel({"internal_panel_target": "52044"})) + assert saved["data"]["internal_panel_target"] == "52044" + + +def test_a_cache_without_picg_triggers_a_fresh_targeted_lookup(of): + calls = [] + + async def find(targets, **kwargs): + calls.append(targets) + return {"ok": True, "nicknames": [{"role": "PICG", "ext": "52011"}]} + + hub = types.SimpleNamespace(stats={"nicknames": [{"role": "PIM", "ext": "52901"}]}, + registered=True, async_find_picg=find) + flow = _options_flow(of, options={"picg_target": "52011"}, + hass_data={"vimar_intercom": {"e1": {"hub": hub}}}) + result = asyncio.run(flow.async_step_internal_panel()) + assert calls == [["52011"]] + assert result["errors"] == {} + assert result["description_placeholders"]["detected"] == "52011" + + +def test_invalid_configured_picg_is_not_queried(of): + hub = types.SimpleNamespace(stats={}, registered=True) + flow = _options_flow(of, options={"picg_target": "invalid"}, + hass_data={"vimar_intercom": {"e1": {"hub": hub}}}) + result = asyncio.run(flow.async_step_internal_panel()) + assert result["errors"] == {"base": "internal_panel_not_found"} + + def test_import_summary_keeps_sga_and_picg_changes_separate(of): flow = _confirm_flow(of, {"actuators": ACTUATORS, "sga": "53011"}, {"sga_target": "53010", "picg_target": "52011"})