diff --git a/python/signal_bot/commands/location.py b/python/signal_bot/commands/location.py new file mode 100644 index 0000000..8a293de --- /dev/null +++ b/python/signal_bot/commands/location.py @@ -0,0 +1,74 @@ +"""Location command for the Signal bot.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import requests + +if TYPE_CHECKING: + from python.signal_bot.models import SignalMessage + from python.signal_bot.signal_client import SignalClient + + +def _get_location_payload(ha_url: str, ha_token: str, entity_id: str) -> dict[str, Any]: + """Fetch location entity state from Home Assistant.""" + response = requests.get( + f"{ha_url}/api/states/{entity_id}", + headers={"Authorization": f"Bearer {ha_token}"}, + timeout=30, + ) + response.raise_for_status() + return response.json() + + +def _format_location(payload: dict[str, Any]) -> str: + """Render a friendly location response.""" + attributes = payload.get("attributes", {}) + latitude = attributes.get("latitude") + longitude = attributes.get("longitude") + + if latitude is None or longitude is None: + state = payload.get("state", "unknown") + if "," not in state: + return "Van location is unavailable in Home Assistant right now." + latitude_text, longitude_text = [part.strip() for part in state.split(",", maxsplit=1)] + else: + latitude_text = str(latitude) + longitude_text = str(longitude) + + lines = [ + f"Van location: {latitude_text}, {longitude_text}", + f"https://maps.google.com/?q={latitude_text},{longitude_text}", + ] + + speed = attributes.get("speed") + if speed not in (None, "", "unknown", "unavailable"): + lines.append(f"Speed: {speed}") + + last_updated = attributes.get("last_updated") + if last_updated: + lines.append(f"Updated: {last_updated}") + + return "\n".join(lines) + + +def handle_location_request( + message: SignalMessage, + signal: SignalClient, + ha_url: str | None, + ha_token: str | None, + ha_location_entity: str, +) -> None: + """Reply with van location from Home Assistant.""" + if ha_url is None or ha_token is None: + signal.reply(message, "Location command is not configured (missing HA_URL or HA_TOKEN).") + return + + try: + payload = _get_location_payload(ha_url, ha_token, ha_location_entity) + except requests.RequestException: + signal.reply(message, "Couldn't fetch van location from Home Assistant right now.") + return + + signal.reply(message, _format_location(payload)) diff --git a/python/signal_bot/main.py b/python/signal_bot/main.py index c4db567..1eca710 100644 --- a/python/signal_bot/main.py +++ b/python/signal_bot/main.py @@ -14,6 +14,7 @@ from python.common import configure_logger, utcnow from python.orm.common import get_postgres_engine from python.orm.richie.dead_letter_message import DeadLetterMessage from python.signal_bot.commands.inventory import handle_inventory_update +from python.signal_bot.commands.location import handle_location_request from python.signal_bot.device_registry import DeviceRegistry from python.signal_bot.llm_client import LLMClient from python.signal_bot.models import BotConfig, MessageStatus, SignalMessage @@ -27,6 +28,7 @@ HELP_TEXT = ( " inventory — update van inventory from a text list\n" " inventory (+ photo) — update van inventory from a receipt photo\n" " status — show bot status\n" + " location — get current van location\n" " help — show this help message\n" "Send a receipt photo with the message 'inventory' to scan it.\n" ) @@ -86,6 +88,18 @@ def inventory_action( handle_inventory_update(message, signal, llm, config.inventory_api_url) +def location_action( + signal: SignalClient, + message: SignalMessage, + _llm: LLMClient, + _registry: DeviceRegistry, + config: BotConfig, + _cmd: str, +) -> None: + """Reply with current van location.""" + handle_location_request(message, signal, config.ha_url, config.ha_token, config.ha_location_entity) + + def dispatch( message: SignalMessage, signal: SignalClient, @@ -112,6 +126,7 @@ def dispatch( "help": help_action, "status": status_action, "inventory": inventory_action, + "location": location_action, } logger.info(f"f{source=} running {cmd=} with {message=}") action = commands.get(cmd) @@ -209,6 +224,9 @@ def main( signal_api_url=signal_api_url, phone_number=phone_number, inventory_api_url=inventory_api_url, + ha_url=getenv("HA_URL"), + ha_token=getenv("HA_TOKEN"), + ha_location_entity=getenv("HA_LOCATION_ENTITY", "sensor.gps_location"), engine=engine, ) diff --git a/python/signal_bot/models.py b/python/signal_bot/models.py index 9cccab2..0baddad 100644 --- a/python/signal_bot/models.py +++ b/python/signal_bot/models.py @@ -79,6 +79,9 @@ class BotConfig(BaseModel): signal_api_url: str phone_number: str inventory_api_url: str + ha_url: str | None = None + ha_token: str | None = None + ha_location_entity: str = "sensor.gps_location" engine: Engine reconnect_delay: int = 5 max_reconnect_delay: int = 300 diff --git a/tests/test_signal_bot.py b/tests/test_signal_bot.py index 8a2a12b..bd0be1c 100644 --- a/tests/test_signal_bot.py +++ b/tests/test_signal_bot.py @@ -14,6 +14,7 @@ from python.signal_bot.commands.inventory import ( _format_summary, parse_llm_response, ) +from python.signal_bot.commands.location import _format_location, handle_location_request from python.signal_bot.device_registry import _BLOCKED_TTL, _DEFAULT_TTL, DeviceRegistry, _CacheEntry from python.signal_bot.llm_client import LLMClient from python.signal_bot.main import dispatch @@ -227,6 +228,30 @@ class TestContactCache: mock_session.execute.assert_called_once() +class TestLocationCommand: + def test_format_location_from_attributes(self): + payload = { + "state": "whatever", + "attributes": { + "latitude": 12.34, + "longitude": 56.78, + "speed": "45 mph", + "last_updated": "2024-01-01T00:00:00+00:00", + }, + } + response = _format_location(payload) + assert "12.34, 56.78" in response + assert "maps.google.com" in response + assert "Speed: 45 mph" in response + + def test_handle_location_request_without_config(self): + signal = MagicMock(spec=SignalClient) + message = SignalMessage(source="+1234", timestamp=0, message="location") + handle_location_request(message, signal, None, None, "sensor.gps_location") + signal.reply.assert_called_once() + assert "not configured" in signal.reply.call_args[0][1] + + class TestDispatch: @pytest.fixture def signal_mock(self): @@ -283,3 +308,16 @@ class TestDispatch: dispatch(msg, signal_mock, llm_mock, registry_mock, config) signal_mock.reply.assert_called_once() assert "Bot online" in signal_mock.reply.call_args[0][1] + + def test_location_command(self, signal_mock, llm_mock, registry_mock, config): + msg = SignalMessage(source="+1234", timestamp=0, message="location") + with patch("python.signal_bot.main.handle_location_request") as mock_location: + dispatch(msg, signal_mock, llm_mock, registry_mock, config) + + mock_location.assert_called_once_with( + msg, + signal_mock, + config.ha_url, + config.ha_token, + config.ha_location_entity, + )