From a80fbf06e0662a7d50f3d6f497784fbde4419463 Mon Sep 17 00:00:00 2001 From: Drew McCalmont Date: Tue, 23 Jun 2026 14:40:20 -0400 Subject: [PATCH 1/5] Add interactive CLI Console for the companion radio The existing command card runs execute_command_ui but discards the response, so output is only visible in Developer Tools or the logs (issues #240, #111). This adds a CLI Console: an interactive command surface that records each command and its response into a sensor transcript visible in the UI. It records only command/response pairs, never the device's continuous diagnostic/noise-floor log stream. - const: SERVICE_CLI_COMMAND / SERVICE_CLI_COMMAND_UI, CONF_CLI_CONSOLE_ENABLED, CLI_CONSOLE_MAX_LINES, EVENT_CLI_RESPONSE - coordinator: bounded cli_console_history deque + record_cli_console() - sensor: MeshCoreCLIConsoleSensor (created when CONF_CLI_CONSOLE_ENABLED); state = last command, attributes carry structured history + a pre-rendered markdown transcript for a markdown card - services: cli_command wraps execute_command, records the result, and fires meshcore_cli_response; cli_command_ui drives it from text.meshcore_command - config_flow: "Enable CLI Console" toggle in Global Settings - services.yaml + en.json translations + docs (dashboard, services, events) - tests: tests/test_cli_command.py Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/meshcore/config_flow.py | 4 + custom_components/meshcore/const.py | 13 ++ custom_components/meshcore/coordinator.py | 35 +++ custom_components/meshcore/sensor.py | 110 +++++++++ custom_components/meshcore/services.py | 132 ++++++++++- custom_components/meshcore/services.yaml | 45 ++++ .../meshcore/translations/en.json | 28 +++ docs/docs/dashboard/overview.md | 49 ++++ docs/docs/events.md | 30 +++ docs/docs/services.md | 46 ++++ tests/test_cli_command.py | 214 ++++++++++++++++++ 11 files changed, 695 insertions(+), 11 deletions(-) create mode 100644 tests/test_cli_command.py diff --git a/custom_components/meshcore/config_flow.py b/custom_components/meshcore/config_flow.py index 6f77580..1f69a94 100644 --- a/custom_components/meshcore/config_flow.py +++ b/custom_components/meshcore/config_flow.py @@ -61,6 +61,7 @@ DEFAULT_SELF_TELEMETRY_INTERVAL, CONF_SELF_DIAGNOSTICS_ENABLED, CONF_SELF_DIAGNOSTICS_INTERVAL, + CONF_CLI_CONSOLE_ENABLED, DEFAULT_SELF_DIAGNOSTICS_INTERVAL, CONF_MAP_UPLOAD_ENABLED, CONF_AUTO_CLEANUP_STALE_CONTACTS, @@ -930,6 +931,7 @@ async def async_step_global_settings(self, user_input=None): new_data[CONF_SELF_TELEMETRY_INTERVAL] = user_input[CONF_SELF_TELEMETRY_INTERVAL] new_data[CONF_SELF_DIAGNOSTICS_ENABLED] = user_input[CONF_SELF_DIAGNOSTICS_ENABLED] new_data[CONF_SELF_DIAGNOSTICS_INTERVAL] = user_input[CONF_SELF_DIAGNOSTICS_INTERVAL] + new_data[CONF_CLI_CONSOLE_ENABLED] = user_input[CONF_CLI_CONSOLE_ENABLED] new_data[CONF_MAP_UPLOAD_ENABLED] = user_input[CONF_MAP_UPLOAD_ENABLED] new_data[CONF_AUTO_CLEANUP_STALE_CONTACTS] = user_input[CONF_AUTO_CLEANUP_STALE_CONTACTS] new_data[CONF_STALE_CONTACT_DAYS] = user_input[CONF_STALE_CONTACT_DAYS] @@ -955,6 +957,7 @@ async def async_step_global_settings(self, user_input=None): current_telemetry_interval = self.config_entry.data.get(CONF_SELF_TELEMETRY_INTERVAL, DEFAULT_SELF_TELEMETRY_INTERVAL) current_diagnostics_enabled = self.config_entry.data.get(CONF_SELF_DIAGNOSTICS_ENABLED, False) current_diagnostics_interval = self.config_entry.data.get(CONF_SELF_DIAGNOSTICS_INTERVAL, DEFAULT_SELF_DIAGNOSTICS_INTERVAL) + current_cli_console_enabled = self.config_entry.data.get(CONF_CLI_CONSOLE_ENABLED, False) current_map_upload_enabled = self.config_entry.data.get(CONF_MAP_UPLOAD_ENABLED, False) current_auto_cleanup = self.config_entry.data.get(CONF_AUTO_CLEANUP_STALE_CONTACTS, False) current_stale_days = self.config_entry.data.get(CONF_STALE_CONTACT_DAYS, DEFAULT_STALE_CONTACT_DAYS) @@ -973,6 +976,7 @@ async def async_step_global_settings(self, user_input=None): vol.Optional(CONF_SELF_TELEMETRY_INTERVAL, default=current_telemetry_interval): vol.All(cv.positive_int, vol.Range(min=60, max=3600)), vol.Optional(CONF_SELF_DIAGNOSTICS_ENABLED, default=current_diagnostics_enabled): cv.boolean, vol.Optional(CONF_SELF_DIAGNOSTICS_INTERVAL, default=current_diagnostics_interval): vol.All(cv.positive_int, vol.Range(min=60, max=3600)), + vol.Optional(CONF_CLI_CONSOLE_ENABLED, default=current_cli_console_enabled): cv.boolean, vol.Optional(CONF_MAP_UPLOAD_ENABLED, default=current_map_upload_enabled): cv.boolean, vol.Optional(CONF_AUTO_CLEANUP_STALE_CONTACTS, default=current_auto_cleanup): cv.boolean, vol.Optional(CONF_STALE_CONTACT_DAYS, default=current_stale_days): vol.All(cv.positive_int, vol.Range(min=1, max=365)), diff --git a/custom_components/meshcore/const.py b/custom_components/meshcore/const.py index 433ad7e..a192917 100644 --- a/custom_components/meshcore/const.py +++ b/custom_components/meshcore/const.py @@ -31,6 +31,8 @@ SERVICE_SEND_CHANNEL_MESSAGE: Final = "send_channel_message" SERVICE_EXECUTE_COMMAND: Final = "execute_command" SERVICE_EXECUTE_COMMAND_UI: Final = "execute_command_ui" +SERVICE_CLI_COMMAND: Final = "cli_command" +SERVICE_CLI_COMMAND_UI: Final = "cli_command_ui" SERVICE_MESSAGE_SCRIPT: Final = "send_ui_message" SERVICE_ADD_SELECTED_CONTACT: Final = "add_selected_contact" SERVICE_REMOVE_SELECTED_CONTACT: Final = "remove_selected_contact" @@ -127,6 +129,17 @@ def get_contact_discovery_mode(config_entry) -> str: CONF_SELF_DIAGNOSTICS_INTERVAL: Final = "self_diagnostics_interval" DEFAULT_SELF_DIAGNOSTICS_INTERVAL: Final = 300 # 5 minutes in seconds +# CLI console settings. An interactive command surface for the local companion +# radio: commands run through the same execute_command path, but the command +# and its response are recorded into a sensor transcript so output is visible +# in the UI (unlike execute_command_ui, which discards the response). It does +# NOT stream LOG_DATA / RX_LOG packet noise — only command/response pairs. +CONF_CLI_CONSOLE_ENABLED: Final = "cli_console_enabled" +# Number of command/response pairs kept in the rolling console transcript. +CLI_CONSOLE_MAX_LINES: Final = 50 +# Event fired after a CLI console command completes (for logbook/automations). +EVENT_CLI_RESPONSE: Final = f"{DOMAIN}_cli_response" + # STATS_CORE `errors` is a bitmask of radio dispatcher fault events, not a # count. Each bit latches on first occurrence and clears only on a radio # reboot. Bit values mirror the firmware (_err_flags in MeshCore Dispatcher.h); diff --git a/custom_components/meshcore/coordinator.py b/custom_components/meshcore/coordinator.py index 5107096..8a322e2 100644 --- a/custom_components/meshcore/coordinator.py +++ b/custom_components/meshcore/coordinator.py @@ -5,6 +5,7 @@ import logging import random import time +from collections import deque from datetime import timedelta from typing import Any, Dict @@ -40,6 +41,7 @@ CONF_SELF_TELEMETRY_ENABLED, CONF_SELF_TELEMETRY_INTERVAL, DEFAULT_SELF_TELEMETRY_INTERVAL, + CLI_CONSOLE_MAX_LINES, CONF_SELF_DIAGNOSTICS_ENABLED, CONF_SELF_DIAGNOSTICS_INTERVAL, DEFAULT_SELF_DIAGNOSTICS_INTERVAL, @@ -127,6 +129,16 @@ def __init__( # Get name and pubkey from config_entry.data (not options) self.name = config_entry.data.get(CONF_NAME) self.pubkey = config_entry.data.get(CONF_PUBKEY) + + # Rolling transcript for the CLI console sensor: each entry is a dict of + # {timestamp, command, response, is_error}. Bounded so the sensor's + # attribute payload stays small regardless of how much the console is + # used. The sensor (when CONF_CLI_CONSOLE_ENABLED) registers itself here + # so record_cli_console() can push fresh state immediately. + self.cli_console_history: deque[dict[str, Any]] = deque( + maxlen=CLI_CONSOLE_MAX_LINES + ) + self.cli_console_sensor: Any = None # Set up device info that entities can reference self._firmware_version = None @@ -256,6 +268,29 @@ def __init__( # Set of pubkey prefixes that have been updated and need sensor refresh self._dirty_contacts = set() + def record_cli_console( + self, command: str, response: Any, is_error: bool = False + ) -> None: + """Append a command/response pair to the CLI console transcript. + + Pushes fresh state to the console sensor immediately when one is + registered (CONF_CLI_CONSOLE_ENABLED). No-ops gracefully when the + console is disabled, so the cli_command service can call this + unconditionally. + """ + self.cli_console_history.append({ + "timestamp": int(time.time()), + "command": command, + "response": response, + "is_error": bool(is_error), + }) + sensor = self.cli_console_sensor + if sensor is not None: + try: + sensor.async_write_ha_state() + except Exception as ex: # pragma: no cover - defensive + _LOGGER.debug("Failed to update CLI console sensor: %s", ex) + def mark_contact_dirty(self, pubkey_prefix: str): """Mark a contact as needing update (for performance optimization). diff --git a/custom_components/meshcore/sensor.py b/custom_components/meshcore/sensor.py index cd8b975..af984c9 100644 --- a/custom_components/meshcore/sensor.py +++ b/custom_components/meshcore/sensor.py @@ -31,6 +31,8 @@ CONF_REPEATER_NEIGHBORS_ENABLED, CONF_TRACKED_CLIENTS, CONF_SELF_DIAGNOSTICS_ENABLED, + CONF_CLI_CONSOLE_ENABLED, + CLI_CONSOLE_MAX_LINES, CONF_LIMIT_DISCOVERED_CONTACTS, CONF_MAX_DISCOVERED_CONTACTS, DEFAULT_MAX_DISCOVERED_CONTACTS, @@ -516,6 +518,12 @@ async def async_setup_entry( # Add companion prefix sensor (first byte of public key, used in routing paths) entities.append(MeshCoreCompanionPrefixSensor(coordinator)) + # Add the CLI console transcript sensor only when opted in (default off). + # The cli_command service records command/response pairs into this entity + # so the output is visible in the UI without a custom card. + if entry.data.get(CONF_CLI_CONSOLE_ENABLED, False): + entities.append(MeshCoreCLIConsoleSensor(coordinator)) + # Store the async_add_entities function for later use coordinator.sensor_add_entities = async_add_entities @@ -1334,6 +1342,108 @@ def extra_state_attributes(self) -> dict[str, Any]: } +def _format_cli_response(response: Any) -> str: + """Render a command response as a compact, human-readable string. + + Responses are JSON-safe dicts (from execute_command normalization), + primitives, or None. Dicts are rendered as `key: value` lines so a + markdown card shows them cleanly without the caller needing to parse. + """ + if response is None: + return "(no response)" + if isinstance(response, dict): + if not response: + return "(empty response)" + return "\n".join(f"{k}: {v}" for k, v in response.items()) + return str(response) + + +class MeshCoreCLIConsoleSensor(CoordinatorEntity, SensorEntity): + """Interactive CLI console transcript for the local companion radio. + + Records command/response pairs produced by the ``meshcore.cli_command`` + service so output is visible in the UI — unlike ``execute_command_ui``, + which runs a command but discards its response. Created only when + CONF_CLI_CONSOLE_ENABLED is set (default off). + + State is the most recent command (so the entity badge shows activity at a + glance). The full rolling transcript lives in attributes: ``history`` is a + structured list of {timestamp, command, response, is_error}, and + ``transcript`` is a pre-rendered markdown string for a markdown card. The + transcript intentionally contains only command/response pairs — it never + streams LOG_DATA / RX_LOG packet noise. + """ + + _attr_has_entity_name = True + _attr_should_poll = False + _attr_icon = "mdi:console" + _attr_name = "CLI Console" + + def __init__(self, coordinator: MeshCoreDataUpdateCoordinator) -> None: + """Initialize the CLI console sensor.""" + super().__init__(coordinator) + self.coordinator = coordinator + public_key_short = coordinator.pubkey[:6] if coordinator.pubkey else "" + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_cli_console" + self.entity_id = format_entity_id( + ENTITY_DOMAIN_SENSOR, public_key_short, "cli_console" + ) + + async def async_added_to_hass(self) -> None: + """Register with the coordinator so the service can push updates.""" + await super().async_added_to_hass() + self.coordinator.cli_console_sensor = self + + async def async_will_remove_from_hass(self) -> None: + """Detach from the coordinator on teardown (e.g. options reload).""" + if self.coordinator.cli_console_sensor is self: + self.coordinator.cli_console_sensor = None + await super().async_will_remove_from_hass() + + @property + def device_info(self) -> DeviceInfo: + """Return device info (attach to the main companion device).""" + return DeviceInfo(**self.coordinator.device_info) + + @property + def native_value(self) -> str: + """Return the most recent command (truncated to the state length cap).""" + history = self.coordinator.cli_console_history + if not history: + return "ready" + # HA state values are capped at 255 chars; keep headroom. + return str(history[-1].get("command", ""))[:250] or "ready" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the rolling transcript and the most recent command/response.""" + history = list(self.coordinator.cli_console_history) + last = history[-1] if history else None + + transcript_lines = [] + for entry in history: + cmd = entry.get("command", "") + rendered = _format_cli_response(entry.get("response")) + prefix = "ERROR" if entry.get("is_error") else "" + transcript_lines.append(f"> {cmd}") + if prefix: + transcript_lines.append(f"[{prefix}] {rendered}") + else: + transcript_lines.append(rendered) + transcript = "\n".join(transcript_lines) + + return { + "history": history, + "transcript": transcript, + "last_command": last.get("command") if last else None, + "last_response": last.get("response") if last else None, + "last_is_error": last.get("is_error") if last else None, + "command_count": len(history), + "max_lines": CLI_CONSOLE_MAX_LINES, + } + + class MeshCoreReliabilitySensor(CoordinatorEntity, SensorEntity): """Sensor for tracking request successes/failures for nodes.""" diff --git a/custom_components/meshcore/services.py b/custom_components/meshcore/services.py index dc66b63..c6e8e29 100644 --- a/custom_components/meshcore/services.py +++ b/custom_components/meshcore/services.py @@ -44,6 +44,9 @@ SERVICE_SEND_CHANNEL_MESSAGE, SERVICE_EXECUTE_COMMAND, SERVICE_EXECUTE_COMMAND_UI, + SERVICE_CLI_COMMAND, + SERVICE_CLI_COMMAND_UI, + EVENT_CLI_RESPONSE, SERVICE_MESSAGE_SCRIPT, SERVICE_ADD_SELECTED_CONTACT, SERVICE_REMOVE_SELECTED_CONTACT, @@ -921,14 +924,105 @@ async def async_execute_command_ui_service(call: ServiceCall) -> None: # Clear the command input after execution try: await hass.services.async_call( - "text", - "set_value", + "text", + "set_value", {"entity_id": "text.meshcore_command", "value": ""}, blocking=False ) except Exception as ex: _LOGGER.warning(f"Could not clear command input: {ex}") + def _resolve_console_coordinator(entry_id: "str | None") -> Any: + """Pick the coordinator a CLI console command should record against. + + Mirrors execute_command's target selection: the entry_id coordinator + when specified, otherwise the first connected one. Returns None when no + suitable coordinator is found. + """ + first_connected = None + for config_entry_id, coordinator in hass.data[DOMAIN].items(): + if not hasattr(coordinator, "api"): + continue + if entry_id and entry_id != config_entry_id: + continue + if entry_id: + return coordinator + api = coordinator.api + if first_connected is None and api and api.connected: + first_connected = coordinator + return first_connected + + async def async_cli_command_service(call: ServiceCall): + """Run a CLI command and record its output to the console transcript. + + Thin wrapper over execute_command: it reuses the exact command parsing + and execution path, then records the command/response pair to the + console sensor (when CONF_CLI_CONSOLE_ENABLED) and fires the + EVENT_CLI_RESPONSE event so the result is visible in the UI and + available to automations. Returns the same response as execute_command. + """ + command_str = call.data[ATTR_COMMAND] + entry_id = call.data.get(ATTR_ENTRY_ID) + + response = await async_execute_command_service(call) + + # execute_command returns None on total failure (no connected device / + # unknown command) and an {"error": ...} dict for explicit no-response. + is_error = response is None or ( + isinstance(response, dict) and "error" in response + ) + + coordinator = _resolve_console_coordinator(entry_id) + if coordinator is not None: + coordinator.record_cli_console(command_str, response, is_error) + + hass.bus.async_fire(EVENT_CLI_RESPONSE, { + "command": command_str, + "response": response, + "is_error": is_error, + "entry_id": entry_id, + "timestamp": int(time.time()), + }) + + return response + + async def async_cli_command_ui_service(call: ServiceCall): + """Run the command from text.meshcore_command via the CLI console. + + Like execute_command_ui, but routes through cli_command so the response + is captured in the console transcript instead of being discarded. The + command input is cleared after execution. + """ + entry_id = call.data.get(ATTR_ENTRY_ID) + + command_entity = hass.states.get("text.meshcore_command") + if not command_entity: + _LOGGER.error("Command input helper not found: text.meshcore_command") + return + command = command_entity.state + if not command: + _LOGGER.warning("No command to execute - command input is empty") + return + + command_call = create_service_call( + DOMAIN, + SERVICE_CLI_COMMAND, + {"command": command, "entry_id": entry_id}, + ) + response = await async_cli_command_service(command_call) + + try: + await hass.services.async_call( + "text", + "set_value", + {"entity_id": "text.meshcore_command", "value": ""}, + blocking=False, + ) + except Exception as ex: + _LOGGER.warning(f"Could not clear command input: {ex}") + + return response + # Register services hass.services.async_register( DOMAIN, @@ -960,13 +1054,26 @@ async def async_execute_command_ui_service(call: ServiceCall) -> None: schema=UI_MESSAGE_SCHEMA, ) - # hass.services.async_register( - # DOMAIN, - # SERVICE_CLI_COMMAND, - # async_cli_command_service, - # schema=CLI_COMMAND_SCHEMA, - # ) - + # Register the CLI console services. cli_command mirrors execute_command + # but records the command/response pair into the CLI console transcript + # sensor so the output is visible in the UI; cli_command_ui drives it from + # the text.meshcore_command input helper. + hass.services.async_register( + DOMAIN, + SERVICE_CLI_COMMAND, + async_cli_command_service, + schema=EXECUTE_COMMAND_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CLI_COMMAND_UI, + async_cli_command_ui_service, + schema=UI_MESSAGE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + # Register the combined UI message service hass.services.async_register( DOMAIN, @@ -1798,8 +1905,11 @@ async def async_unload_services(hass: HomeAssistant) -> None: if hass.services.has_service(DOMAIN, SERVICE_SEND_CHANNEL_MESSAGE): hass.services.async_remove(DOMAIN, SERVICE_SEND_CHANNEL_MESSAGE) - # if hass.services.has_service(DOMAIN, SERVICE_CLI_COMMAND): - # hass.services.async_remove(DOMAIN, SERVICE_CLI_COMMAND) + if hass.services.has_service(DOMAIN, SERVICE_CLI_COMMAND): + hass.services.async_remove(DOMAIN, SERVICE_CLI_COMMAND) + + if hass.services.has_service(DOMAIN, SERVICE_CLI_COMMAND_UI): + hass.services.async_remove(DOMAIN, SERVICE_CLI_COMMAND_UI) if hass.services.has_service(DOMAIN, SERVICE_MESSAGE_SCRIPT): hass.services.async_remove(DOMAIN, SERVICE_MESSAGE_SCRIPT) diff --git a/custom_components/meshcore/services.yaml b/custom_components/meshcore/services.yaml index 011ba68..9135ac8 100644 --- a/custom_components/meshcore/services.yaml +++ b/custom_components/meshcore/services.yaml @@ -130,6 +130,51 @@ execute_command_ui: selector: text: +cli_command: + name: CLI Command + description: >- + Run a CLI command against the local companion radio and record the command and + its response to the CLI Console sensor so the output is visible in the UI. + Same syntax and capabilities as Execute MeshCore Command, plus a visible transcript. + Enable the CLI Console in Global Settings to see the output entity. + CAUTION: Some commands will make PERMANENT CHANGES to your node's configuration. Use with care. + fields: + command: + name: Command + description: >- + The command with parameters to run. Format is "[command_name] [param1] [param2] ..." + Examples: + - get_bat + - get_time + - send_advert + - set_tx_power 20 + - get_stats_radio + required: true + example: "get_bat" + selector: + text: + entry_id: + name: Config Entry ID + description: The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device. + required: false + example: "abc123def456" + selector: + text: + +cli_command_ui: + name: CLI Command from UI + description: >- + Run the command in the text.meshcore_command helper entity through the CLI Console + (so the response is recorded to the console transcript) and clear the input field afterwards. + fields: + entry_id: + name: Config Entry ID + description: The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device. + required: false + example: "abc123def456" + selector: + text: + add_selected_contact: name: Add Selected Contact description: Add the contact selected in the discovered contact select entity to your node's contact list. diff --git a/custom_components/meshcore/translations/en.json b/custom_components/meshcore/translations/en.json index 06ad7d3..da50eb3 100644 --- a/custom_components/meshcore/translations/en.json +++ b/custom_components/meshcore/translations/en.json @@ -164,6 +164,7 @@ "self_telemetry_interval": "Self Telemetry Interval (seconds)", "self_diagnostics_enabled": "Enable Self Diagnostics", "self_diagnostics_interval": "Self Diagnostics Interval (seconds)", + "cli_console_enabled": "Enable CLI Console", "map_upload_enabled": "Enable Map Auto Uploader (map.meshcore.io)", "auto_cleanup_stale_contacts": "Auto-Cleanup Stale Discovered Contacts (runs daily)", "stale_contact_days": "Stale Contact Threshold (days)", @@ -175,6 +176,7 @@ "data_description": { "self_diagnostics_enabled": "Creates roughly 15 local diagnostic sensors for the companion node (uptime, TX queue length, noise floor, last RSSI/SNR, TX/RX airtime, and packet counters). Off by default. Adds no mesh traffic — these are local queries to the attached radio, not mesh requests. The interval controls how often the sensors refresh (60–3600 seconds).", "contact_discovery_mode": "Entity per contact: every discovered contact gets its own entity (best for small meshes). Data only: discovered contacts are tracked as data only with no per-contact entity -- inspect them via the dropdown or the Get Discovered Contact service. Disabled: contact discovery is turned off entirely and any existing discovered contacts are cleared.", + "cli_console_enabled": "Adds a CLI Console sensor that records the commands you run via the meshcore.cli_command service and their responses, so the output is visible in the UI (the existing command card runs commands but discards the output). It shows only command/response pairs — it does not stream the device's continuous diagnostic/noise-floor log. Off by default. Pair it with the text.meshcore_command input helper and a markdown card; see the docs for an example dashboard.", "map_upload_enabled": "When enabled, adverts from repeaters and room servers you receive are uploaded to map.meshcore.io. Those nodes appear on the official MeshCore map for the community. Requires private key export enabled on the firmware (`ENABLE_PRIVATE_KEY_EXPORT=1`); without it, uploads cannot be signed.", "auto_cleanup_stale_contacts": "When enabled, discovered contacts whose last update is older than the configured threshold are automatically removed once per day. Contacts saved to the node are always preserved.", "auto_cleanup_stale_neighbors": "Automatically remove neighbor entries and their sensor entities when they haven't been heard from in the configured number of days.", @@ -293,6 +295,7 @@ "self_telemetry_interval": "Self Telemetry Interval (seconds)", "self_diagnostics_enabled": "Enable Self Diagnostics", "self_diagnostics_interval": "Self Diagnostics Interval (seconds)", + "cli_console_enabled": "Enable CLI Console", "map_upload_enabled": "Enable Map Auto Uploader (map.meshcore.io)", "auto_cleanup_stale_contacts": "Auto-Cleanup Stale Discovered Contacts (runs daily)", "stale_contact_days": "Stale Contact Threshold (days)", @@ -304,6 +307,7 @@ "data_description": { "self_diagnostics_enabled": "Creates roughly 15 local diagnostic sensors for the companion node (uptime, TX queue length, noise floor, last RSSI/SNR, TX/RX airtime, and packet counters). Off by default. Adds no mesh traffic — these are local queries to the attached radio, not mesh requests. The interval controls how often the sensors refresh (60–3600 seconds).", "contact_discovery_mode": "Entity per contact: every discovered contact gets its own entity (best for small meshes). Data only: discovered contacts are tracked as data only with no per-contact entity -- inspect them via the dropdown or the Get Discovered Contact service. Disabled: contact discovery is turned off entirely and any existing discovered contacts are cleared.", + "cli_console_enabled": "Adds a CLI Console sensor that records the commands you run via the meshcore.cli_command service and their responses, so the output is visible in the UI (the existing command card runs commands but discards the output). It shows only command/response pairs — it does not stream the device's continuous diagnostic/noise-floor log. Off by default. Pair it with the text.meshcore_command input helper and a markdown card; see the docs for an example dashboard.", "map_upload_enabled": "When enabled, adverts from repeaters and room servers you receive are uploaded to map.meshcore.io. Those nodes appear on the official MeshCore map for the community. Requires private key export enabled on the firmware (`ENABLE_PRIVATE_KEY_EXPORT=1`); without it, uploads cannot be signed.", "auto_cleanup_stale_contacts": "When enabled, discovered contacts whose last update is older than the configured threshold are automatically removed once per day. Contacts saved to the node are always preserved.", "auto_cleanup_stale_neighbors": "Automatically remove neighbor entries and their sensor entities when they haven't been heard from in the configured number of days.", @@ -419,6 +423,30 @@ } } }, + "cli_command": { + "name": "CLI Command", + "description": "Run a CLI command against the local companion radio and record the command and its response to the CLI Console sensor so the output is visible in the UI. Same command syntax and capabilities as Execute MeshCore Command, plus a visible transcript. Enable the CLI Console in Global Settings to see the output entity. CAUTION: some commands make PERMANENT changes to your node's configuration.", + "fields": { + "command": { + "name": "Command", + "description": "The command with parameters to run. Format is \"[command_name] [param1] [param2] ...\" Examples: - get_bat - get_time - send_advert - set_tx_power 20 - get_stats_radio" + }, + "entry_id": { + "name": "Config Entry ID", + "description": "The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device." + } + } + }, + "cli_command_ui": { + "name": "CLI Command from UI", + "description": "Run the command in the text.meshcore_command helper entity through the CLI Console (so the response is recorded to the console transcript) and clear the input field afterwards.", + "fields": { + "entry_id": { + "name": "Config Entry ID", + "description": "The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device." + } + } + }, "add_selected_contact": { "name": "Add Selected Contact", "description": "Add the contact selected in the discovered contact select entity to your node's contact list.", diff --git a/docs/docs/dashboard/overview.md b/docs/docs/dashboard/overview.md index 52e72ab..8155267 100644 --- a/docs/docs/dashboard/overview.md +++ b/docs/docs/dashboard/overview.md @@ -86,6 +86,55 @@ cards: icon_height: 24px ``` +### CLI Console + +The `execute_command` / `execute_command_ui` services run a command but the +response is only visible in Developer Tools or the logs. The **CLI Console** +gives you an interactive terminal-style surface that shows the output of each +command directly on the dashboard. + +Enable it first under **Settings → Devices & Services → MeshCore → Configure → +Global Settings → Enable CLI Console**. That creates a `sensor.*_cli_console` +entity whose `transcript` attribute holds a rolling log of the commands you run +and their responses. It records only command/response pairs — it does **not** +stream the radio's continuous diagnostic/noise-floor output. + +Use the `cli_command_ui` service (instead of `execute_command_ui`) so the +output is captured, and render the transcript with a markdown card: + +```yaml +type: vertical-stack +cards: + - type: entities + entities: + - entity: text.meshcore_command + name: MeshCore CLI + - show_name: true + show_icon: true + type: button + tap_action: + action: call-service + service: meshcore.cli_command_ui + name: Run Command + icon: mdi:console-line + icon_height: 24px + - type: markdown + content: >- + ``` + {{ state_attr('sensor.YOUR_NODE_cli_console', 'transcript') }} + ``` +``` + +Replace `sensor.YOUR_NODE_cli_console` with your node's actual entity id (find +it under the device's entities). You can also call the service directly with a +command, e.g. from an automation or script: + +```yaml +action: meshcore.cli_command +data: + command: get_stats_radio +``` + ### Network Map Display all Meshcore contacts on a map using their location data. diff --git a/docs/docs/events.md b/docs/docs/events.md index ba4961f..4acd351 100644 --- a/docs/docs/events.md +++ b/docs/docs/events.md @@ -332,6 +332,36 @@ action: message: "Battery: {{ (trigger.event.data.payload.level / 1000) | round(2) }}V" ``` +## CLI Console Events + +### meshcore_cli_response +Fired after a `meshcore.cli_command` (or `cli_command_ui`) call completes. Use +it to react to CLI output in automations without polling the console sensor. + +**Event data:** + +- `command` - The command string that was run +- `response` - The normalized response (see [execute_command Response Shapes](#execute_command-response-shapes)), or `null` on failure +- `is_error` - `true` when the command failed or returned no response +- `entry_id` - The config entry the command ran against (may be `null`) +- `timestamp` - Unix epoch seconds when the response was recorded + +**Example:** +```yaml +alias: Notify on CLI error +trigger: + - platform: event + event_type: meshcore_cli_response +condition: + - condition: template + value_template: "{{ trigger.event.data.is_error }}" +action: + - service: persistent_notification.create + data: + title: "MeshCore CLI error" + message: "Command '{{ trigger.event.data.command }}' failed" +``` + ## Connection Events ### meshcore_connected diff --git a/docs/docs/services.md b/docs/docs/services.md index 3cd81b9..be63f54 100644 --- a/docs/docs/services.md +++ b/docs/docs/services.md @@ -179,6 +179,52 @@ service: meshcore.execute_command_ui data: {} ``` +### CLI Command +Run a command against the local companion radio **and record the command and +its response to the CLI Console sensor**, so the output is visible in the UI +(unlike `execute_command_ui`, which discards the response). Same command syntax +and capabilities as `execute_command`. Enable the CLI Console under **Global +Settings → Enable CLI Console** to get the `sensor.*_cli_console` output entity. +The console records only command/response pairs — it does not stream the +radio's continuous diagnostic/noise-floor log. + +**Service:** `meshcore.cli_command` + +**Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `command` | string | Yes | Command with parameters, e.g. `get_bat`, `get_stats_radio`, `set_tx_power 20` | +| `entry_id` | string | No | Config entry ID for multiple devices | + +**Example:** + +```yaml +service: meshcore.cli_command +data: + command: get_stats_radio +``` + +### CLI Command UI +Run the command in `text.meshcore_command` through the CLI Console (so the +response is recorded to the transcript) and clear the input afterwards. This is +the CLI-Console equivalent of `execute_command_ui`. + +**Service:** `meshcore.cli_command_ui` + +**Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `entry_id` | string | No | Config entry ID for multiple devices | + +**Example:** + +```yaml +service: meshcore.cli_command_ui +data: {} +``` + ## Usage in Automations ### Battery Check Automation diff --git a/tests/test_cli_command.py b/tests/test_cli_command.py new file mode 100644 index 0000000..2179ebc --- /dev/null +++ b/tests/test_cli_command.py @@ -0,0 +1,214 @@ +"""Tests for the CLI console services (cli_command / cli_command_ui) in services.py. + +The CLI console services wrap execute_command and additionally: + * record the command/response pair to the resolved coordinator's console + * fire the EVENT_CLI_RESPONSE event + * return the same response execute_command produces + +These tests reuse the module-loading approach from test_execute_command.py: +conftest stubs meshcore/const/homeassistant, so services.py is loaded directly +and its handlers are exercised against MagicMock coordinators. +""" +import importlib.util +import os +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +class _ET: + ERROR = "error" + TRACE_DATA = "trace_data" + PATH_RESPONSE = "path_response" + MSG_SENT = "msg_sent" + CONTACTS = "contacts" + ACK = "ack" + + +_mc_events = sys.modules.get("meshcore.events") +if _mc_events is not None: + _mc_events.EventType = _ET + +_SERVICES_PATH = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "custom_components", "meshcore", "services.py", +) +_spec = importlib.util.spec_from_file_location( + "custom_components.meshcore.services", _SERVICES_PATH +) +_module = importlib.util.module_from_spec(_spec) +_module.__package__ = "custom_components.meshcore" +_spec.loader.exec_module(_module) + +# create_service_call (used by cli_command_ui) branches on MAJOR_VERSION, which +# conftest leaves as a MagicMock; pin it so the comparison works under test. +_module.MAJOR_VERSION = 2025 + +async_setup_services = _module.async_setup_services +DOMAIN = _module.DOMAIN +ATTR_COMMAND = _module.ATTR_COMMAND +ATTR_ENTRY_ID = _module.ATTR_ENTRY_ID +EVENT_CLI_RESPONSE = _module.EVENT_CLI_RESPONSE + + +class _Event: + """Mimic meshcore.events.Event enough for response normalization.""" + + def __init__(self, type_, payload): + self.type = type_ + self.payload = payload + + +def _build_coordinator(command_name, return_value, connected=True): + """Coordinator with a single mocked SDK command. record_cli_console is a + plain MagicMock so calls can be asserted.""" + coord = MagicMock() + coord.api = MagicMock() + coord.api.connected = connected + coord.api.self_info = {"suggested_timeout": 1000} + + mesh_core = MagicMock() + mesh_core.commands = MagicMock() + setattr( + mesh_core.commands, + command_name, + AsyncMock(return_value=return_value), + ) + coord.api.mesh_core = mesh_core + coord._discovered_contacts = {} + return coord + + +async def _setup(coordinator): + """Run async_setup_services with stubbed hass; return (hass, registered).""" + registered = {} + hass = MagicMock() + hass.data = {DOMAIN: {"entry1": coordinator}} + + def _register(domain, service_const, handler, **kwargs): + registered[getattr(handler, "__name__", repr(handler))] = (handler, kwargs) + + hass.services.async_register = _register + hass.services.has_service = MagicMock(return_value=False) + + await async_setup_services(hass) + return hass, registered + + +def _call(command, entry_id=None): + call = MagicMock() + call.data = {ATTR_COMMAND: command, ATTR_ENTRY_ID: entry_id} + return call + + +@pytest.mark.asyncio +async def test_cli_command_records_and_returns_response(): + """cli_command returns the execute_command response and records it.""" + payload = {"level": 4100, "status": "ok"} + coord = _build_coordinator("get_bat", _Event(_ET.MSG_SENT, payload)) + hass, registered = await _setup(coord) + handler = registered["async_cli_command_service"][0] + + result = await handler(_call("get_bat")) + + assert result == payload + coord.record_cli_console.assert_called_once() + args, _ = coord.record_cli_console.call_args + assert args[0] == "get_bat" # command + assert args[1] == payload # response + assert args[2] is False # is_error + + +@pytest.mark.asyncio +async def test_cli_command_fires_event(): + """cli_command fires EVENT_CLI_RESPONSE with the command and response.""" + payload = {"ok": True} + coord = _build_coordinator("get_time", _Event(_ET.MSG_SENT, payload)) + hass, registered = await _setup(coord) + handler = registered["async_cli_command_service"][0] + + await handler(_call("get_time")) + + hass.bus.async_fire.assert_called_once() + event_name, event_data = hass.bus.async_fire.call_args[0] + assert event_name == EVENT_CLI_RESPONSE + assert event_data["command"] == "get_time" + assert event_data["response"] == payload + assert event_data["is_error"] is False + + +@pytest.mark.asyncio +async def test_cli_command_marks_error_on_no_response(): + """A None response (e.g. unknown/failed command) records is_error=True.""" + # An unknown command makes execute_command return None without running. + coord = _build_coordinator("get_bat", _Event(_ET.MSG_SENT, {"x": 1})) + hass, registered = await _setup(coord) + handler = registered["async_cli_command_service"][0] + + result = await handler(_call("definitely_not_a_command")) + + assert result is None + coord.record_cli_console.assert_called_once() + args, _ = coord.record_cli_console.call_args + assert args[2] is True # is_error + + +@pytest.mark.asyncio +async def test_cli_command_ui_reads_text_helper(): + """cli_command_ui pulls the command from text.meshcore_command and runs it.""" + payload = {"done": True} + coord = _build_coordinator("send_advert", _Event(_ET.MSG_SENT, payload)) + hass, registered = await _setup(coord) + handler = registered["async_cli_command_ui_service"][0] + + state = MagicMock() + state.state = "send_advert" + hass.states.get = MagicMock(return_value=state) + hass.services.async_call = AsyncMock() + + # create_service_call wraps homeassistant.core.ServiceCall, which conftest + # mocks (its .data is not the dict we pass). Stub it to a plain call object + # so the delegated cli_command sees the real command string. + def _fake_call(domain, service, data=None, hass=None): + # The UI handler builds data with literal "command"/"entry_id" keys; + # the cli_command handler reads the (mocked) ATTR_* constants. Remap so + # the delegated handler finds the command string. + data = data or {} + c = MagicMock() + c.data = { + ATTR_COMMAND: data.get("command"), + ATTR_ENTRY_ID: data.get("entry_id"), + } + return c + + monkeypatched = _module.create_service_call + _module.create_service_call = _fake_call + try: + ui_call = MagicMock() + ui_call.data = {ATTR_ENTRY_ID: None} + result = await handler(ui_call) + finally: + _module.create_service_call = monkeypatched + + assert result == payload + coord.record_cli_console.assert_called_once() + # Input field is cleared after execution. + hass.services.async_call.assert_awaited() + + +@pytest.mark.asyncio +async def test_cli_command_ui_noop_on_empty_input(): + """cli_command_ui does nothing when the text helper is empty.""" + coord = _build_coordinator("get_bat", _Event(_ET.MSG_SENT, {"x": 1})) + hass, registered = await _setup(coord) + handler = registered["async_cli_command_ui_service"][0] + + state = MagicMock() + state.state = "" + hass.states.get = MagicMock(return_value=state) + + result = await handler(MagicMock(data={ATTR_ENTRY_ID: None})) + + assert result is None + coord.record_cli_console.assert_not_called() From 286213e57aa2a946557f82a32205b563528cbd68 Mon Sep 17 00:00:00 2001 From: Drew McCalmont Date: Wed, 24 Jun 2026 12:22:54 -0400 Subject: [PATCH 2/5] docs: add CLI Command Reference and fix CLI Console card example - New docs/docs/cli-commands.md: explains these are meshcore-py SDK method names (not the iOS app / meshcli / repeater-CLI vocabularies), with a grouped reference (query, config, channels, remote-node commands), argument syntax, and pointers to the authoritative source. - Cross-link it from services.md and the dashboard overview. - Fix the CLI Console card example: use a literal block (|) for the markdown content so the transcript keeps its newlines instead of collapsing onto one line, and modern perform_action syntax. Addresses the discoverability gap from upstream issue #111. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/docs/cli-commands.md | 124 ++++++++++++++++++++++++++++++++ docs/docs/dashboard/overview.md | 21 +++--- docs/docs/services.md | 3 + 3 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 docs/docs/cli-commands.md diff --git a/docs/docs/cli-commands.md b/docs/docs/cli-commands.md new file mode 100644 index 0000000..0808644 --- /dev/null +++ b/docs/docs/cli-commands.md @@ -0,0 +1,124 @@ +--- +sidebar_position: 4 +title: CLI Command Reference +--- + +# CLI Command Reference + +The `meshcore.cli_command` and `meshcore.execute_command` services (and the CLI +Console card) run commands against your **local companion radio**. This page +lists what you can type. + +## Which "CLI" is this? + +MeshCore has several command vocabularies — they are **not** interchangeable: + +| Vocabulary | Where | Example | +|---|---|---| +| **This CLI** (meshcore-py SDK methods) | HA `cli_command` / `execute_command` / CLI Console | `send_device_query`, `get_bat` | +| Companion app (iOS/Android) | The phone GUI | buttons/screens (same protocol underneath) | +| `meshcli` (meshcore-cli) | A separate terminal tool | `infos`, `advert`, `send` | +| Repeater / room-server CLI | Sent to a **remote** repeater after `send_login` | `reboot`, `set freq …` | + +The commands here are the **Python SDK method names** (snake_case). So it's +`send_advert`, not `advert`; `send_device_query`, not `version`; `get_time`, not +`time`. The authoritative source is the meshcore-py library's +[`meshcore/commands/`](https://github.com/meshcore-dev/meshcore_py/tree/main/meshcore/commands) +modules — every `async def (...)` is a command. + +## Syntax + +- **Space-separated arguments:** `set_tx_power 20`, `get_channel 0` +- **Quote strings with spaces:** `set_name "Drew Node"` +- **``** means a contact's **public-key prefix** (≥6 hex chars) or its + name — used for commands that talk to a *remote* node. +- Functional form also works: `send_advert(flood=True)`. + +Responses appear in the CLI Console transcript, in the Developer Tools → Actions +result, and (for `cli_command`) on the `meshcore_cli_response` event. + +:::warning +Commands prefixed `set_*`, `import_*`, `reboot`, and `send_advert` **change your +device or put traffic on the mesh**. Read-only `get_*` / `send_device_query` +commands are safe to experiment with. +::: + +## Query / read (safe) + +| Command | Args | Returns | +|---|---|---| +| `send_device_query` | — | Firmware version, hardware model (the "version" command) | +| `send_appstart` | — | Self info (name, public key, radio params) | +| `get_bat` | — | Battery voltage | +| `get_time` | — | Device clock | +| `get_self_telemetry` | — | Local telemetry payload | +| `get_custom_vars` | — | Custom variables | +| `get_stats_core` | — | Uptime, queue length, error flags | +| `get_stats_radio` | — | Noise floor, last RSSI/SNR, airtime | +| `get_stats_packets` | — | Packet counters | +| `get_contacts` | `[lastmod]` | Contact list | +| `get_channel` | `` | Channel name + secret | +| `get_path_hash_mode` | — | Path hash mode (0–2) | +| `get_allowed_repeat_freq` | — | Allowed repeat frequency | +| `get_tuning` | — | RX delay / AF tuning params | +| `get_autoadd_config` | — | Auto-add-contacts config | + +## Configuration / write (changes the device) + +| Command | Args | Notes | +|---|---|---| +| `set_name` | `` | Device name | +| `set_tx_power` | `` | TX power (dBm) | +| `set_time` | `` | Set clock | +| `set_coords` | ` ` | Set GPS coords | +| `set_radio` | ` ` | Radio params | +| `set_tuning` | ` ` | Tuning params | +| `set_path_hash_mode` | `` | 0=1 byte, 1=2, 2=3 | +| `set_telemetry_mode_base` / `_loc` / `_env` | `` | Telemetry modes | +| `set_advert_loc_policy` | `` | Advert location policy | +| `set_manual_add_contacts` | `` | Manual contact-add mode | +| `set_multi_acks` | `` | Multi-ack setting | +| `set_custom_var` | ` ` | Set a custom variable | +| `set_devicepin` | `` | Device/BLE PIN | +| `send_advert` | `[flood]` | Broadcast an advert packet to the mesh | +| `reboot` | — | **Reboots the radio** | + +## Channels + +| Command | Args | +|---|---| +| `get_channel` | `` | +| `set_channel` | ` ` | + +## Remote nodes (need a ``) + +These send traffic over the mesh to another node. The `*_sync` variants wait for +a reply and return it; the async variants return immediately. + +| Command | Args | +|---|---| +| `send_login` / `send_logout` | ` [password]` | +| `send_msg` | ` ""` | +| `send_cmd` | ` ""` (repeater CLI command) | +| `req_status_sync` | `` | +| `req_telemetry_sync` | `` | +| `req_neighbours_sync` | `` | +| `req_owner_sync` / `req_basic_sync` / `req_regions_sync` | `` | +| `send_path_discovery` | `` | +| `reset_path` | `` | +| `share_contact` / `export_contact` / `remove_contact` | `` | + +:::tip +To run a **repeater's** text CLI (e.g. `reboot`, `advert`) on a *remote* +repeater, log in first (`send_login `) and then use +`send_cmd ""`. Those repeater verbs are a different +vocabulary from the local commands on this page. +::: + +## Full list + +The complete set the integration recognizes — with argument types — lives in the +`command_param_types` mapping in +[`custom_components/meshcore/services.py`](https://github.com/meshcore-dev/meshcore-ha/blob/main/custom_components/meshcore/services.py). +Any method that exists on the meshcore-py `commands` object can be called even if +it isn't listed above. diff --git a/docs/docs/dashboard/overview.md b/docs/docs/dashboard/overview.md index 8155267..2d1955e 100644 --- a/docs/docs/dashboard/overview.md +++ b/docs/docs/dashboard/overview.md @@ -91,7 +91,8 @@ cards: The `execute_command` / `execute_command_ui` services run a command but the response is only visible in Developer Tools or the logs. The **CLI Console** gives you an interactive terminal-style surface that shows the output of each -command directly on the dashboard. +command directly on the dashboard. See the +[CLI Command Reference](../cli-commands) for what you can type. Enable it first under **Settings → Devices & Services → MeshCore → Configure → Global Settings → Enable CLI Console**. That creates a `sensor.*_cli_console` @@ -109,22 +110,24 @@ cards: entities: - entity: text.meshcore_command name: MeshCore CLI - - show_name: true - show_icon: true - type: button - tap_action: - action: call-service - service: meshcore.cli_command_ui + - type: button name: Run Command icon: mdi:console-line - icon_height: 24px + tap_action: + action: perform-action + perform_action: meshcore.cli_command_ui - type: markdown - content: >- + content: | ``` {{ state_attr('sensor.YOUR_NODE_cli_console', 'transcript') }} ``` ``` +The markdown `content` uses a literal block (`|`), not a folded one (`>-`) — a +folded scalar collapses the newlines and renders the transcript on a single +line. Use `perform_action`/`perform-action` on recent Home Assistant +(`service`/`call-service` on older versions). + Replace `sensor.YOUR_NODE_cli_console` with your node's actual entity id (find it under the device's entities). You can also call the service directly with a command, e.g. from an automation or script: diff --git a/docs/docs/services.md b/docs/docs/services.md index be63f54..db7b788 100644 --- a/docs/docs/services.md +++ b/docs/docs/services.md @@ -188,6 +188,9 @@ Settings → Enable CLI Console** to get the `sensor.*_cli_console` output entit The console records only command/response pairs — it does not stream the radio's continuous diagnostic/noise-floor log. +> See the [CLI Command Reference](./cli-commands) for the full list of commands +> and their arguments. + **Service:** `meshcore.cli_command` **Fields:** From be1c85aa06398d24f50ab2f1dc9b4781a48ff789 Mon Sep 17 00:00:00 2001 From: Drew McCalmont Date: Wed, 24 Jun 2026 12:35:39 -0400 Subject: [PATCH 3/5] cli: add CLI Run/Clear button entities + cli_console_clear service Addresses console testing feedback: - No way to clear the transcript -> add coordinator.clear_cli_console(), a meshcore.cli_console_clear service, and a "CLI Clear Console" button. - The Run button as a `type: button` card renders huge -> ship Run/Clear as compact button *entities* (button platform), attached to the device so they also appear on the device page automatically. - Docs: recommend an entities-card layout (compact rows) instead of the oversized button card, and explain that the auto device page can't host the transcript card / control placement (use a dashboard sections view). New button platform (gated on CONF_CLI_CONSOLE_ENABLED): button.*_cli_run -> calls cli_command_ui button.*_cli_clear -> clears the console - services.yaml + en.json for cli_console_clear - test: cli_console_clear clears the resolved coordinator Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/meshcore/__init__.py | 2 +- custom_components/meshcore/button.py | 100 ++++++++++++++++++ custom_components/meshcore/const.py | 2 + custom_components/meshcore/coordinator.py | 10 ++ custom_components/meshcore/services.py | 25 +++++ custom_components/meshcore/services.yaml | 13 +++ .../meshcore/translations/en.json | 10 ++ docs/docs/dashboard/overview.md | 49 +++++---- tests/test_cli_command.py | 12 +++ 9 files changed, 203 insertions(+), 20 deletions(-) create mode 100644 custom_components/meshcore/button.py diff --git a/custom_components/meshcore/__init__.py b/custom_components/meshcore/__init__.py index 2b65a47..d2766a0 100644 --- a/custom_components/meshcore/__init__.py +++ b/custom_components/meshcore/__init__.py @@ -65,7 +65,7 @@ _LOGGER = logging.getLogger(__name__) # List of platforms to set up -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SELECT, Platform.TEXT, Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SELECT, Platform.TEXT, Platform.DEVICE_TRACKER, Platform.BUTTON] STATIC_PATH_REGISTERED_KEY = f"{DOMAIN}_static_path_registered" diff --git a/custom_components/meshcore/button.py b/custom_components/meshcore/button.py new file mode 100644 index 0000000..468f6bf --- /dev/null +++ b/custom_components/meshcore/button.py @@ -0,0 +1,100 @@ +"""Button platform for MeshCore integration. + +Provides the CLI Console controls as button entities so they appear on the +device page automatically and render compactly (unlike a full button *card*). +Created only when CONF_CLI_CONSOLE_ENABLED is set. +""" +from __future__ import annotations + +import logging + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_CLI_CONSOLE_ENABLED, + DOMAIN, + ENTITY_DOMAIN_BUTTON, + SERVICE_CLI_COMMAND_UI, +) +from .utils import format_entity_id + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up MeshCore button entities from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[ButtonEntity] = [] + if entry.data.get(CONF_CLI_CONSOLE_ENABLED, False): + entities.append(MeshCoreCLIRunButton(coordinator)) + entities.append(MeshCoreCLIClearButton(coordinator)) + + if entities: + async_add_entities(entities) + + +class _MeshCoreCLIButton(CoordinatorEntity, ButtonEntity): + """Shared base for CLI Console buttons (attached to the companion device).""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, coordinator) -> None: + super().__init__(coordinator) + self.coordinator = coordinator + + @property + def device_info(self) -> DeviceInfo: + """Attach to the main companion device so it shows on the device page.""" + return DeviceInfo(**self.coordinator.device_info) + + +class MeshCoreCLIRunButton(_MeshCoreCLIButton): + """Runs the command in text.meshcore_command through the CLI Console.""" + + _attr_name = "CLI Run Command" + _attr_icon = "mdi:play" + + def __init__(self, coordinator) -> None: + super().__init__(coordinator) + public_key_short = coordinator.pubkey[:6] if coordinator.pubkey else "" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_cli_run" + self.entity_id = format_entity_id( + ENTITY_DOMAIN_BUTTON, public_key_short, "cli_run" + ) + + async def async_press(self) -> None: + """Execute the command in the input helper and record its output.""" + await self.hass.services.async_call( + DOMAIN, + SERVICE_CLI_COMMAND_UI, + {"entry_id": self.coordinator.config_entry.entry_id}, + blocking=True, + ) + + +class MeshCoreCLIClearButton(_MeshCoreCLIButton): + """Clears the CLI Console transcript.""" + + _attr_name = "CLI Clear Console" + _attr_icon = "mdi:notification-clear-all" + + def __init__(self, coordinator) -> None: + super().__init__(coordinator) + public_key_short = coordinator.pubkey[:6] if coordinator.pubkey else "" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_cli_clear" + self.entity_id = format_entity_id( + ENTITY_DOMAIN_BUTTON, public_key_short, "cli_clear" + ) + + async def async_press(self) -> None: + """Empty the console transcript.""" + self.coordinator.clear_cli_console() diff --git a/custom_components/meshcore/const.py b/custom_components/meshcore/const.py index a192917..5db9060 100644 --- a/custom_components/meshcore/const.py +++ b/custom_components/meshcore/const.py @@ -33,6 +33,7 @@ SERVICE_EXECUTE_COMMAND_UI: Final = "execute_command_ui" SERVICE_CLI_COMMAND: Final = "cli_command" SERVICE_CLI_COMMAND_UI: Final = "cli_command_ui" +SERVICE_CLI_CLEAR: Final = "cli_console_clear" SERVICE_MESSAGE_SCRIPT: Final = "send_ui_message" SERVICE_ADD_SELECTED_CONTACT: Final = "add_selected_contact" SERVICE_REMOVE_SELECTED_CONTACT: Final = "remove_selected_contact" @@ -62,6 +63,7 @@ # Entity naming constants ENTITY_DOMAIN_BINARY_SENSOR: Final = "binary_sensor" ENTITY_DOMAIN_SENSOR: Final = "sensor" +ENTITY_DOMAIN_BUTTON: Final = "button" DEFAULT_DEVICE_NAME: Final = "meshcore" MESSAGES_SUFFIX: Final = "messages" CONTACT_SUFFIX: Final = "contact" diff --git a/custom_components/meshcore/coordinator.py b/custom_components/meshcore/coordinator.py index 8a322e2..b5326fc 100644 --- a/custom_components/meshcore/coordinator.py +++ b/custom_components/meshcore/coordinator.py @@ -291,6 +291,16 @@ def record_cli_console( except Exception as ex: # pragma: no cover - defensive _LOGGER.debug("Failed to update CLI console sensor: %s", ex) + def clear_cli_console(self) -> None: + """Empty the CLI console transcript and refresh the sensor.""" + self.cli_console_history.clear() + sensor = self.cli_console_sensor + if sensor is not None: + try: + sensor.async_write_ha_state() + except Exception as ex: # pragma: no cover - defensive + _LOGGER.debug("Failed to update CLI console sensor: %s", ex) + def mark_contact_dirty(self, pubkey_prefix: str): """Mark a contact as needing update (for performance optimization). diff --git a/custom_components/meshcore/services.py b/custom_components/meshcore/services.py index c6e8e29..245a8de 100644 --- a/custom_components/meshcore/services.py +++ b/custom_components/meshcore/services.py @@ -46,6 +46,7 @@ SERVICE_EXECUTE_COMMAND_UI, SERVICE_CLI_COMMAND, SERVICE_CLI_COMMAND_UI, + SERVICE_CLI_CLEAR, EVENT_CLI_RESPONSE, SERVICE_MESSAGE_SCRIPT, SERVICE_ADD_SELECTED_CONTACT, @@ -1023,6 +1024,20 @@ async def async_cli_command_ui_service(call: ServiceCall): return response + async def async_cli_clear_service(call: ServiceCall) -> None: + """Clear the CLI console transcript. + + Clears the resolved coordinator when an entry_id is given, otherwise + clears every configured coordinator's console. + """ + entry_id = call.data.get(ATTR_ENTRY_ID) + for config_entry_id, coordinator in hass.data[DOMAIN].items(): + if not hasattr(coordinator, "clear_cli_console"): + continue + if entry_id and entry_id != config_entry_id: + continue + coordinator.clear_cli_console() + # Register services hass.services.async_register( DOMAIN, @@ -1074,6 +1089,13 @@ async def async_cli_command_ui_service(call: ServiceCall): supports_response=SupportsResponse.OPTIONAL, ) + hass.services.async_register( + DOMAIN, + SERVICE_CLI_CLEAR, + async_cli_clear_service, + schema=UI_MESSAGE_SCHEMA, + ) + # Register the combined UI message service hass.services.async_register( DOMAIN, @@ -1911,6 +1933,9 @@ async def async_unload_services(hass: HomeAssistant) -> None: if hass.services.has_service(DOMAIN, SERVICE_CLI_COMMAND_UI): hass.services.async_remove(DOMAIN, SERVICE_CLI_COMMAND_UI) + if hass.services.has_service(DOMAIN, SERVICE_CLI_CLEAR): + hass.services.async_remove(DOMAIN, SERVICE_CLI_CLEAR) + if hass.services.has_service(DOMAIN, SERVICE_MESSAGE_SCRIPT): hass.services.async_remove(DOMAIN, SERVICE_MESSAGE_SCRIPT) diff --git a/custom_components/meshcore/services.yaml b/custom_components/meshcore/services.yaml index 9135ac8..8ee0c8d 100644 --- a/custom_components/meshcore/services.yaml +++ b/custom_components/meshcore/services.yaml @@ -175,6 +175,19 @@ cli_command_ui: selector: text: +cli_console_clear: + name: Clear CLI Console + description: >- + Clear the CLI Console transcript. With no entry_id, clears every configured device's console. + fields: + entry_id: + name: Config Entry ID + description: The config entry ID if you have multiple MeshCore devices. Leave empty to clear all. + required: false + example: "abc123def456" + selector: + text: + add_selected_contact: name: Add Selected Contact description: Add the contact selected in the discovered contact select entity to your node's contact list. diff --git a/custom_components/meshcore/translations/en.json b/custom_components/meshcore/translations/en.json index da50eb3..f0863f1 100644 --- a/custom_components/meshcore/translations/en.json +++ b/custom_components/meshcore/translations/en.json @@ -447,6 +447,16 @@ } } }, + "cli_console_clear": { + "name": "Clear CLI Console", + "description": "Clear the CLI Console transcript. With no entry_id, clears every configured device's console.", + "fields": { + "entry_id": { + "name": "Config Entry ID", + "description": "The config entry ID if you have multiple MeshCore devices. Leave empty to clear all." + } + } + }, "add_selected_contact": { "name": "Add Selected Contact", "description": "Add the contact selected in the discovered contact select entity to your node's contact list.", diff --git a/docs/docs/dashboard/overview.md b/docs/docs/dashboard/overview.md index 2d1955e..d385344 100644 --- a/docs/docs/dashboard/overview.md +++ b/docs/docs/dashboard/overview.md @@ -97,11 +97,13 @@ command directly on the dashboard. See the Enable it first under **Settings → Devices & Services → MeshCore → Configure → Global Settings → Enable CLI Console**. That creates a `sensor.*_cli_console` entity whose `transcript` attribute holds a rolling log of the commands you run -and their responses. It records only command/response pairs — it does **not** -stream the radio's continuous diagnostic/noise-floor output. +and their responses (it records only command/response pairs — it does **not** +stream the radio's continuous diagnostic/noise-floor output), plus two compact +button entities on the device: **CLI Run Command** and **CLI Clear Console**. -Use the `cli_command_ui` service (instead of `execute_command_ui`) so the -output is captured, and render the transcript with a markdown card: +The recommended card puts the input and the **button entities** in a single +`entities` card (compact rows) so you don't get the oversized `button` *card*, +then renders the transcript with a markdown card: ```yaml type: vertical-stack @@ -109,13 +111,11 @@ cards: - type: entities entities: - entity: text.meshcore_command - name: MeshCore CLI - - type: button - name: Run Command - icon: mdi:console-line - tap_action: - action: perform-action - perform_action: meshcore.cli_command_ui + name: Command + - entity: button.YOUR_NODE_cli_run + name: Run + - entity: button.YOUR_NODE_cli_clear + name: Clear - type: markdown content: | ``` @@ -123,14 +123,25 @@ cards: ``` ``` -The markdown `content` uses a literal block (`|`), not a folded one (`>-`) — a -folded scalar collapses the newlines and renders the transcript on a single -line. Use `perform_action`/`perform-action` on recent Home Assistant -(`service`/`call-service` on older versions). - -Replace `sensor.YOUR_NODE_cli_console` with your node's actual entity id (find -it under the device's entities). You can also call the service directly with a -command, e.g. from an automation or script: +Notes: +- Replace `YOUR_NODE` with your node's prefix (e.g. `meshcore_49d715_…`) — find + the exact ids under the device's entities or in Developer Tools → States. +- The markdown `content` uses a literal block (`|`), not a folded one (`>-`); a + folded scalar collapses the newlines and renders the transcript on one line. +- Prefer the **button entities** over a `type: button` card — the button card + renders as a large full-width tile, while the entity rows are compact. + +### Placing it on the right / on the device page + +The auto-generated **device page** (Settings → Devices → your node) lists these +entities automatically but isn't customizable — you can't add the markdown +transcript card there or control left/right placement. For a controlled +layout (e.g. console on the right), use a **dashboard** with a two-column +[sections view](https://www.home-assistant.io/dashboards/sections/) or a +`horizontal-stack`, and put the `vertical-stack` above in the right column. + +You can also call the services directly with a command, e.g. from an automation +or script: ```yaml action: meshcore.cli_command diff --git a/tests/test_cli_command.py b/tests/test_cli_command.py index 2179ebc..61664bd 100644 --- a/tests/test_cli_command.py +++ b/tests/test_cli_command.py @@ -212,3 +212,15 @@ async def test_cli_command_ui_noop_on_empty_input(): assert result is None coord.record_cli_console.assert_not_called() + + +@pytest.mark.asyncio +async def test_cli_clear_service_clears_console(): + """cli_console_clear clears the resolved coordinator's transcript.""" + coord = _build_coordinator("get_bat", _Event(_ET.MSG_SENT, {"x": 1})) + hass, registered = await _setup(coord) + handler = registered["async_cli_clear_service"][0] + + await handler(MagicMock(data={ATTR_ENTRY_ID: None})) + + coord.clear_cli_console.assert_called_once() From d7a3b37655af17370e44c72cc6bb20ab3f5aeccd Mon Sep 17 00:00:00 2001 From: Drew McCalmont Date: Wed, 24 Jun 2026 12:59:23 -0400 Subject: [PATCH 4/5] cli: keep console entities off the device page; clearer command errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device page feedback: the CLI buttons + console sensor showed under the device's Controls/Sensors, implying a console that can't actually work there (a device page can't render the transcript). Detach all three CLI entities from the device and hide them by default; they're used only by entity_id in the dashboard CLI Console card. Error feedback: contact-typed commands that failed to resolve returned a silent None, surfacing as a vague "(no response)". Add _contact_error() so the response explains why — pubkey_prefix_too_short (e.g. "ae6d" is under 6 hex chars), contact_not_found, or not_connected — with the offending argument. Also return a structured unknown_keyword error. - sensor/button: no device_info, entity_registry_visible_default = False - docs: correct the device-page claims; note entities are dashboard-only - tests: short-prefix and unknown-contact return structured errors Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/meshcore/button.py | 15 +++++----- custom_components/meshcore/sensor.py | 10 +++---- custom_components/meshcore/services.py | 38 ++++++++++++++++++++++---- docs/docs/dashboard/overview.md | 18 +++++++----- tests/test_execute_command.py | 25 +++++++++++++++++ 5 files changed, 82 insertions(+), 24 deletions(-) diff --git a/custom_components/meshcore/button.py b/custom_components/meshcore/button.py index 468f6bf..0b6e4ea 100644 --- a/custom_components/meshcore/button.py +++ b/custom_components/meshcore/button.py @@ -11,7 +11,6 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -42,20 +41,22 @@ async def async_setup_entry( class _MeshCoreCLIButton(CoordinatorEntity, ButtonEntity): - """Shared base for CLI Console buttons (attached to the companion device).""" + """Shared base for CLI Console buttons. + + Deliberately NOT attached to the companion device and hidden by default: + the console only works as a dashboard card (the device page can't render the + transcript), so surfacing these on the device page would be misleading. + They're used by referencing their entity_id in the CLI Console card. + """ _attr_has_entity_name = True _attr_should_poll = False + _attr_entity_registry_visible_default = False def __init__(self, coordinator) -> None: super().__init__(coordinator) self.coordinator = coordinator - @property - def device_info(self) -> DeviceInfo: - """Attach to the main companion device so it shows on the device page.""" - return DeviceInfo(**self.coordinator.device_info) - class MeshCoreCLIRunButton(_MeshCoreCLIButton): """Runs the command in text.meshcore_command through the CLI Console.""" diff --git a/custom_components/meshcore/sensor.py b/custom_components/meshcore/sensor.py index af984c9..5ff66ed 100644 --- a/custom_components/meshcore/sensor.py +++ b/custom_components/meshcore/sensor.py @@ -1374,8 +1374,13 @@ class MeshCoreCLIConsoleSensor(CoordinatorEntity, SensorEntity): streams LOG_DATA / RX_LOG packet noise. """ + # Not attached to the companion device and hidden by default: the console + # only works as a dashboard card (a device page can't render the transcript), + # so surfacing it on the device page would be misleading. The CLI Console + # card references this entity by entity_id. _attr_has_entity_name = True _attr_should_poll = False + _attr_entity_registry_visible_default = False _attr_icon = "mdi:console" _attr_name = "CLI Console" @@ -1401,11 +1406,6 @@ async def async_will_remove_from_hass(self) -> None: self.coordinator.cli_console_sensor = None await super().async_will_remove_from_hass() - @property - def device_info(self) -> DeviceInfo: - """Return device info (attach to the main companion device).""" - return DeviceInfo(**self.coordinator.device_info) - @property def native_value(self) -> str: """Return the most recent command (truncated to the state length cap).""" diff --git a/custom_components/meshcore/services.py b/custom_components/meshcore/services.py index 245a8de..534202d 100644 --- a/custom_components/meshcore/services.py +++ b/custom_components/meshcore/services.py @@ -165,6 +165,33 @@ def _resolve_contact(arg: str, command_name: str, api: Any, coordinator: Any) -> return contact +def _contact_error(arg: str, command_name: str, api: Any) -> dict: + """Build a clear, structured error for a failed contact lookup. + + Returned as the command response (instead of a silent None) so the CLI + Console / execute_command caller sees *why* a contact-typed argument + failed rather than a vague "(no response)". + """ + if len(arg) < 6: + detail = ( + f"'{arg}' is too short — use at least 6 hex characters of the " + "contact's public key, or its exact name" + ) + reason = "pubkey_prefix_too_short" + elif not api or not api.mesh_core: + detail = "device not connected" + reason = "not_connected" + else: + detail = f"no contact matches '{arg}' by key prefix or name" + reason = "contact_not_found" + return { + "error": reason, + "command": command_name, + "argument": arg, + "detail": detail, + } + + def _node_has_tracked_subscription(coordinator, pubkey_prefix: str) -> bool: """True when the node has a repeater/client tracking subscription. @@ -632,7 +659,7 @@ async def async_execute_command_service(call: ServiceCall) -> None: if ptype == "contact": contact = _resolve_contact(str(val), command_name, api, coordinator) if contact is None: - return + return _contact_error(str(val), command_name, api) prepared_args.append(contact) else: prepared_args.append(val) @@ -641,13 +668,14 @@ async def async_execute_command_service(call: ServiceCall) -> None: for kw_name, kw_val in kw_literals.items(): if kw_name not in sig_params: _LOGGER.error("Unknown keyword '%s' for command '%s'", kw_name, command_name) - return + return {"error": "unknown_keyword", "command": command_name, "argument": kw_name} idx = sig_params.index(kw_name) ptype = param_types[idx] if idx < len(param_types) else None if ptype == "contact": - kw_val = _resolve_contact(str(kw_val), command_name, api, coordinator) + original_kw = str(kw_val) + kw_val = _resolve_contact(original_kw, command_name, api, coordinator) if kw_val is None: - return + return _contact_error(original_kw, command_name, api) prepared_kwargs[kw_name] = kw_val else: # Space-separated format: convert string arguments by declared type @@ -656,7 +684,7 @@ async def async_execute_command_service(call: ServiceCall) -> None: if param_type == "contact": contact = _resolve_contact(arg, command_name, api, coordinator) if contact is None: - return + return _contact_error(arg, command_name, api) prepared_args.append(contact) elif param_type == "int": try: diff --git a/docs/docs/dashboard/overview.md b/docs/docs/dashboard/overview.md index d385344..378c0a7 100644 --- a/docs/docs/dashboard/overview.md +++ b/docs/docs/dashboard/overview.md @@ -99,7 +99,12 @@ Global Settings → Enable CLI Console**. That creates a `sensor.*_cli_console` entity whose `transcript` attribute holds a rolling log of the commands you run and their responses (it records only command/response pairs — it does **not** stream the radio's continuous diagnostic/noise-floor output), plus two compact -button entities on the device: **CLI Run Command** and **CLI Clear Console**. +button entities: **CLI Run Command** and **CLI Clear Console**. + +These entities are intentionally **hidden by default and not shown on the device +page** — the console only works as a dashboard card (a device page can't render +the transcript), so you reference them by entity_id in the card below. Find the +exact ids in Developer Tools → States (filter `cli`). The recommended card puts the input and the **button entities** in a single `entities` card (compact rows) so you don't get the oversized `button` *card*, @@ -125,18 +130,17 @@ cards: Notes: - Replace `YOUR_NODE` with your node's prefix (e.g. `meshcore_49d715_…`) — find - the exact ids under the device's entities or in Developer Tools → States. + the exact ids in Developer Tools → States (filter `cli`). - The markdown `content` uses a literal block (`|`), not a folded one (`>-`); a folded scalar collapses the newlines and renders the transcript on one line. - Prefer the **button entities** over a `type: button` card — the button card renders as a large full-width tile, while the entity rows are compact. -### Placing it on the right / on the device page +### Placing it on the right -The auto-generated **device page** (Settings → Devices → your node) lists these -entities automatically but isn't customizable — you can't add the markdown -transcript card there or control left/right placement. For a controlled -layout (e.g. console on the right), use a **dashboard** with a two-column +The console lives on a **dashboard**, not the auto-generated device page (a +device page can't render the transcript card or control placement). For a +console on the right, use a two-column [sections view](https://www.home-assistant.io/dashboards/sections/) or a `horizontal-stack`, and put the `vertical-stack` above in the right column. diff --git a/tests/test_execute_command.py b/tests/test_execute_command.py index e07f838..1caa3c9 100644 --- a/tests/test_execute_command.py +++ b/tests/test_execute_command.py @@ -237,3 +237,28 @@ async def test_empty_event_payload_returns_none(): result = await handler(_call("reboot")) assert result is None + + +@pytest.mark.asyncio +async def test_short_pubkey_prefix_returns_clear_error(): + """A too-short contact prefix returns a structured error, not silent None.""" + coord = _build_coordinator("req_status_sync", return_value=None, contact=None) + handler = await _get_execute_handler(coord) + + result = await handler(_call("req_status_sync ae6d")) + + assert result["error"] == "pubkey_prefix_too_short" + assert result["argument"] == "ae6d" + assert result["command"] == "req_status_sync" + + +@pytest.mark.asyncio +async def test_unknown_contact_returns_clear_error(): + """An unresolvable contact returns contact_not_found, not silent None.""" + coord = _build_coordinator("req_status_sync", return_value=None, contact=None) + handler = await _get_execute_handler(coord) + + result = await handler(_call("req_status_sync deadbeefcafe")) + + assert result["error"] == "contact_not_found" + assert result["argument"] == "deadbeefcafe" From 59ac044782102c8a19f5acd8fa58e4f3f20f5085 Mon Sep 17 00:00:00 2001 From: Drew McCalmont Date: Tue, 21 Jul 2026 21:59:35 -0400 Subject: [PATCH 5/5] cli: fold console recording into execute_command flag; keep transcript out of recorder Address PR #295 review: - Replace cli_command/cli_command_ui with a record_to_console flag on execute_command/execute_command_ui; the Run button sets the flag. Removes the parallel command API, its schema, yaml, translations, and unload entries. - Exclude history/transcript/last_command/last_response from the recorder DB and make the console sensor state a neutral command count instead of the raw command string (which could leak secrets like send_login ). Co-Authored-By: Claude Opus 4.8 (1M context) --- custom_components/meshcore/button.py | 9 +- custom_components/meshcore/const.py | 11 +- custom_components/meshcore/coordinator.py | 4 +- custom_components/meshcore/sensor.py | 52 ++-- custom_components/meshcore/services.py | 239 ++++++------------ custom_components/meshcore/services.yaml | 61 ++--- .../meshcore/translations/en.json | 34 +-- docs/docs/cli-commands.md | 12 +- docs/docs/dashboard/overview.md | 3 +- docs/docs/events.md | 5 +- docs/docs/services.md | 51 +--- tests/test_cli_command.py | 77 ++++-- 12 files changed, 230 insertions(+), 328 deletions(-) diff --git a/custom_components/meshcore/button.py b/custom_components/meshcore/button.py index 0b6e4ea..4410249 100644 --- a/custom_components/meshcore/button.py +++ b/custom_components/meshcore/button.py @@ -18,7 +18,7 @@ CONF_CLI_CONSOLE_ENABLED, DOMAIN, ENTITY_DOMAIN_BUTTON, - SERVICE_CLI_COMMAND_UI, + SERVICE_EXECUTE_COMMAND_UI, ) from .utils import format_entity_id @@ -76,8 +76,11 @@ async def async_press(self) -> None: """Execute the command in the input helper and record its output.""" await self.hass.services.async_call( DOMAIN, - SERVICE_CLI_COMMAND_UI, - {"entry_id": self.coordinator.config_entry.entry_id}, + SERVICE_EXECUTE_COMMAND_UI, + { + "entry_id": self.coordinator.config_entry.entry_id, + "record_to_console": True, + }, blocking=True, ) diff --git a/custom_components/meshcore/const.py b/custom_components/meshcore/const.py index 5db9060..612c478 100644 --- a/custom_components/meshcore/const.py +++ b/custom_components/meshcore/const.py @@ -31,8 +31,6 @@ SERVICE_SEND_CHANNEL_MESSAGE: Final = "send_channel_message" SERVICE_EXECUTE_COMMAND: Final = "execute_command" SERVICE_EXECUTE_COMMAND_UI: Final = "execute_command_ui" -SERVICE_CLI_COMMAND: Final = "cli_command" -SERVICE_CLI_COMMAND_UI: Final = "cli_command_ui" SERVICE_CLI_CLEAR: Final = "cli_console_clear" SERVICE_MESSAGE_SCRIPT: Final = "send_ui_message" SERVICE_ADD_SELECTED_CONTACT: Final = "add_selected_contact" @@ -56,6 +54,9 @@ ATTR_COMMAND: Final = "command" ATTR_ENTRY_ID: Final = "entry_id" ATTR_SCOPE: Final = "scope" +# When set on execute_command / execute_command_ui, the command/response pair is +# recorded to the CLI Console transcript and the meshcore_cli_response event fires. +ATTR_RECORD_TO_CONSOLE: Final = "record_to_console" # Platform constants PLATFORM_MESSAGE: Final = "message" @@ -132,9 +133,9 @@ def get_contact_discovery_mode(config_entry) -> str: DEFAULT_SELF_DIAGNOSTICS_INTERVAL: Final = 300 # 5 minutes in seconds # CLI console settings. An interactive command surface for the local companion -# radio: commands run through the same execute_command path, but the command -# and its response are recorded into a sensor transcript so output is visible -# in the UI (unlike execute_command_ui, which discards the response). It does +# radio: execute_command / execute_command_ui called with record_to_console: true +# record the command and its response into a sensor transcript so output is +# visible in the UI (a plain execute_command_ui discards the response). It does # NOT stream LOG_DATA / RX_LOG packet noise — only command/response pairs. CONF_CLI_CONSOLE_ENABLED: Final = "cli_console_enabled" # Number of command/response pairs kept in the rolling console transcript. diff --git a/custom_components/meshcore/coordinator.py b/custom_components/meshcore/coordinator.py index b5326fc..02ae53f 100644 --- a/custom_components/meshcore/coordinator.py +++ b/custom_components/meshcore/coordinator.py @@ -275,8 +275,8 @@ def record_cli_console( Pushes fresh state to the console sensor immediately when one is registered (CONF_CLI_CONSOLE_ENABLED). No-ops gracefully when the - console is disabled, so the cli_command service can call this - unconditionally. + console is disabled, so the execute_command record_to_console path can + call this unconditionally. """ self.cli_console_history.append({ "timestamp": int(time.time()), diff --git a/custom_components/meshcore/sensor.py b/custom_components/meshcore/sensor.py index 5ff66ed..8d2805b 100644 --- a/custom_components/meshcore/sensor.py +++ b/custom_components/meshcore/sensor.py @@ -519,8 +519,8 @@ async def async_setup_entry( entities.append(MeshCoreCompanionPrefixSensor(coordinator)) # Add the CLI console transcript sensor only when opted in (default off). - # The cli_command service records command/response pairs into this entity - # so the output is visible in the UI without a custom card. + # execute_command / execute_command_ui with record_to_console record + # command/response pairs into this entity so the output is visible in the UI. if entry.data.get(CONF_CLI_CONSOLE_ENABLED, False): entities.append(MeshCoreCLIConsoleSensor(coordinator)) @@ -1361,19 +1361,30 @@ def _format_cli_response(response: Any) -> str: class MeshCoreCLIConsoleSensor(CoordinatorEntity, SensorEntity): """Interactive CLI console transcript for the local companion radio. - Records command/response pairs produced by the ``meshcore.cli_command`` - service so output is visible in the UI — unlike ``execute_command_ui``, - which runs a command but discards its response. Created only when - CONF_CLI_CONSOLE_ENABLED is set (default off). - - State is the most recent command (so the entity badge shows activity at a - glance). The full rolling transcript lives in attributes: ``history`` is a - structured list of {timestamp, command, response, is_error}, and - ``transcript`` is a pre-rendered markdown string for a markdown card. The - transcript intentionally contains only command/response pairs — it never - streams LOG_DATA / RX_LOG packet noise. + Records command/response pairs produced by ``execute_command`` / + ``execute_command_ui`` called with ``record_to_console: true`` so output is + visible in the UI — a plain ``execute_command_ui`` runs a command but + discards its response. Created only when CONF_CLI_CONSOLE_ENABLED is set + (default off). + + State is the command count (a neutral activity indicator). The state is + always written to the recorder DB, so it must NOT be the raw command string, + which can carry secrets (e.g. ``send_login ``). The full + rolling transcript lives in attributes: ``history`` is a structured list of + {timestamp, command, response, is_error}, and ``transcript`` is a + pre-rendered markdown string for a markdown card. The transcript + intentionally contains only command/response pairs — it never streams + LOG_DATA / RX_LOG packet noise. """ + # Keep the transcript out of the recorder DB: these attributes hold the full + # rolling transcript (tens of KB per command) and the raw command/response, + # which can contain secrets. Recording them would bloat history and leak + # credentials. The state itself (command_count) stays a neutral counter. + _unrecorded_attributes = frozenset( + {"history", "transcript", "last_response", "last_command"} + ) + # Not attached to the companion device and hidden by default: the console # only works as a dashboard card (a device page can't render the transcript), # so surfacing it on the device page would be misleading. The CLI Console @@ -1407,13 +1418,14 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() @property - def native_value(self) -> str: - """Return the most recent command (truncated to the state length cap).""" - history = self.coordinator.cli_console_history - if not history: - return "ready" - # HA state values are capped at 255 chars; keep headroom. - return str(history[-1].get("command", ""))[:250] or "ready" + def native_value(self) -> int: + """Return the number of command/response pairs in the transcript. + + A neutral counter, deliberately not the raw command string: the state is + always recorded to the DB and command text can contain secrets (e.g. + ``send_login ``). + """ + return len(self.coordinator.cli_console_history) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/custom_components/meshcore/services.py b/custom_components/meshcore/services.py index 534202d..c415351 100644 --- a/custom_components/meshcore/services.py +++ b/custom_components/meshcore/services.py @@ -44,8 +44,6 @@ SERVICE_SEND_CHANNEL_MESSAGE, SERVICE_EXECUTE_COMMAND, SERVICE_EXECUTE_COMMAND_UI, - SERVICE_CLI_COMMAND, - SERVICE_CLI_COMMAND_UI, SERVICE_CLI_CLEAR, EVENT_CLI_RESPONSE, SERVICE_MESSAGE_SCRIPT, @@ -67,6 +65,7 @@ ATTR_COMMAND, ATTR_ENTRY_ID, ATTR_SCOPE, + ATTR_RECORD_TO_CONSOLE, ) from .utils import extract_pubkey_from_selection from .binary_sensor import create_contact_sensor @@ -99,6 +98,15 @@ { vol.Required(ATTR_COMMAND): cv.string, vol.Optional(ATTR_ENTRY_ID): cv.string, + vol.Optional(ATTR_RECORD_TO_CONSOLE): cv.boolean, + } +) + +# Schema for execute_command_ui (reads the text.meshcore_command helper) +EXECUTE_COMMAND_UI_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTRY_ID): cv.string, + vol.Optional(ATTR_RECORD_TO_CONSOLE): cv.boolean, } ) @@ -504,11 +512,16 @@ async def async_message_script_service(call: ServiceCall) -> None: except Exception as ex: _LOGGER.warning(f"Could not clear message input: {ex}") - async def async_execute_command_service(call: ServiceCall) -> None: - """Handle execute command service call.""" + async def _async_run_command(call: ServiceCall): + """Parse and run an execute_command call; return the normalized response. + + The command targeting / normalization core shared by execute_command and + execute_command_ui. Recording to the CLI console (record_to_console) is + layered on by async_execute_command_service, not here. + """ command_str = call.data[ATTR_COMMAND] entry_id = call.data.get(ATTR_ENTRY_ID) - + # Support both functional: cmd(arg1, kw=val) and positional: cmd arg1 arg2 functional = _parse_functional_command(command_str) if functional: @@ -923,88 +936,21 @@ async def async_execute_command_service(call: ServiceCall) -> None: return _LOGGER.error("Failed to execute command on any device: %s", command_name) - - async def async_execute_command_ui_service(call: ServiceCall) -> None: - """Execute command from the text helper entity.""" - entry_id = call.data.get(ATTR_ENTRY_ID) - - # Get command from command text entity - command_entity = hass.states.get("text.meshcore_command") - if not command_entity: - _LOGGER.error("Command input helper not found: text.meshcore_command") - return - - command = command_entity.state - - if not command: - _LOGGER.warning("No command to execute - command input is empty") - return - - # Create command service call - command_call = create_service_call( - DOMAIN, - SERVICE_EXECUTE_COMMAND, - {"command": command, "entry_id": entry_id} - ) - - # Execute the command - await async_execute_command_service(command_call) - - # Clear the command input after execution - try: - await hass.services.async_call( - "text", - "set_value", - {"entity_id": "text.meshcore_command", "value": ""}, - blocking=False - ) - except Exception as ex: - _LOGGER.warning(f"Could not clear command input: {ex}") - - def _resolve_console_coordinator(entry_id: "str | None") -> Any: - """Pick the coordinator a CLI console command should record against. - - Mirrors execute_command's target selection: the entry_id coordinator - when specified, otherwise the first connected one. Returns None when no - suitable coordinator is found. - """ - first_connected = None - for config_entry_id, coordinator in hass.data[DOMAIN].items(): - if not hasattr(coordinator, "api"): - continue - if entry_id and entry_id != config_entry_id: - continue - if entry_id: - return coordinator - api = coordinator.api - if first_connected is None and api and api.connected: - first_connected = coordinator - return first_connected - async def async_cli_command_service(call: ServiceCall): - """Run a CLI command and record its output to the console transcript. + def _record_cli_console(command_str: str, response: Any, entry_id: "str | None") -> None: + """Record a command/response pair to the console and fire the event. - Thin wrapper over execute_command: it reuses the exact command parsing - and execution path, then records the command/response pair to the - console sensor (when CONF_CLI_CONSOLE_ENABLED) and fires the - EVENT_CLI_RESPONSE event so the result is visible in the UI and - available to automations. Returns the same response as execute_command. + Shared by execute_command / execute_command_ui when record_to_console is + set. _async_run_command returns None on total failure (no connected + device / unknown command) and an {"error": ...} dict for explicit + no-response; both count as errors in the transcript. """ - command_str = call.data[ATTR_COMMAND] - entry_id = call.data.get(ATTR_ENTRY_ID) - - response = await async_execute_command_service(call) - - # execute_command returns None on total failure (no connected device / - # unknown command) and an {"error": ...} dict for explicit no-response. is_error = response is None or ( isinstance(response, dict) and "error" in response ) - coordinator = _resolve_console_coordinator(entry_id) if coordinator is not None: coordinator.record_cli_console(command_str, response, is_error) - hass.bus.async_fire(EVENT_CLI_RESPONSE, { "command": command_str, "response": response, @@ -1013,45 +959,90 @@ async def async_cli_command_service(call: ServiceCall): "timestamp": int(time.time()), }) + async def async_execute_command_service(call: ServiceCall): + """Handle execute command service call. + + Runs the command and returns its normalized response. When + record_to_console is set, the command/response pair is also appended to + the CLI Console transcript and a meshcore_cli_response event is fired so + the output is visible in the UI. + """ + response = await _async_run_command(call) + if call.data.get(ATTR_RECORD_TO_CONSOLE): + _record_cli_console( + call.data[ATTR_COMMAND], response, call.data.get(ATTR_ENTRY_ID) + ) return response - async def async_cli_command_ui_service(call: ServiceCall): - """Run the command from text.meshcore_command via the CLI console. + async def async_execute_command_ui_service(call: ServiceCall): + """Execute command from the text helper entity. - Like execute_command_ui, but routes through cli_command so the response - is captured in the console transcript instead of being discarded. The - command input is cleared after execution. + Reads text.meshcore_command, runs it, and clears the input. Passes + record_to_console through so the CLI Console Run button (which sets the + flag) captures the response in the transcript. """ entry_id = call.data.get(ATTR_ENTRY_ID) + record_to_console = call.data.get(ATTR_RECORD_TO_CONSOLE, False) + # Get command from command text entity command_entity = hass.states.get("text.meshcore_command") if not command_entity: _LOGGER.error("Command input helper not found: text.meshcore_command") return + command = command_entity.state + if not command: _LOGGER.warning("No command to execute - command input is empty") return + # Create command service call command_call = create_service_call( DOMAIN, - SERVICE_CLI_COMMAND, - {"command": command, "entry_id": entry_id}, + SERVICE_EXECUTE_COMMAND, + { + "command": command, + "entry_id": entry_id, + "record_to_console": record_to_console, + } ) - response = await async_cli_command_service(command_call) + # Execute the command (records to the console when the flag is set) + response = await async_execute_command_service(command_call) + + # Clear the command input after execution try: await hass.services.async_call( "text", "set_value", {"entity_id": "text.meshcore_command", "value": ""}, - blocking=False, + blocking=False ) except Exception as ex: _LOGGER.warning(f"Could not clear command input: {ex}") return response + def _resolve_console_coordinator(entry_id: "str | None") -> Any: + """Pick the coordinator a CLI console command should record against. + + Mirrors execute_command's target selection: the entry_id coordinator + when specified, otherwise the first connected one. Returns None when no + suitable coordinator is found. + """ + first_connected = None + for config_entry_id, coordinator in hass.data[DOMAIN].items(): + if not hasattr(coordinator, "api"): + continue + if entry_id and entry_id != config_entry_id: + continue + if entry_id: + return coordinator + api = coordinator.api + if first_connected is None and api and api.connected: + first_connected = coordinator + return first_connected + async def async_cli_clear_service(call: ServiceCall) -> None: """Clear the CLI console transcript. @@ -1094,29 +1085,12 @@ async def async_cli_clear_service(call: ServiceCall) -> None: DOMAIN, SERVICE_EXECUTE_COMMAND_UI, async_execute_command_ui_service, - schema=UI_MESSAGE_SCHEMA, - ) - - # Register the CLI console services. cli_command mirrors execute_command - # but records the command/response pair into the CLI console transcript - # sensor so the output is visible in the UI; cli_command_ui drives it from - # the text.meshcore_command input helper. - hass.services.async_register( - DOMAIN, - SERVICE_CLI_COMMAND, - async_cli_command_service, - schema=EXECUTE_COMMAND_SCHEMA, - supports_response=SupportsResponse.OPTIONAL, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_CLI_COMMAND_UI, - async_cli_command_ui_service, - schema=UI_MESSAGE_SCHEMA, + schema=EXECUTE_COMMAND_UI_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) + # cli_console_clear empties the CLI Console transcript. The console itself is + # populated by execute_command / execute_command_ui with record_to_console. hass.services.async_register( DOMAIN, SERVICE_CLI_CLEAR, @@ -1901,51 +1875,6 @@ async def async_trace_service(call: ServiceCall) -> dict: supports_response=SupportsResponse.ONLY, ) - # Create CLI command execution service from UI helper - # async def async_execute_cli_command_ui(call: ServiceCall) -> None: - # """Execute CLI command from the text helper entity.""" - # entry_id = call.data.get(ATTR_ENTRY_ID) - - # # Get command from CLI command text entity - # cli_command_entity = hass.states.get("text.meshcore_cli_command") - # if not cli_command_entity: - # _LOGGER.error("CLI command input helper not found: text.meshcore_cli_command") - # return - - # command = cli_command_entity.state - - # if not command: - # _LOGGER.warning("No command to execute - CLI input is empty") - # return - - # # Create CLI command service call - # cli_call = create_service_call( - # DOMAIN, - # SERVICE_CLI_COMMAND, - # {"command": command, "entry_id": entry_id} - # ) - - # # Execute the CLI command - # await async_cli_command_service(cli_call) - - # # Clear the command input after execution - # try: - # await hass.services.async_call( - # "text", - # "set_value", - # {"entity_id": "text.meshcore_cli_command", "value": ""}, - # blocking=False - # ) - # except Exception as ex: - # _LOGGER.warning(f"Could not clear CLI command input: {ex}") - - # Register the CLI command execution service - # hass.services.async_register( - # DOMAIN, - # SERVICE_EXECUTE_CLI_COMMAND_UI, - # async_execute_cli_command_ui, - # schema=UI_MESSAGE_SCHEMA, - # ) async def async_unload_services(hass: HomeAssistant) -> None: """Unload MeshCore services.""" @@ -1955,12 +1884,6 @@ async def async_unload_services(hass: HomeAssistant) -> None: if hass.services.has_service(DOMAIN, SERVICE_SEND_CHANNEL_MESSAGE): hass.services.async_remove(DOMAIN, SERVICE_SEND_CHANNEL_MESSAGE) - if hass.services.has_service(DOMAIN, SERVICE_CLI_COMMAND): - hass.services.async_remove(DOMAIN, SERVICE_CLI_COMMAND) - - if hass.services.has_service(DOMAIN, SERVICE_CLI_COMMAND_UI): - hass.services.async_remove(DOMAIN, SERVICE_CLI_COMMAND_UI) - if hass.services.has_service(DOMAIN, SERVICE_CLI_CLEAR): hass.services.async_remove(DOMAIN, SERVICE_CLI_CLEAR) diff --git a/custom_components/meshcore/services.yaml b/custom_components/meshcore/services.yaml index 8ee0c8d..db189a4 100644 --- a/custom_components/meshcore/services.yaml +++ b/custom_components/meshcore/services.yaml @@ -114,6 +114,17 @@ execute_command: example: "abc123def456" selector: text: + record_to_console: + name: Record to CLI Console + description: >- + Record the command and its response to the CLI Console transcript and fire the + meshcore_cli_response event, so the output is visible in the UI. Requires the + CLI Console to be enabled in Global Settings. Defaults to false. + required: false + example: true + default: false + selector: + boolean: execute_command_ui: name: Execute Command from UI @@ -129,51 +140,17 @@ execute_command_ui: example: "abc123def456" selector: text: - -cli_command: - name: CLI Command - description: >- - Run a CLI command against the local companion radio and record the command and - its response to the CLI Console sensor so the output is visible in the UI. - Same syntax and capabilities as Execute MeshCore Command, plus a visible transcript. - Enable the CLI Console in Global Settings to see the output entity. - CAUTION: Some commands will make PERMANENT CHANGES to your node's configuration. Use with care. - fields: - command: - name: Command + record_to_console: + name: Record to CLI Console description: >- - The command with parameters to run. Format is "[command_name] [param1] [param2] ..." - Examples: - - get_bat - - get_time - - send_advert - - set_tx_power 20 - - get_stats_radio - required: true - example: "get_bat" - selector: - text: - entry_id: - name: Config Entry ID - description: The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device. + Record the command and its response to the CLI Console transcript and fire the + meshcore_cli_response event, so the output is visible in the UI. Requires the + CLI Console to be enabled in Global Settings. Defaults to false. required: false - example: "abc123def456" + example: true + default: false selector: - text: - -cli_command_ui: - name: CLI Command from UI - description: >- - Run the command in the text.meshcore_command helper entity through the CLI Console - (so the response is recorded to the console transcript) and clear the input field afterwards. - fields: - entry_id: - name: Config Entry ID - description: The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device. - required: false - example: "abc123def456" - selector: - text: + boolean: cli_console_clear: name: Clear CLI Console diff --git a/custom_components/meshcore/translations/en.json b/custom_components/meshcore/translations/en.json index f0863f1..1f5d65a 100644 --- a/custom_components/meshcore/translations/en.json +++ b/custom_components/meshcore/translations/en.json @@ -176,7 +176,7 @@ "data_description": { "self_diagnostics_enabled": "Creates roughly 15 local diagnostic sensors for the companion node (uptime, TX queue length, noise floor, last RSSI/SNR, TX/RX airtime, and packet counters). Off by default. Adds no mesh traffic — these are local queries to the attached radio, not mesh requests. The interval controls how often the sensors refresh (60–3600 seconds).", "contact_discovery_mode": "Entity per contact: every discovered contact gets its own entity (best for small meshes). Data only: discovered contacts are tracked as data only with no per-contact entity -- inspect them via the dropdown or the Get Discovered Contact service. Disabled: contact discovery is turned off entirely and any existing discovered contacts are cleared.", - "cli_console_enabled": "Adds a CLI Console sensor that records the commands you run via the meshcore.cli_command service and their responses, so the output is visible in the UI (the existing command card runs commands but discards the output). It shows only command/response pairs — it does not stream the device's continuous diagnostic/noise-floor log. Off by default. Pair it with the text.meshcore_command input helper and a markdown card; see the docs for an example dashboard.", + "cli_console_enabled": "Adds a CLI Console sensor that records the commands you run via execute_command / execute_command_ui with record_to_console enabled and their responses, so the output is visible in the UI (a plain command card runs commands but discards the output). It shows only command/response pairs — it does not stream the device's continuous diagnostic/noise-floor log. Off by default. Pair it with the text.meshcore_command input helper and a markdown card; see the docs for an example dashboard.", "map_upload_enabled": "When enabled, adverts from repeaters and room servers you receive are uploaded to map.meshcore.io. Those nodes appear on the official MeshCore map for the community. Requires private key export enabled on the firmware (`ENABLE_PRIVATE_KEY_EXPORT=1`); without it, uploads cannot be signed.", "auto_cleanup_stale_contacts": "When enabled, discovered contacts whose last update is older than the configured threshold are automatically removed once per day. Contacts saved to the node are always preserved.", "auto_cleanup_stale_neighbors": "Automatically remove neighbor entries and their sensor entities when they haven't been heard from in the configured number of days.", @@ -307,7 +307,7 @@ "data_description": { "self_diagnostics_enabled": "Creates roughly 15 local diagnostic sensors for the companion node (uptime, TX queue length, noise floor, last RSSI/SNR, TX/RX airtime, and packet counters). Off by default. Adds no mesh traffic — these are local queries to the attached radio, not mesh requests. The interval controls how often the sensors refresh (60–3600 seconds).", "contact_discovery_mode": "Entity per contact: every discovered contact gets its own entity (best for small meshes). Data only: discovered contacts are tracked as data only with no per-contact entity -- inspect them via the dropdown or the Get Discovered Contact service. Disabled: contact discovery is turned off entirely and any existing discovered contacts are cleared.", - "cli_console_enabled": "Adds a CLI Console sensor that records the commands you run via the meshcore.cli_command service and their responses, so the output is visible in the UI (the existing command card runs commands but discards the output). It shows only command/response pairs — it does not stream the device's continuous diagnostic/noise-floor log. Off by default. Pair it with the text.meshcore_command input helper and a markdown card; see the docs for an example dashboard.", + "cli_console_enabled": "Adds a CLI Console sensor that records the commands you run via execute_command / execute_command_ui with record_to_console enabled and their responses, so the output is visible in the UI (a plain command card runs commands but discards the output). It shows only command/response pairs — it does not stream the device's continuous diagnostic/noise-floor log. Off by default. Pair it with the text.meshcore_command input helper and a markdown card; see the docs for an example dashboard.", "map_upload_enabled": "When enabled, adverts from repeaters and room servers you receive are uploaded to map.meshcore.io. Those nodes appear on the official MeshCore map for the community. Requires private key export enabled on the firmware (`ENABLE_PRIVATE_KEY_EXPORT=1`); without it, uploads cannot be signed.", "auto_cleanup_stale_contacts": "When enabled, discovered contacts whose last update is older than the configured threshold are automatically removed once per day. Contacts saved to the node are always preserved.", "auto_cleanup_stale_neighbors": "Automatically remove neighbor entries and their sensor entities when they haven't been heard from in the configured number of days.", @@ -410,6 +410,10 @@ "entry_id": { "name": "Config Entry ID", "description": "The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device." + }, + "record_to_console": { + "name": "Record to CLI Console", + "description": "Record the command and its response to the CLI Console transcript and fire the meshcore_cli_response event, so the output is visible in the UI. Requires the CLI Console to be enabled in Global Settings. Defaults to false." } } }, @@ -420,30 +424,10 @@ "entry_id": { "name": "Config Entry ID", "description": "The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device." - } - } - }, - "cli_command": { - "name": "CLI Command", - "description": "Run a CLI command against the local companion radio and record the command and its response to the CLI Console sensor so the output is visible in the UI. Same command syntax and capabilities as Execute MeshCore Command, plus a visible transcript. Enable the CLI Console in Global Settings to see the output entity. CAUTION: some commands make PERMANENT changes to your node's configuration.", - "fields": { - "command": { - "name": "Command", - "description": "The command with parameters to run. Format is \"[command_name] [param1] [param2] ...\" Examples: - get_bat - get_time - send_advert - set_tx_power 20 - get_stats_radio" }, - "entry_id": { - "name": "Config Entry ID", - "description": "The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device." - } - } - }, - "cli_command_ui": { - "name": "CLI Command from UI", - "description": "Run the command in the text.meshcore_command helper entity through the CLI Console (so the response is recorded to the console transcript) and clear the input field afterwards.", - "fields": { - "entry_id": { - "name": "Config Entry ID", - "description": "The config entry ID if you have multiple MeshCore devices. Leave empty to use the first available device." + "record_to_console": { + "name": "Record to CLI Console", + "description": "Record the command and its response to the CLI Console transcript and fire the meshcore_cli_response event, so the output is visible in the UI. Requires the CLI Console to be enabled in Global Settings. Defaults to false." } } }, diff --git a/docs/docs/cli-commands.md b/docs/docs/cli-commands.md index 0808644..d111da1 100644 --- a/docs/docs/cli-commands.md +++ b/docs/docs/cli-commands.md @@ -5,9 +5,8 @@ title: CLI Command Reference # CLI Command Reference -The `meshcore.cli_command` and `meshcore.execute_command` services (and the CLI -Console card) run commands against your **local companion radio**. This page -lists what you can type. +The `meshcore.execute_command` service (and the CLI Console card) runs commands +against your **local companion radio**. This page lists what you can type. ## Which "CLI" is this? @@ -15,7 +14,7 @@ MeshCore has several command vocabularies — they are **not** interchangeable: | Vocabulary | Where | Example | |---|---|---| -| **This CLI** (meshcore-py SDK methods) | HA `cli_command` / `execute_command` / CLI Console | `send_device_query`, `get_bat` | +| **This CLI** (meshcore-py SDK methods) | HA `execute_command` / CLI Console | `send_device_query`, `get_bat` | | Companion app (iOS/Android) | The phone GUI | buttons/screens (same protocol underneath) | | `meshcli` (meshcore-cli) | A separate terminal tool | `infos`, `advert`, `send` | | Repeater / room-server CLI | Sent to a **remote** repeater after `send_login` | `reboot`, `set freq …` | @@ -34,8 +33,9 @@ modules — every `async def (...)` is a command. name — used for commands that talk to a *remote* node. - Functional form also works: `send_advert(flood=True)`. -Responses appear in the CLI Console transcript, in the Developer Tools → Actions -result, and (for `cli_command`) on the `meshcore_cli_response` event. +Responses appear in the Developer Tools → Actions result, and — when the command +is run with `record_to_console: true` — in the CLI Console transcript and on the +`meshcore_cli_response` event. :::warning Commands prefixed `set_*`, `import_*`, `reboot`, and `send_advert` **change your diff --git a/docs/docs/dashboard/overview.md b/docs/docs/dashboard/overview.md index 378c0a7..88022c8 100644 --- a/docs/docs/dashboard/overview.md +++ b/docs/docs/dashboard/overview.md @@ -148,9 +148,10 @@ You can also call the services directly with a command, e.g. from an automation or script: ```yaml -action: meshcore.cli_command +action: meshcore.execute_command data: command: get_stats_radio + record_to_console: true ``` ### Network Map diff --git a/docs/docs/events.md b/docs/docs/events.md index 4acd351..76eb9ae 100644 --- a/docs/docs/events.md +++ b/docs/docs/events.md @@ -335,8 +335,9 @@ action: ## CLI Console Events ### meshcore_cli_response -Fired after a `meshcore.cli_command` (or `cli_command_ui`) call completes. Use -it to react to CLI output in automations without polling the console sensor. +Fired after an `execute_command` (or `execute_command_ui`) call made with +`record_to_console: true` completes. Use it to react to CLI output in +automations without polling the console sensor. **Event data:** diff --git a/docs/docs/services.md b/docs/docs/services.md index db7b788..bbb5179 100644 --- a/docs/docs/services.md +++ b/docs/docs/services.md @@ -91,6 +91,7 @@ Execute Meshcore SDK commands directly for advanced control. |-------|------|----------|-------------| | `command` | string | Yes | Command with parameters | | `entry_id` | string | No | Config entry ID for multiple devices | +| `record_to_console` | boolean | No | Record the command/response to the CLI Console and fire `meshcore_cli_response` (default `false`) | **Common Commands:** @@ -169,6 +170,7 @@ Execute commands from the UI text input helper. | Field | Type | Required | Description | |-------|------|----------|-------------| | `entry_id` | string | No | Config entry ID for multiple devices | +| `record_to_console` | boolean | No | Record the command/response to the CLI Console and fire `meshcore_cli_response` (default `false`) | This service reads from `text.meshcore_command` and clears it after execution. @@ -179,54 +181,29 @@ service: meshcore.execute_command_ui data: {} ``` -### CLI Command -Run a command against the local companion radio **and record the command and -its response to the CLI Console sensor**, so the output is visible in the UI -(unlike `execute_command_ui`, which discards the response). Same command syntax -and capabilities as `execute_command`. Enable the CLI Console under **Global -Settings → Enable CLI Console** to get the `sensor.*_cli_console` output entity. -The console records only command/response pairs — it does not stream the -radio's continuous diagnostic/noise-floor log. +### CLI Console +Pass `record_to_console: true` to `execute_command` (or `execute_command_ui`) to +**record the command and its response to the CLI Console sensor**, so the output +is visible in the UI (without the flag the response is only returned to the +caller and, for `execute_command_ui`, discarded). Enable the CLI Console under +**Global Settings → Enable CLI Console** to get the `sensor.*_cli_console` +output entity. The console records only command/response pairs — it does not +stream the radio's continuous diagnostic/noise-floor log. > See the [CLI Command Reference](./cli-commands) for the full list of commands > and their arguments. -**Service:** `meshcore.cli_command` - -**Fields:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `command` | string | Yes | Command with parameters, e.g. `get_bat`, `get_stats_radio`, `set_tx_power 20` | -| `entry_id` | string | No | Config entry ID for multiple devices | - **Example:** ```yaml -service: meshcore.cli_command +service: meshcore.execute_command data: command: get_stats_radio + record_to_console: true ``` -### CLI Command UI -Run the command in `text.meshcore_command` through the CLI Console (so the -response is recorded to the transcript) and clear the input afterwards. This is -the CLI-Console equivalent of `execute_command_ui`. - -**Service:** `meshcore.cli_command_ui` - -**Fields:** - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `entry_id` | string | No | Config entry ID for multiple devices | - -**Example:** - -```yaml -service: meshcore.cli_command_ui -data: {} -``` +Use `meshcore.cli_console_clear` to empty the transcript (no `entry_id` clears +every configured device's console). ## Usage in Automations diff --git a/tests/test_cli_command.py b/tests/test_cli_command.py index 61664bd..d7c3dea 100644 --- a/tests/test_cli_command.py +++ b/tests/test_cli_command.py @@ -1,9 +1,12 @@ -"""Tests for the CLI console services (cli_command / cli_command_ui) in services.py. +"""Tests for the CLI console recording folded into execute_command / _ui. -The CLI console services wrap execute_command and additionally: +execute_command and execute_command_ui accept a ``record_to_console`` flag. +When set, they additionally: * record the command/response pair to the resolved coordinator's console * fire the EVENT_CLI_RESPONSE event - * return the same response execute_command produces + * (unchanged) return the same response execute_command produces + +Without the flag they behave as plain command runners and do neither. These tests reuse the module-loading approach from test_execute_command.py: conftest stubs meshcore/const/homeassistant, so services.py is loaded directly @@ -41,14 +44,15 @@ class _ET: _module.__package__ = "custom_components.meshcore" _spec.loader.exec_module(_module) -# create_service_call (used by cli_command_ui) branches on MAJOR_VERSION, which -# conftest leaves as a MagicMock; pin it so the comparison works under test. +# create_service_call (used by execute_command_ui) branches on MAJOR_VERSION, +# which conftest leaves as a MagicMock; pin it so the comparison works. _module.MAJOR_VERSION = 2025 async_setup_services = _module.async_setup_services DOMAIN = _module.DOMAIN ATTR_COMMAND = _module.ATTR_COMMAND ATTR_ENTRY_ID = _module.ATTR_ENTRY_ID +ATTR_RECORD_TO_CONSOLE = _module.ATTR_RECORD_TO_CONSOLE EVENT_CLI_RESPONSE = _module.EVENT_CLI_RESPONSE @@ -96,19 +100,23 @@ def _register(domain, service_const, handler, **kwargs): return hass, registered -def _call(command, entry_id=None): +def _call(command, entry_id=None, record=True): call = MagicMock() - call.data = {ATTR_COMMAND: command, ATTR_ENTRY_ID: entry_id} + call.data = { + ATTR_COMMAND: command, + ATTR_ENTRY_ID: entry_id, + ATTR_RECORD_TO_CONSOLE: record, + } return call @pytest.mark.asyncio -async def test_cli_command_records_and_returns_response(): - """cli_command returns the execute_command response and records it.""" +async def test_record_flag_records_and_returns_response(): + """record_to_console records the pair and still returns the response.""" payload = {"level": 4100, "status": "ok"} coord = _build_coordinator("get_bat", _Event(_ET.MSG_SENT, payload)) hass, registered = await _setup(coord) - handler = registered["async_cli_command_service"][0] + handler = registered["async_execute_command_service"][0] result = await handler(_call("get_bat")) @@ -121,12 +129,12 @@ async def test_cli_command_records_and_returns_response(): @pytest.mark.asyncio -async def test_cli_command_fires_event(): - """cli_command fires EVENT_CLI_RESPONSE with the command and response.""" +async def test_record_flag_fires_event(): + """record_to_console fires EVENT_CLI_RESPONSE with the command and response.""" payload = {"ok": True} coord = _build_coordinator("get_time", _Event(_ET.MSG_SENT, payload)) hass, registered = await _setup(coord) - handler = registered["async_cli_command_service"][0] + handler = registered["async_execute_command_service"][0] await handler(_call("get_time")) @@ -139,12 +147,27 @@ async def test_cli_command_fires_event(): @pytest.mark.asyncio -async def test_cli_command_marks_error_on_no_response(): +async def test_no_flag_does_not_record_or_fire(): + """Without record_to_console, execute_command records nothing and is silent.""" + payload = {"ok": True} + coord = _build_coordinator("get_time", _Event(_ET.MSG_SENT, payload)) + hass, registered = await _setup(coord) + handler = registered["async_execute_command_service"][0] + + result = await handler(_call("get_time", record=False)) + + assert result == payload + coord.record_cli_console.assert_not_called() + hass.bus.async_fire.assert_not_called() + + +@pytest.mark.asyncio +async def test_record_flag_marks_error_on_no_response(): """A None response (e.g. unknown/failed command) records is_error=True.""" # An unknown command makes execute_command return None without running. coord = _build_coordinator("get_bat", _Event(_ET.MSG_SENT, {"x": 1})) hass, registered = await _setup(coord) - handler = registered["async_cli_command_service"][0] + handler = registered["async_execute_command_service"][0] result = await handler(_call("definitely_not_a_command")) @@ -155,12 +178,12 @@ async def test_cli_command_marks_error_on_no_response(): @pytest.mark.asyncio -async def test_cli_command_ui_reads_text_helper(): - """cli_command_ui pulls the command from text.meshcore_command and runs it.""" +async def test_command_ui_records_when_flag_set(): + """execute_command_ui pulls the command from the helper and records it.""" payload = {"done": True} coord = _build_coordinator("send_advert", _Event(_ET.MSG_SENT, payload)) hass, registered = await _setup(coord) - handler = registered["async_cli_command_ui_service"][0] + handler = registered["async_execute_command_ui_service"][0] state = MagicMock() state.state = "send_advert" @@ -169,16 +192,14 @@ async def test_cli_command_ui_reads_text_helper(): # create_service_call wraps homeassistant.core.ServiceCall, which conftest # mocks (its .data is not the dict we pass). Stub it to a plain call object - # so the delegated cli_command sees the real command string. + # so the delegated execute_command sees the real command string and flag. def _fake_call(domain, service, data=None, hass=None): - # The UI handler builds data with literal "command"/"entry_id" keys; - # the cli_command handler reads the (mocked) ATTR_* constants. Remap so - # the delegated handler finds the command string. data = data or {} c = MagicMock() c.data = { ATTR_COMMAND: data.get("command"), ATTR_ENTRY_ID: data.get("entry_id"), + ATTR_RECORD_TO_CONSOLE: data.get("record_to_console"), } return c @@ -186,7 +207,7 @@ def _fake_call(domain, service, data=None, hass=None): _module.create_service_call = _fake_call try: ui_call = MagicMock() - ui_call.data = {ATTR_ENTRY_ID: None} + ui_call.data = {ATTR_ENTRY_ID: None, ATTR_RECORD_TO_CONSOLE: True} result = await handler(ui_call) finally: _module.create_service_call = monkeypatched @@ -198,17 +219,19 @@ def _fake_call(domain, service, data=None, hass=None): @pytest.mark.asyncio -async def test_cli_command_ui_noop_on_empty_input(): - """cli_command_ui does nothing when the text helper is empty.""" +async def test_command_ui_noop_on_empty_input(): + """execute_command_ui does nothing when the text helper is empty.""" coord = _build_coordinator("get_bat", _Event(_ET.MSG_SENT, {"x": 1})) hass, registered = await _setup(coord) - handler = registered["async_cli_command_ui_service"][0] + handler = registered["async_execute_command_ui_service"][0] state = MagicMock() state.state = "" hass.states.get = MagicMock(return_value=state) - result = await handler(MagicMock(data={ATTR_ENTRY_ID: None})) + result = await handler( + MagicMock(data={ATTR_ENTRY_ID: None, ATTR_RECORD_TO_CONSOLE: True}) + ) assert result is None coord.record_cli_console.assert_not_called()