From 90b6d6e6778f4f1242da737b8754fa92954051c0 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 25 Oct 2026 14:28:15 +0000 Subject: [PATCH] tsk-sp4nji: Add ESPHome orb bridge adapter RED-FIRST proof (failing run, fix not yet applied, tests/orb/test_bridge_esphome.py): ``` FAILED tests/orb/test_bridge_esphome.py::test_esphome_installed_and_target - ImportError: cannot import name 'esphome' from 'tinyagentos.orb.bridges' FAILED tests/orb/test_bridge_esphome.py::test_esphome_not_installed_target_still_present - ImportError: cannot import name 'esphome' from 'tinyagentos.orb.bridges' FAILED tests/orb/test_bridge_esphome.py::test_esphome_ignores_mimiclaw_orb - ImportError: cannot import name 'esphome' from 'tinyagentos.orb.bridges' FAILED tests/orb/test_bridge_esphome.py::test_esphome_ipv6_address - ImportError: cannot import name 'esphome' from 'tinyagentos.orb.bridges' FAILED tests/orb/test_bridge_esphome.py::test_esphome_empty_input - ImportError: cannot import name 'esphome' from 'tinyagentos.orb.bridges' FAILED tests/orb/test_bridge_esphome.py::test_esphome_constants - ImportError: cannot import name 'esphome' from 'tinyagentos.orb.bridges' FAILED tests/orb/test_bridge_esphome.py::test_esphome_in_adapters_and_evaluate_all - ImportError: cannot import name 'esphome' from 'tinyagentos.orb.bridges' 7 failed, 15 passed in 0.37s ``` Green run (after implementing esphome.py): ``` 22 passed in 0.22s ``` --- changelog.d/tsk-sp4nji-orb-bridge-esphome.md | 3 + tests/orb/test_bridge_esphome.py | 137 +++++++++++++++++++ tinyagentos/orb/bridges/esphome.py | 51 +++++++ 3 files changed, 191 insertions(+) create mode 100644 changelog.d/tsk-sp4nji-orb-bridge-esphome.md create mode 100644 tests/orb/test_bridge_esphome.py create mode 100644 tinyagentos/orb/bridges/esphome.py diff --git a/changelog.d/tsk-sp4nji-orb-bridge-esphome.md b/changelog.d/tsk-sp4nji-orb-bridge-esphome.md new file mode 100644 index 000000000..6c2797b34 --- /dev/null +++ b/changelog.d/tsk-sp4nji-orb-bridge-esphome.md @@ -0,0 +1,3 @@ +### Added + +- ESPHome bridge adapter (tinyagentos/orb/bridges/esphome.py): host-side esphome tool detection via shutil.which and esphome logs command targets for orbs running the ESPHome app, with install hint when the tool is absent. diff --git a/tests/orb/test_bridge_esphome.py b/tests/orb/test_bridge_esphome.py new file mode 100644 index 000000000..55cf46192 --- /dev/null +++ b/tests/orb/test_bridge_esphome.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Tests for tinyagentos/orb/bridges/esphome.""" + +import pytest + + +def test_esphome_installed_and_target(monkeypatch): + """Test installed() True via monkeypatch and target instruction exact.""" + from tinyagentos.orb.bridges import esphome + + monkeypatch.setattr(esphome.shutil, "which", lambda x: "/usr/bin/esphome") + lan_orbs = [ + {"name": "orb-1", "app": "esphome", "addresses": ["192.168.1.40"], "port": 80}, + ] + agent_buddy = [] + + assert esphome.installed() is True + + result = esphome.evaluate(lan_orbs, agent_buddy) + + assert result.bridge_id == "esphome" + assert result.title == "ESPHome" + assert result.installed is True + assert len(result.targets) == 1 + assert result.targets[0]["orb"] == "orb-1" + assert result.targets[0]["address"] == "192.168.1.40" + assert ( + result.targets[0]["instruction"] + == "esphome logs .yaml --device 192.168.1.40" + ) + assert result.instruction == "Run the command shown with your ESPHome config file." + + +def test_esphome_not_installed_target_still_present(monkeypatch): + """Test installed() False: target still present, append to instruction.""" + from tinyagentos.orb.bridges import esphome + + monkeypatch.setattr(esphome.shutil, "which", lambda x: None) + lan_orbs = [ + {"name": "orb-1", "app": "esphome", "addresses": ["192.168.1.40"], "port": 80}, + ] + agent_buddy = [] + + assert esphome.installed() is False + + result = esphome.evaluate(lan_orbs, agent_buddy) + + assert result.installed is False + assert len(result.targets) == 1 + assert ( + result.targets[0]["instruction"] + == "esphome logs .yaml --device 192.168.1.40" + ) + assert result.instruction.endswith( + "Install ESPHome first: pip install esphome." + ) + assert result.instruction == ( + "Run the command shown with your ESPHome config file." + " Install ESPHome first: pip install esphome." + ) + + +def test_esphome_ignores_mimiclaw_orb(monkeypatch): + """Test an orb with app 'mimiclaw' is ignored.""" + from tinyagentos.orb.bridges import esphome + + monkeypatch.setattr(esphome.shutil, "which", lambda x: "/usr/bin/esphome") + lan_orbs = [ + {"name": "orb-1", "app": "mimiclaw", "addresses": ["192.168.1.40"], "port": 80}, + ] + agent_buddy = [] + + result = esphome.evaluate(lan_orbs, agent_buddy) + + assert len(result.targets) == 0 + assert result.instruction == "No Orb running ESPHome found on the LAN." + + +def test_esphome_ipv6_address(monkeypatch): + """Test IPv6-only orb address is wrapped in brackets.""" + from tinyagentos.orb.bridges import esphome + + monkeypatch.setattr(esphome.shutil, "which", lambda x: "/usr/bin/esphome") + lan_orbs = [ + {"name": "orb-1", "app": "esphome", "addresses": ["fe80::2"], "port": 80}, + ] + agent_buddy = [] + + result = esphome.evaluate(lan_orbs, agent_buddy) + + assert len(result.targets) == 1 + assert result.targets[0]["address"] == "[fe80::2]" + assert ( + result.targets[0]["instruction"] + == "esphome logs .yaml --device [fe80::2]" + ) + + +def test_esphome_empty_input(monkeypatch): + """Test empty input returns no targets and correct instruction.""" + from tinyagentos.orb.bridges import esphome + + monkeypatch.setattr(esphome.shutil, "which", lambda x: "/usr/bin/esphome") + + result = esphome.evaluate([], []) + + assert result.bridge_id == "esphome" + assert result.title == "ESPHome" + assert result.installed is True + assert len(result.targets) == 0 + assert result.instruction == "No Orb running ESPHome found on the LAN." + + +def test_esphome_constants(): + """Test esphome module constants.""" + from tinyagentos.orb.bridges import esphome + + assert esphome.BRIDGE_ID == "esphome" + assert esphome.TITLE == "ESPHome" + assert esphome.APP_ID == "esphome" + + +def test_esphome_in_adapters_and_evaluate_all(monkeypatch): + """Test esphome is auto-discovered by adapters() and evaluate_all has no error.""" + import tinyagentos.orb.bridges as bridges_mod + from tinyagentos.orb.bridges import esphome + + monkeypatch.setattr(esphome.shutil, "which", lambda x: None) + + modules = bridges_mod.adapters() + names = [m.__name__.rsplit(".", 1)[1] for m in modules] + assert "esphome" in names + + all_results = bridges_mod.evaluate_all([], []) + esphome_result = [r for r in all_results if r["bridge_id"] == "esphome"] + assert len(esphome_result) == 1 + assert "error" not in esphome_result[0] diff --git a/tinyagentos/orb/bridges/esphome.py b/tinyagentos/orb/bridges/esphome.py new file mode 100644 index 000000000..ad39e0b8c --- /dev/null +++ b/tinyagentos/orb/bridges/esphome.py @@ -0,0 +1,51 @@ +"""ESPHome bridge adapter. + +Reference adapter for the ESPHome logs interface. +ESPHome ships a host-side tool; installed() checks shutil.which. +""" + +import shutil + +from . import BridgeResult, orbs_running, orb_host, target + +BRIDGE_ID = "esphome" +TITLE = "ESPHome" +APP_ID = "esphome" + + +def installed() -> bool | None: + """Best effort for the host running the core, spec s5.1.""" + return shutil.which("esphome") is not None + + +def evaluate(lan_orbs: list[dict], agent_buddy: list[dict]) -> BridgeResult: + """Evaluate ESPHome bridges from discovered LAN orbs. + + agent_buddy is unused (kept for adapter signature compatibility). + """ + matching_orbs = orbs_running(lan_orbs, APP_ID) + targets = [] + for orb in matching_orbs: + host = orb_host(orb) + if host is not None: + targets.append( + target( + orb["name"], + host, + f"esphome logs .yaml --device {host}", + ) + ) + targets_tuple = tuple(targets) + if targets: + instruction = "Run the command shown with your ESPHome config file." + if not installed(): + instruction += " Install ESPHome first: pip install esphome." + else: + instruction = "No Orb running ESPHome found on the LAN." + return BridgeResult( + bridge_id=BRIDGE_ID, + title=TITLE, + installed=installed(), + targets=targets_tuple, + instruction=instruction, + )