diff --git a/CONTEXT.md b/CONTEXT.md index 4c42de9..1201279 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -70,7 +70,7 @@ Matching uses **unique_id** patterns (`{entry_id}_relay_*`, `{entry_id}_serial_* 2. **Options flow menus** — some steps use **`vol.In({...})`** instead of **`SelectSelector`** for broader HA compatibility. -3. **Translations** — `options.step.remote_commands` needs **`description`** + **`{hint}`** placeholder. **`options.step.init.data.next`** in `strings.json`. IR/serial command JSON uses **`TextSelector(multiline=True)`** (textarea), not a single-line field. Learn IR uses **`learn_ir`** / **`learn_ir_capture`** with placeholders `{timeout}`, `{command}`, `{detail}`. +3. **Translations** — `options.step.remote_commands` needs **`description`** + **`{hint}`** placeholder. **`options.step.init.data.next`** in `strings.json`. IR/serial command JSON uses **`TextSelector(multiline=True)`** (textarea), not a single-line field. Learn IR uses **`learn_ir`** / **`learn_ir_capture`** with placeholders `{timeout}`, `{command}`, `{detail}`, and **`{ir_hint}`** on `learn_ir` (always pass a non-empty placeholder so HA does not show the raw token). When creating a remote mid-learn, the form key is **`remote_name`** (not `name`) because `CONF_REMOTE_NAME` and `CONF_CMD_NAME` both serialize as `"name"`. 4. **Do not use `listen` as a config-flow field key** — Home Assistant does not apply `options.step.*.data.listen` labels; UI shows raw `listen`. Use **`monitor_incoming`** with label *Monitor incoming data (persistent connection)* (plain **`bool`**, same as `append_cr`). @@ -92,7 +92,7 @@ Matching uses **unique_id** patterns (`{entry_id}_relay_*`, `{entry_id}_serial_* 13. **Git / releases** — semver bumps via [`.github/workflows/release.yml`](.github/workflows/release.yml) (workflow_dispatch). Each release attaches **`globalcache_itach.zip`** (integration files at zip root) for HACS `zip_release` download counting; [`hacs.json`](hacs.json) sets `zip_release` + `filename`. `WORKFLOW_TRIGGER_TOKEN` enables automated manifest-bump PRs; without it, open the compare URL from the workflow summary. **Do not** add `Co-authored-by: Cursor` to commits; history was rewritten (2026-06-05) to remove it from `main` and retag `v1.0.0`. -14. **Pinhole Learn IR** — Options **Learn IR command (pinhole)** calls `get_IRL`, waits for one `sendir` line, `stop_IRL`, then stores `full_sendir` on the chosen remote via `rewrite_sendir_connector` (learner always reports `1:1`). +14. **Pinhole Learn IR** — Options menu is built by [`options_menu.build_options_init_menu`](custom_components/globalcache_itach/options_menu.py). **Learn IR command (pinhole)** is **always** listed (even with zero remotes); edit/remove remote/relay/serial entries are omitted when their lists are empty. Flow: `get_IRL` → wait for one `sendir` → `stop_IRL` → store `full_sendir` via `rewrite_sendir_connector` (learner always reports `1:1`). With no remotes, `async_step_learn_ir` collects remote name + module/port and creates the remote on successful capture. 15. **Infrared timing padding** — `infrared-protocols` NEC (and similar) frames end on a mark with no trailing space. `us_timings_to_gc_pairs` pads with a **~40 ms** off pulse (not the ~80 µs GC minimum), otherwise LG Infrared / other consumers fail to decode on the TV. ## Documentation @@ -134,19 +134,25 @@ cannot import name 'DhcpServiceInfo' from 'homeassistant.components.dhcp' Config entry shows **`setup_error` / Import error**. Deploy manifest **1.0.2+** (or cherry-pick the one-line import change in `config_flow.py`), then restart HA or reload the integration. -## Production / deployment snapshot (2026-07-31) +## Production / deployment snapshot (2026-09-21) | Environment | Integration version | Status | |-------------|---------------------|--------| | **HACS default** | Search **Global Caché iTach** / **GlobalCache** | Added via [hacs/default#8063](https://github.com/hacs/default/pull/8063) | -| **Latest release** | **v1.0.3** | https://github.com/Shaffer-Softworks/GlobalCache/releases | +| **Latest release** | **v1.1.3** | https://github.com/Shaffer-Softworks/GlobalCache/releases/tag/v1.1.3 | Install via HACS (default feed) preferred; custom-repository install is no longer needed. +### v1.1.3 release notes (summary) + +- **IP2CC config validation** — accept bare `getversion` firmware replies ([#17](https://github.com/Shaffer-Softworks/GlobalCache/issues/17)) +- **iTach RECEIVER probe** — skip on non–Global Connect so IP2IR / WF2IR stop logging spurious reload warnings ([#18](https://github.com/Shaffer-Softworks/GlobalCache/issues/18)) +- **`via_device_id`** — replace deprecated `via_device` for HA 2027.8 ([#19](https://github.com/Shaffer-Softworks/GlobalCache/issues/19)) + ## Optional follow-ups (not implemented) - YAML import from core `itach` integration if applicable --- -*Last updated: 2026-09-20 — bare getversion / IP2CC config validation (#17).* +*Last updated: 2026-09-21 — Learn IR always visible in Configure; CONTEXT production snapshot → v1.1.3.* diff --git a/README.md b/README.md index 4bae190..c0cc743 100644 --- a/README.md +++ b/README.md @@ -98,11 +98,22 @@ Open **Configure** on the integration card (gear icon on older layouts): | **IR defaults** | Carrier frequency, repeat, offset, sendir ID policy (auto-increment vs fixed). | | **Timeouts** | Connect and command timeouts. | | **Add remote** | Name, module/port (e.g. module `1`, port `2` → connector **1:2**), repeat multiplier, **JSON command list**. | -| **Learn IR command (pinhole)** | Pick a remote and command name → Submit → aim the handheld at the iTach **pinhole** within the timeout. Saves a **`full_sendir`** button (rewrites connector to the remote’s module:port). Same name replaces an existing command. | -| **Edit remote** / **Remove remote** | Change or delete a configured remote (entity IDs stay stable on edit). | +| **Learn IR command (pinhole)** | Always available in Configure. Pick an existing remote (or enter a name + IR connector to create one) and a command name → Submit → aim the handheld at the iTach **pinhole** within the timeout. Saves a **`full_sendir`** button (rewrites connector to the remote’s module:port). Same name replaces an existing command. | +| **Edit remote** / **Remove remote** | Change or delete a configured remote (entity IDs stay stable on edit). Shown only when at least one remote exists. | | **Add relay** / **Edit relay** / **Remove relay** | Relay `switch` entities via `setstate` / `getstate`. | | **Add serial port** / **Edit serial** / **Remove serial** | Serial `text` entity, optional preset **buttons**, **Last received** sensor. | +#### Learn IR (pinhole) + +iTach / GC-100 / Flex do **not** expose room IR receive (`RECEIVER`). Use **Configure → Learn IR command (pinhole)** (`get_IRL` / `stop_IRL`) instead: + +1. Open **Configure** on the hub (Learn IR is always in the Action list, even with no remotes yet). +2. If you already have remotes: pick one, enter a command name and timeout. +3. If you have none: enter a new remote name, IR module/port, command name, and timeout — the first successful capture creates that remote. +4. Submit on the capture step, then hold the handheld remote 1–2 inches from the iTach **pinhole** and press one button within the timeout. + +The learned line is stored as a **`full_sendir`** command button (connector rewritten to the remote’s module:port). Automations can also use services `ir_learner_start` / `ir_learner_stop` and the bus event `globalcache_itach_ir_learned`. + ### Devices and entities Each **gateway** is one hub device with diagnostic sensors (**TCP connected**, **Last gateway poll**, **Configured remotes**, optional **Gateway diagnostics**), plus one **`infrared` emitter** per IR connector discovered via `getdevices` (and, on **Global Connect** only, an optional **receiver** when a jack is in `RECEIVER` mode). Each configured **remote** appears as a child device with one **button** per JSON command. @@ -178,7 +189,7 @@ Protocol reference: [iTach API (PDF)](https://www.globalcache.com/files/docs/API - One **serialized** TCP client per config entry with **connect retries** and **EOF recovery** so the next command opens a new session. Multiple Home Assistant instances or other controllers talking to the same iTach can still contend on port **4998**. - **Relay** and **serial** connectors are configured in **integration options** (like remotes). Serial payloads use the Unified TCP data socket (**control port + module**, e.g. 4999 for module 1 when control is 4998). Confirm module/port wiring on your SKU (IP2CC relays are often module **3**; **GC-100-12** relays are module **3**, IR emitters modules **4** and **5** — run `get_devices` or check diagnostics). - **GC-100** allows only **one** TCP client on port **4998** at a time; avoid iHelp/other tools holding that port while Home Assistant is connected. -- **IR learner** — use **Configure → Learn IR command (pinhole)** to capture a `sendir` line and append it to a remote as a **button** (`full_sendir`). Services `ir_learner_start` / `ir_learner_stop` and bus events remain for automations; Global Caché’s **iLearn** utility is still useful for advanced editing. +- **IR learner** — use **Configure → Learn IR command (pinhole)** (always listed, even with no remotes yet) to capture a `sendir` line and append it to a remote as a **button** (`full_sendir`). With no remotes, the same flow creates one. Services `ir_learner_start` / `ir_learner_stop` and bus events remain for automations; Global Caché’s **iLearn** utility is still useful for advanced editing. - **`infrared` receivers** / `set_IR RECEIVER` / `receiveIR` are **Global Connect only** (Unified TCP API). On iTach/GC-100/Flex the integration does not probe for RECEIVER or create receiver entities; use emitters plus the **pinhole learner** (`get_IRL`). Raw `set_ir` / `receive_ir` services remain for advanced use but `RECEIVER` is invalid on non–Global Connect hardware. - Removing a remote, relay, or serial port from options **deletes** its entities from the registry on reload (they are not left as orphaned grey entities). diff --git a/custom_components/globalcache_itach/config_flow.py b/custom_components/globalcache_itach/config_flow.py index afd0fe3..9b8da53 100644 --- a/custom_components/globalcache_itach/config_flow.py +++ b/custom_components/globalcache_itach/config_flow.py @@ -35,6 +35,7 @@ async_scan_beacons, normalize_unique_id, ) +from .options_menu import build_options_init_menu from .const import ( _LEGACY_SERIAL_LISTEN, CONF_CMD_DATA, @@ -507,6 +508,18 @@ def _default_options() -> dict[str, Any]: } +def _learn_timeout_selector() -> selector.NumberSelector: + return selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, + min=5, + max=120, + step=1, + unit_of_measurement="s", + ) + ) + + class GlobalCacheItachOptionsFlow(OptionsFlow): """Options and remote/command editor.""" @@ -565,30 +578,10 @@ async def async_step_init( opts = self._opts() remotes: list[dict[str, Any]] = list(opts.get(CONF_REMOTES, [])) relays: list[dict[str, Any]] = list(opts.get(CONF_RELAYS, [])) - menu = { - "ir_defaults": "IR defaults", - "timeouts": "Timeouts", - "add_remote": "Add remote", - "edit_remote": "Edit remote", - "remove_remote": "Remove remote", - "learn_ir": "Learn IR command (pinhole)", - "add_relay": "Add relay", - "edit_relay": "Edit relay", - "remove_relay": "Remove relay", - "add_serial": "Add serial port", - "edit_serial": "Edit serial port", - "remove_serial": "Remove serial port", - } - if not remotes: - menu.pop("edit_remote", None) - menu.pop("learn_ir", None) - if not relays: - menu.pop("edit_relay", None) - menu.pop("remove_relay", None) serials: list[dict[str, Any]] = list(opts.get(CONF_SERIAL_PORTS, [])) - if not serials: - menu.pop("edit_serial", None) - menu.pop("remove_serial", None) + menu = build_options_init_menu( + remotes=remotes, relays=relays, serials=serials + ) return self.async_show_form( step_id="init", data_schema=vol.Schema({vol.Required("next"): vol.In(menu)}), @@ -865,60 +858,110 @@ async def async_step_remote_commands( async def async_step_learn_ir( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Pick remote and command name, then capture from the pinhole learner.""" + """Pick remote (or create one) and command name, then capture from pinhole.""" opts = self._opts() remotes: list[dict[str, Any]] = list(opts.get(CONF_REMOTES, [])) + entry = self._options_entry() + modules = list(entry.data.get(CONF_DEVICE_MODULES, [])) + default_module = default_ir_module(modules) if modules else 1 + if not remotes: - return self.async_abort(reason="no_remotes") + return await self._async_step_learn_ir_new_remote( + user_input, default_module=default_module, modules=modules + ) + choices = { str(r[CONF_REMOTE_ID]): str(r.get(CONF_REMOTE_NAME, r[CONF_REMOTE_ID])) for r in remotes if r.get(CONF_REMOTE_ID) } + schema = vol.Schema( + { + vol.Required(CONF_REMOTE_ID): vol.In(choices), + vol.Required(CONF_CMD_NAME, default="power"): str, + vol.Required("timeout", default=30): _learn_timeout_selector(), + } + ) + placeholders = {"ir_hint": ir_connectors_hint(modules) or " "} if user_input is not None: + cmd_name = str(user_input[CONF_CMD_NAME]).strip() self._learn_draft = { CONF_REMOTE_ID: str(user_input[CONF_REMOTE_ID]), - CONF_CMD_NAME: str(user_input[CONF_CMD_NAME]).strip(), + CONF_CMD_NAME: cmd_name, "timeout": float(user_input.get("timeout", 30)), } - if not self._learn_draft[CONF_CMD_NAME]: + if not cmd_name: return self.async_show_form( step_id="learn_ir", - data_schema=vol.Schema( - { - vol.Required(CONF_REMOTE_ID): vol.In(choices), - vol.Required(CONF_CMD_NAME): str, - vol.Required("timeout", default=30): selector.NumberSelector( - selector.NumberSelectorConfig( - mode=selector.NumberSelectorMode.BOX, - min=5, - max=120, - step=1, - unit_of_measurement="s", - ) - ), - } - ), + data_schema=schema, errors={"base": "command_name_required"}, + description_placeholders=placeholders, ) return await self.async_step_learn_ir_capture() return self.async_show_form( step_id="learn_ir", - data_schema=vol.Schema( - { - vol.Required(CONF_REMOTE_ID): vol.In(choices), - vol.Required(CONF_CMD_NAME, default="power"): str, - vol.Required("timeout", default=30): selector.NumberSelector( - selector.NumberSelectorConfig( - mode=selector.NumberSelectorMode.BOX, - min=5, - max=120, - step=1, - unit_of_measurement="s", - ) - ), - } - ), + data_schema=schema, + description_placeholders=placeholders, + ) + + async def _async_step_learn_ir_new_remote( + self, + user_input: dict[str, Any] | None, + *, + default_module: int, + modules: list[Any], + ) -> FlowResult: + """Learn IR when no remotes exist: create remote + command in one flow.""" + # CONF_REMOTE_NAME and CONF_CMD_NAME are both "name" in options storage; + # use a distinct form key for the remote label on this combined step. + schema = vol.Schema( + { + vol.Required("remote_name", default="Remote"): str, + vol.Required( + CONF_MODULE, default=default_module + ): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, + min=1, + max=5, + ) + ), + vol.Required(CONF_CONN_PORT, default=1): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, + min=1, + max=6, + ) + ), + vol.Required(CONF_CMD_NAME, default="power"): str, + vol.Required("timeout", default=30): _learn_timeout_selector(), + } + ) + placeholders = {"ir_hint": ir_connectors_hint(modules) or " "} + if user_input is not None: + remote_name = str(user_input.get("remote_name", "")).strip() or "Remote" + cmd_name = str(user_input[CONF_CMD_NAME]).strip() + self._learn_draft = { + "create_remote": True, + CONF_REMOTE_NAME: remote_name, + CONF_MODULE: int(user_input[CONF_MODULE]), + CONF_CONN_PORT: int(user_input[CONF_CONN_PORT]), + CONF_IR_COUNT: 1, + CONF_CMD_NAME: cmd_name, + "timeout": float(user_input.get("timeout", 30)), + } + if not cmd_name: + return self.async_show_form( + step_id="learn_ir", + data_schema=schema, + errors={"base": "command_name_required"}, + description_placeholders=placeholders, + ) + return await self.async_step_learn_ir_capture() + return self.async_show_form( + step_id="learn_ir", + data_schema=schema, + description_placeholders=placeholders, ) async def async_step_learn_ir_capture( @@ -960,17 +1003,10 @@ async def async_step_learn_ir_capture( if raw_line is not None and not errors: opts = self._opts() remotes = list(opts.get(CONF_REMOTES, [])) - rid = str(draft[CONF_REMOTE_ID]) name = command - updated: list[dict[str, Any]] = [] - found = False - for remote in remotes: - if str(remote.get(CONF_REMOTE_ID)) != rid: - updated.append(remote) - continue - found = True - module = int(remote[CONF_MODULE]) - port = int(remote[CONF_CONN_PORT]) + if draft.get("create_remote"): + module = int(draft[CONF_MODULE]) + port = int(draft[CONF_CONN_PORT]) try: sendir = rewrite_sendir_connector( raw_line, module, port @@ -978,31 +1014,73 @@ async def async_step_learn_ir_capture( except ValueError as err: errors["base"] = "learn_failed" draft["error_detail"] = str(err) - updated.append(remote) - continue - commands = [ - c - for c in list(remote.get(CONF_COMMANDS, [])) - if str(c.get(CONF_CMD_NAME, "")).strip().lower() - != name.lower() - ] - commands.append( - { - CONF_CMD_NAME: name, - CONF_CMD_FORMAT: "full_sendir", - CONF_CMD_DATA: sendir, - } - ) - updated.append({**remote, CONF_COMMANDS: commands}) - if not errors and not found: - errors["base"] = "learn_failed" - draft["error_detail"] = "Remote not found" - elif not errors: - self._learn_draft = None - return self.async_create_entry( - title="", - data={**opts, CONF_REMOTES: updated}, - ) + else: + remotes.append( + { + CONF_REMOTE_ID: str(uuid.uuid4()), + CONF_REMOTE_NAME: str( + draft.get(CONF_REMOTE_NAME, "Remote") + ), + CONF_MODULE: module, + CONF_CONN_PORT: port, + CONF_IR_COUNT: int(draft.get(CONF_IR_COUNT, 1)), + CONF_COMMANDS: [ + { + CONF_CMD_NAME: name, + CONF_CMD_FORMAT: "full_sendir", + CONF_CMD_DATA: sendir, + } + ], + } + ) + self._learn_draft = None + return self.async_create_entry( + title="", + data={**opts, CONF_REMOTES: remotes}, + ) + else: + rid = str(draft[CONF_REMOTE_ID]) + updated: list[dict[str, Any]] = [] + found = False + for remote in remotes: + if str(remote.get(CONF_REMOTE_ID)) != rid: + updated.append(remote) + continue + found = True + module = int(remote[CONF_MODULE]) + port = int(remote[CONF_CONN_PORT]) + try: + sendir = rewrite_sendir_connector( + raw_line, module, port + ) + except ValueError as err: + errors["base"] = "learn_failed" + draft["error_detail"] = str(err) + updated.append(remote) + continue + commands = [ + c + for c in list(remote.get(CONF_COMMANDS, [])) + if str(c.get(CONF_CMD_NAME, "")).strip().lower() + != name.lower() + ] + commands.append( + { + CONF_CMD_NAME: name, + CONF_CMD_FORMAT: "full_sendir", + CONF_CMD_DATA: sendir, + } + ) + updated.append({**remote, CONF_COMMANDS: commands}) + if not errors and not found: + errors["base"] = "learn_failed" + draft["error_detail"] = "Remote not found" + elif not errors: + self._learn_draft = None + return self.async_create_entry( + title="", + data={**opts, CONF_REMOTES: updated}, + ) detail = str(draft.get("error_detail") or "") return self.async_show_form( diff --git a/custom_components/globalcache_itach/options_menu.py b/custom_components/globalcache_itach/options_menu.py new file mode 100644 index 0000000..01e5b94 --- /dev/null +++ b/custom_components/globalcache_itach/options_menu.py @@ -0,0 +1,43 @@ +"""Configure (options) menu labels for Global Caché iTach.""" + +from __future__ import annotations + +from typing import Any + + +def build_options_init_menu( + *, + remotes: list[Any], + relays: list[Any], + serials: list[Any], +) -> dict[str, str]: + """Build the Configure menu; Learn IR stays visible even with no remotes. + + Edit/remove entries are omitted when the matching list is empty. Learn IR + must remain available so iTach users (no infrared receiver) can still + capture codes via the pinhole learner without first adding a remote. + """ + menu = { + "ir_defaults": "IR defaults", + "timeouts": "Timeouts", + "add_remote": "Add remote", + "edit_remote": "Edit remote", + "remove_remote": "Remove remote", + "learn_ir": "Learn IR command (pinhole)", + "add_relay": "Add relay", + "edit_relay": "Edit relay", + "remove_relay": "Remove relay", + "add_serial": "Add serial port", + "edit_serial": "Edit serial port", + "remove_serial": "Remove serial port", + } + if not remotes: + menu.pop("edit_remote", None) + menu.pop("remove_remote", None) + if not relays: + menu.pop("edit_relay", None) + menu.pop("remove_relay", None) + if not serials: + menu.pop("edit_serial", None) + menu.pop("remove_serial", None) + return menu diff --git a/custom_components/globalcache_itach/strings.json b/custom_components/globalcache_itach/strings.json index a7c3317..a781a48 100644 --- a/custom_components/globalcache_itach/strings.json +++ b/custom_components/globalcache_itach/strings.json @@ -82,11 +82,14 @@ }, "learn_ir": { "title": "Learn IR command", - "description": "Capture a code with the iTach pinhole learner and add it to a remote as a button.", + "description": "Capture a code with the iTach pinhole learner and add it to a remote as a button. If you have no remotes yet, enter a name and IR connector here to create one. {ir_hint}", "data": { "remote_id": "Remote", + "remote_name": "New remote name", "name": "Command name", - "timeout": "Listen timeout" + "timeout": "Listen timeout", + "module": "Module address", + "port": "Port (1–3 on IP2IR)" } }, "learn_ir_capture": { diff --git a/custom_components/globalcache_itach/translations/en.json b/custom_components/globalcache_itach/translations/en.json index 8a38b40..df531ba 100644 --- a/custom_components/globalcache_itach/translations/en.json +++ b/custom_components/globalcache_itach/translations/en.json @@ -150,11 +150,14 @@ }, "learn_ir": { "title": "Learn IR command", - "description": "Capture a code with the iTach pinhole learner and add it to a remote as a button.", + "description": "Capture a code with the iTach pinhole learner and add it to a remote as a button. If you have no remotes yet, enter a name and IR connector here to create one. {ir_hint}", "data": { "remote_id": "Remote", + "remote_name": "New remote name", "name": "Command name", - "timeout": "Listen timeout" + "timeout": "Listen timeout", + "module": "Module address", + "port": "Port (1–3 on IP2IR)" } }, "learn_ir_capture": { diff --git a/tests/test_options_menu.py b/tests/test_options_menu.py new file mode 100644 index 0000000..1261de3 --- /dev/null +++ b/tests/test_options_menu.py @@ -0,0 +1,36 @@ +"""Options Configure menu visibility (Learn IR must stay available).""" + +from custom_components.globalcache_itach.options_menu import build_options_init_menu + + +def test_learn_ir_visible_without_remotes() -> None: + menu = build_options_init_menu(remotes=[], relays=[], serials=[]) + assert "learn_ir" in menu + assert menu["learn_ir"] == "Learn IR command (pinhole)" + assert "add_remote" in menu + assert "edit_remote" not in menu + assert "remove_remote" not in menu + + +def test_learn_ir_visible_with_remotes() -> None: + menu = build_options_init_menu( + remotes=[{"remote_id": "r1"}], + relays=[], + serials=[], + ) + assert "learn_ir" in menu + assert "edit_remote" in menu + assert "remove_remote" in menu + + +def test_relay_serial_edit_gated_on_empty() -> None: + menu = build_options_init_menu( + remotes=[], + relays=[{"relay_id": "x"}], + serials=[], + ) + assert "edit_relay" in menu + assert "remove_relay" in menu + assert "edit_serial" not in menu + assert "remove_serial" not in menu + assert "learn_ir" in menu