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..4410249 --- /dev/null +++ b/custom_components/meshcore/button.py @@ -0,0 +1,104 @@ +"""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_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_CLI_CONSOLE_ENABLED, + DOMAIN, + ENTITY_DOMAIN_BUTTON, + SERVICE_EXECUTE_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. + + 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 + + +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_EXECUTE_COMMAND_UI, + { + "entry_id": self.coordinator.config_entry.entry_id, + "record_to_console": True, + }, + 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/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..612c478 100644 --- a/custom_components/meshcore/const.py +++ b/custom_components/meshcore/const.py @@ -31,6 +31,7 @@ SERVICE_SEND_CHANNEL_MESSAGE: Final = "send_channel_message" SERVICE_EXECUTE_COMMAND: Final = "execute_command" SERVICE_EXECUTE_COMMAND_UI: Final = "execute_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" @@ -53,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" @@ -60,6 +64,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" @@ -127,6 +132,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: 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. +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..02ae53f 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,39 @@ 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 execute_command record_to_console path 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 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/sensor.py b/custom_components/meshcore/sensor.py index cd8b975..8d2805b 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). + # 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)) + # Store the async_add_entities function for later use coordinator.sensor_add_entities = async_add_entities @@ -1334,6 +1342,120 @@ 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 ``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 + # 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" + + 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 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]: + """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..c415351 100644 --- a/custom_components/meshcore/services.py +++ b/custom_components/meshcore/services.py @@ -44,6 +44,8 @@ SERVICE_SEND_CHANNEL_MESSAGE, SERVICE_EXECUTE_COMMAND, SERVICE_EXECUTE_COMMAND_UI, + SERVICE_CLI_CLEAR, + EVENT_CLI_RESPONSE, SERVICE_MESSAGE_SCRIPT, SERVICE_ADD_SELECTED_CONTACT, SERVICE_REMOVE_SELECTED_CONTACT, @@ -63,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 @@ -95,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, } ) @@ -161,6 +173,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. @@ -473,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: @@ -628,7 +672,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) @@ -637,13 +681,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 @@ -652,7 +697,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: @@ -891,44 +936,127 @@ 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.""" + + 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. + + 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. + """ + 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()), + }) + + 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_execute_command_ui_service(call: ServiceCall): + """Execute command from the text helper entity. + + 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_EXECUTE_COMMAND, - {"command": command, "entry_id": entry_id} + DOMAIN, + SERVICE_EXECUTE_COMMAND, + { + "command": command, + "entry_id": entry_id, + "record_to_console": record_to_console, + } ) - - # Execute the command - await async_execute_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", + "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 + + 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. + + 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, @@ -957,16 +1085,19 @@ async def async_execute_command_ui_service(call: ServiceCall) -> None: DOMAIN, SERVICE_EXECUTE_COMMAND_UI, async_execute_command_ui_service, + 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, + async_cli_clear_service, schema=UI_MESSAGE_SCHEMA, ) - - # hass.services.async_register( - # DOMAIN, - # SERVICE_CLI_COMMAND, - # async_cli_command_service, - # schema=CLI_COMMAND_SCHEMA, - # ) - + # Register the combined UI message service hass.services.async_register( DOMAIN, @@ -1744,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.""" @@ -1798,8 +1884,8 @@ 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_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 011ba68..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,6 +140,30 @@ execute_command_ui: 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: + +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 diff --git a/custom_components/meshcore/translations/en.json b/custom_components/meshcore/translations/en.json index 06ad7d3..1f5d65a 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 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.", @@ -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 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.", @@ -406,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." } } }, @@ -416,6 +424,20 @@ "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." + } + } + }, + "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." } } }, diff --git a/docs/docs/cli-commands.md b/docs/docs/cli-commands.md new file mode 100644 index 0000000..d111da1 --- /dev/null +++ b/docs/docs/cli-commands.md @@ -0,0 +1,124 @@ +--- +sidebar_position: 4 +title: CLI Command Reference +--- + +# CLI Command Reference + +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? + +MeshCore has several command vocabularies — they are **not** interchangeable: + +| Vocabulary | Where | Example | +|---|---|---| +| **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 …` | + +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 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 +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 52e72ab..88022c8 100644 --- a/docs/docs/dashboard/overview.md +++ b/docs/docs/dashboard/overview.md @@ -86,6 +86,74 @@ 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. 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` +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: **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*, +then renders the transcript with a markdown card: + +```yaml +type: vertical-stack +cards: + - type: entities + entities: + - entity: text.meshcore_command + name: Command + - entity: button.YOUR_NODE_cli_run + name: Run + - entity: button.YOUR_NODE_cli_clear + name: Clear + - type: markdown + content: | + ``` + {{ state_attr('sensor.YOUR_NODE_cli_console', 'transcript') }} + ``` +``` + +Notes: +- Replace `YOUR_NODE` with your node's prefix (e.g. `meshcore_49d715_…`) — find + 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 + +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. + +You can also call the services directly with a command, e.g. from an automation +or script: + +```yaml +action: meshcore.execute_command +data: + command: get_stats_radio + record_to_console: true +``` + ### 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..76eb9ae 100644 --- a/docs/docs/events.md +++ b/docs/docs/events.md @@ -332,6 +332,37 @@ action: message: "Battery: {{ (trigger.event.data.payload.level / 1000) | round(2) }}V" ``` +## CLI Console Events + +### meshcore_cli_response +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:** + +- `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..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,6 +181,30 @@ service: meshcore.execute_command_ui data: {} ``` +### 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. + +**Example:** + +```yaml +service: meshcore.execute_command +data: + command: get_stats_radio + record_to_console: true +``` + +Use `meshcore.cli_console_clear` to empty the transcript (no `entry_id` clears +every configured device's console). + ## 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..d7c3dea --- /dev/null +++ b/tests/test_cli_command.py @@ -0,0 +1,249 @@ +"""Tests for the CLI console recording folded into execute_command / _ui. + +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 + * (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 +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 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 + + +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, record=True): + call = MagicMock() + call.data = { + ATTR_COMMAND: command, + ATTR_ENTRY_ID: entry_id, + ATTR_RECORD_TO_CONSOLE: record, + } + return call + + +@pytest.mark.asyncio +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_execute_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_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_execute_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_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_execute_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_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_execute_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 execute_command sees the real command string and flag. + def _fake_call(domain, service, data=None, hass=None): + 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 + + monkeypatched = _module.create_service_call + _module.create_service_call = _fake_call + try: + ui_call = MagicMock() + ui_call.data = {ATTR_ENTRY_ID: None, ATTR_RECORD_TO_CONSOLE: True} + 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_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_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, ATTR_RECORD_TO_CONSOLE: True}) + ) + + 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() 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"