Device Protocols¶
The Translators --- Device protocols are the staff members who speak each device's native language. One translator knows how to talk to LIFX bulbs over UDP. Another knows the Govee cloud API. The steward dispatches them to discover and control devices, but the translator handles the actual conversation with hardware.
A device protocol implements the IJarvisDeviceProtocol interface (from device_families/base.py) to provide direct LAN or cloud control of a specific device family. Protocols are discovered automatically by DeviceFamilyDiscoveryService at startup.
Protocols are the low-level layer --- they talk to hardware. The JarvisDirectDeviceManager aggregates all protocols into a unified device list.
Interface Reference¶
from abc import ABC, abstractmethod
from dataclasses import dataclass
class IJarvisDeviceProtocol(ABC):
# --- Required (abstract) ---
@property
@abstractmethod
def protocol_name(self) -> str:
"""Unique protocol identifier. Examples: 'lifx', 'kasa', 'govee'."""
...
@property
@abstractmethod
def supported_domains(self) -> list[str]:
"""HA-style domains this protocol can control. Examples: ['light'], ['light', 'switch']."""
...
@abstractmethod
async def discover(self) -> list[DiscoveredDevice]:
"""Scan the network/cloud for devices this protocol can control."""
...
@abstractmethod
async def control(
self, ip: str, action: str, data: dict | None = None, **kwargs
) -> DeviceControlResult:
"""Send a control command to a device."""
...
@abstractmethod
async def get_state(self, ip: str, **kwargs) -> dict | None:
"""Query the current state of a device. Returns None if unavailable."""
...
# --- Optional (with defaults) ---
@property
def connection_type(self) -> str:
"""'lan', 'cloud', or 'hybrid'. Default: 'lan'."""
return "lan"
@property
def required_secrets(self) -> list[IJarvisSecret]:
return []
@property
def friendly_name(self) -> str:
return self.protocol_name.title()
@property
def description(self) -> str:
return ""
@property
def authentication(self) -> AuthenticationConfig | None:
return None
@property
def supported_actions(self) -> list[IJarvisButton]:
"""Default actions: turn_on, turn_off."""
return [
IJarvisButton(action="turn_on", label="Turn On"),
IJarvisButton(action="turn_off", label="Turn Off"),
]
def store_auth_values(self, values: dict) -> None:
"""Called after OAuth/auth flow completes to persist credentials."""
...
async def on_removed(self, device: Any, **kwargs: Any) -> None:
"""Called when a device of this protocol is removed from Jarvis.
Override to release resources tied to this device (e.g. unpair an
external pairing so the accessory can be re-paired cleanly). The node
calls this before the device record is deleted; failures are logged
and do not block the delete. Default: no-op (SDK v0.4.2+).
"""
...
async def get_stream_source(self, device: Any) -> str | None:
"""Return a go2rtc source URL for live-streaming this camera device.
Return None if this protocol has no camera-stream support or the
device isn't configured. Default: None (SDK v0.5.0+).
"""
...
def validate_secrets(self) -> list[str]:
"""Returns a list of validation error messages (empty = valid)."""
...
on_removed — Device Deletion Hook (SDK v0.4.2+)¶
When a device is deleted from Jarvis, the node calls on_removed(device, **kwargs) on the protocol adapter before the database record is dropped. Implement this to release any external pairing or session tied to the device:
async def on_removed(self, device: Any, **kwargs: Any) -> None:
# Release any external resource (e.g. revoke a token, close a pairing)
await self._session_manager.release(device["entity_id"])
Behaviour:
- Best-effort: failures are logged as a warning; the delete always proceeds.
- Scope: only fired for
source="direct"(node-managed protocol) devices. - Default: no-op — existing protocols require no changes to compile or run.
- Node dispatch: the node's MQTT listener receives the
device_removedcommand from command-center and dispatches it off-thread todirect_device_service.remove_device(), which callson_removedvia ahasattrguard. The tool cache is always invalidated afterward, even ifon_removedraises.
See Protocol Cleanup on Delete for the command-center side of this flow.
get_stream_source — Camera Streaming Hook (SDK v0.5.0+)¶
Protocols that support camera streaming can override get_stream_source(device) to build a go2rtc source string for live video:
async def get_stream_source(self, device: DiscoveredDevice) -> str | None:
if not self._has_camera_support():
return None
return f"mydevice:?device_id={device.cloud_id}&token={await self._get_token()}"
The node hands the returned string to command-center, which registers it with go2rtc verbatim — so the protocol, not command-center, owns the source format and the choice of streaming transport (e.g. WebRTC vs RTSP).
Behaviour:
- Async: implementations can do I/O (e.g. a capability lookup) before returning a source string.
- Default:
None— protocols without camera support are inert; existing protocols require no changes to compile or run. - Node dispatch: the node's
camera_credentials_handlerresolves the protocol adapter for the requesting device viaDeviceFamilyDiscoveryService.get_family(), awaitsget_stream_source(device), and POSTs{"stream_source": "..."}back to command-center at/api/v0/camera-credentials/{request_id}— or{"error": "..."}if the adapter predates this hook (older SDK), returnsNone(camera not configured), or raises. Requires jarvis-command-sdk >= 0.5.0. - Reference implementation: jarvis-device-nest's
NestProtocol.get_stream_source()builds anest:go2rtc source, preferring WebRTC (omitting theprotocolsparam) and settingprotocols=RTSPonly for devices that support RTSP but not WebRTC — a forced RTSP request gets HTTP 400 from WebRTC-only devices (e.g. the battery Nest Doorbell). On an SDM capability-lookup failure, it falls back to WebRTC rather than RTSP, since an incorrect RTSP fallback would re-break those same devices.
needs_pairing — Pairing State Signal (v0.1.130+)¶
When get_state() returns {"needs_pairing": True}, the node short-circuits device state reporting before domain normalization and emits:
{
"entity_id": ...,
"domain": ...,
"state": {"needs_pairing": True},
"ui_hints": {"control_type": "pairing"},
}
This tells the mobile app to show a Pair affordance (setup-code / PIN dialog) instead of the device's normal domain panel, which can't operate until pairing completes. Once pairing succeeds, get_state() should return real state (without needs_pairing) and the app transitions directly to the domain control.
Use this signal for protocols that require an out-of-band pairing step before control is possible — for example, HomeKit HAP setup-code exchange:
async def get_state(self, ip: str, **kwargs) -> dict | None:
if not self._is_paired(ip):
return {"needs_pairing": True}
# ... normal state query once paired
return {"on": True, "brightness": 80}
The short-circuit runs before domain normalization, so control_type: "pairing" is preserved regardless of the device's domain (light, climate, etc.).
Supporting Dataclasses¶
DiscoveredDevice¶
Returned by discover(). Contains everything Jarvis needs to identify and control a device:
@dataclass
class DiscoveredDevice:
name: str # "Living Room Bulb"
domain: str # "light", "switch", "media_player"
manufacturer: str # "LIFX", "TP-Link"
model: str # "A19", "KP125"
protocol: str # "lifx", "kasa"
entity_id: str # "lifx.d073d5xxxxxx"
local_ip: str | None # "192.168.1.42"
mac_address: str | None # "D0:73:D5:XX:XX:XX"
cloud_id: str | None # Vendor-specific cloud ID
device_class: str | None # "outlet", "dimmer", etc.
is_controllable: bool # True if Jarvis can send commands
extra: dict | None # Protocol-specific metadata
DeviceControlResult¶
Returned by control(). Indicates success or failure:
@dataclass
class DeviceControlResult:
success: bool # Did the command succeed?
entity_id: str # Which device was targeted
action: str # What action was attempted ("turn_on", "set_brightness")
error: str | None # Error message if success=False
Built-in Protocols¶
LIFX Protocol¶
Connection: LAN UDP on port 56700
Library: lifxlan
Secrets: None (discovery is broadcast-based)
Domains: ["light"]
class LifxProtocol(IJarvisDeviceProtocol):
protocol_name = "lifx"
supported_domains = ["light"]
connection_type = "lan"
async def discover(self) -> list[DiscoveredDevice]:
# Uses lifxlan.LifxLAN() to broadcast discovery on UDP 56700
# Returns all LIFX bulbs found on the local network
...
async def control(self, ip, action, data=None, **kwargs):
# Supported actions: turn_on, turn_off, set_brightness
# Brightness: 0-100 (user-facing) mapped to 0-65535 (LIFX protocol)
...
Brightness mapping: LIFX uses a 0--65535 range internally. The protocol adapter maps the user-facing 0--100 scale:
Kasa Protocol¶
Connection: LAN broadcast
Library: python-kasa
Secrets: None (local discovery)
Domains: ["light", "switch"]
class KasaProtocol(IJarvisDeviceProtocol):
protocol_name = "kasa"
supported_domains = ["light", "switch"]
connection_type = "lan"
async def discover(self) -> list[DiscoveredDevice]:
# Uses kasa.Discover.discover() for LAN broadcast
# Maps Kasa device types to HA-style domains:
# SmartBulb -> "light"
# SmartPlug -> "switch"
# SmartDimmer -> "light"
...
async def control(self, ip, action, data=None, **kwargs):
# Connects to device by IP, sends turn_on/turn_off/set_brightness
...
Device type mapping: Kasa has its own device taxonomy. The protocol maps these to HA-style domains:
| Kasa Type | HA Domain |
|---|---|
SmartBulb |
light |
SmartPlug |
switch |
SmartDimmer |
light |
SmartStrip |
switch |
SmartSwitch |
switch |
Govee Protocol¶
Connection: Hybrid (LAN + cloud REST API)
Library: httpx
Secrets: GOVEE_API_KEY (required for cloud API)
Domains: ["light"]
class GoveeProtocol(IJarvisDeviceProtocol):
protocol_name = "govee"
supported_domains = ["light"]
connection_type = "hybrid"
required_secrets = [
IJarvisSecret(key="GOVEE_API_KEY", description="Govee Developer API key"),
]
async def discover(self) -> list[DiscoveredDevice]:
# Calls Govee cloud API: GET https://developer-api.govee.com/v1/devices
# Returns device list with capabilities
...
async def control(self, ip, action, data=None, **kwargs):
# Dual API fallback:
# 1. Try LAN control first (faster, no rate limits)
# 2. Fall back to cloud REST API if LAN fails
#
# Capability-based payloads:
# {"device": "...", "model": "...", "cmd": {"name": "turn", "value": "on"}}
...
Dual API fallback: Govee devices that support LAN control receive commands directly over the local network. If LAN control fails (device not on same subnet, firmware limitation), the protocol falls back to the cloud REST API transparently.
Capability-based payloads: Govee devices report their capabilities via the API. The protocol checks capabilities before sending commands --- for example, only devices with the brightness capability receive brightness commands.
Apple Protocol¶
Connection: mDNS / Bonjour
Library: pyatv
Secrets: None (mDNS discovery + pairing)
Domains: ["media_player"]
class AppleProtocol(IJarvisDeviceProtocol):
protocol_name = "apple"
supported_domains = ["media_player"]
connection_type = "lan"
async def discover(self) -> list[DiscoveredDevice]:
# Uses pyatv.scan() for mDNS/Bonjour discovery
# Filters by model whitelist (Apple TV, HomePod)
...
async def control(self, ip, action, data=None, **kwargs):
# Supported actions: turn_on, turn_off, play, pause, next, previous
# Uses pyatv RemoteControl protocol
...
Model whitelist: The Apple protocol only reports devices matching a known model list (Apple TV 4K, Apple TV HD, HomePod, HomePod mini). This avoids exposing every AirPlay-capable device on the network as a controllable device.
Discovery¶
Protocols are discovered by DeviceFamilyDiscoveryService, which scans the device_families/ package at startup:
device_families/
__init__.py
base.py # IJarvisDeviceProtocol ABC + dataclasses
lifx_protocol.py
kasa_protocol.py
govee_protocol.py
apple_protocol.py
your_new_protocol.py # <-- just add the file
See Discovery System for details.
Writing a Custom Protocol¶
Here is a minimal example for controlling Hue bulbs over the local Hue Bridge API:
from device_families.base import IJarvisDeviceProtocol, DiscoveredDevice, DeviceControlResult
from core.interfaces import IJarvisSecret
class HueProtocol(IJarvisDeviceProtocol):
@property
def protocol_name(self) -> str:
return "hue"
@property
def supported_domains(self) -> list[str]:
return ["light"]
@property
def connection_type(self) -> str:
return "lan"
@property
def required_secrets(self) -> list[IJarvisSecret]:
return [
IJarvisSecret(key="HUE_BRIDGE_IP", description="Hue Bridge IP address"),
IJarvisSecret(key="HUE_USERNAME", description="Hue Bridge API username"),
]
async def discover(self) -> list[DiscoveredDevice]:
bridge_ip = self.secret_service.get_secret("HUE_BRIDGE_IP")
username = self.secret_service.get_secret("HUE_USERNAME")
async with httpx.AsyncClient() as client:
resp = await client.get(f"http://{bridge_ip}/api/{username}/lights")
resp.raise_for_status()
lights = resp.json()
devices: list[DiscoveredDevice] = []
for light_id, light in lights.items():
devices.append(DiscoveredDevice(
name=light["name"],
domain="light",
manufacturer=light.get("manufacturername", "Philips"),
model=light.get("modelid", "Unknown"),
protocol="hue",
entity_id=f"hue.{light_id}",
local_ip=bridge_ip,
mac_address=light.get("uniqueid"),
cloud_id=None,
device_class="light",
is_controllable=True,
extra={"light_id": light_id},
))
return devices
async def control(
self, ip: str, action: str, data: dict | None = None, **kwargs
) -> DeviceControlResult:
light_id = kwargs.get("entity_id", "").split(".")[-1]
username = self.secret_service.get_secret("HUE_USERNAME")
payload: dict = {}
if action == "turn_on":
payload = {"on": True}
elif action == "turn_off":
payload = {"on": False}
elif action == "set_brightness":
# Hue uses 1-254 range
bri = int((data or {}).get("brightness", 100) / 100 * 254)
payload = {"on": True, "bri": max(1, bri)}
try:
async with httpx.AsyncClient() as client:
resp = await client.put(
f"http://{ip}/api/{username}/lights/{light_id}/state",
json=payload,
)
resp.raise_for_status()
return DeviceControlResult(
success=True, entity_id=f"hue.{light_id}", action=action, error=None
)
except Exception as e:
return DeviceControlResult(
success=False, entity_id=f"hue.{light_id}", action=action, error=str(e)
)
async def get_state(self, ip: str, **kwargs) -> dict | None:
light_id = kwargs.get("entity_id", "").split(".")[-1]
username = self.secret_service.get_secret("HUE_USERNAME")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(f"http://{ip}/api/{username}/lights/{light_id}")
resp.raise_for_status()
data = resp.json()
return {
"on": data["state"]["on"],
"brightness": int(data["state"].get("bri", 0) / 254 * 100),
"reachable": data["state"]["reachable"],
}
except Exception:
return None
Save as device_families/hue_protocol.py and restart the node.