3 Commits

Author SHA1 Message Date
cf6bc5d2bf Add Lights to OpenJibo 2026-06-20 12:07:50 -04:00
a996a00a40 Potentially get working OpenJibo server support 2026-06-20 11:19:03 -04:00
91dcaec103 Add Icon for Jibo.Say 2026-05-19 13:13:23 -04:00
9 changed files with 331 additions and 38 deletions

View File

@@ -4,7 +4,8 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
import logging
from .const import DOMAIN, PLATFORMS
from .const import CONF_JIBO_IP, DOMAIN, PLATFORMS
from .coordinator import JiboCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -23,9 +24,14 @@ async def async_setup(hass: HomeAssistant, config: dict):
async def async_setup_entry(hass: HomeAssistant, entry):
hass.data.setdefault(DOMAIN, {})
coordinator = JiboCoordinator(hass, entry)
await coordinator.async_start()
hass.data[DOMAIN][entry.entry_id] = {
"jibo_ip": entry.data["jibo_ip"],
"jibo_ip": entry.data.get(CONF_JIBO_IP, ""),
"name": entry.data.get("name", entry.title),
"coordinator": coordinator,
}
if not hass.services.has_service(DOMAIN, "say"):
@@ -36,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry):
targets = [
data["jibo_ip"]
for data in hass.data[DOMAIN].values()
if robot_filter is None or data["name"] == robot_filter
if data.get("jibo_ip") and (robot_filter is None or data["name"] == robot_filter)
]
if not targets:
@@ -91,7 +97,9 @@ async def async_unload_entry(hass: HomeAssistant, entry):
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
hass.data[DOMAIN].pop(entry.entry_id, None)
entry_data = hass.data[DOMAIN].pop(entry.entry_id, None)
if entry_data and (coordinator := entry_data.get("coordinator")):
await coordinator.async_shutdown()
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, "say")

View File

@@ -26,7 +26,7 @@ async def async_setup_entry(
) -> None:
data = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[JiboConnectivitySensor(entry, data["jibo_ip"], data["name"])],
[JiboConnectivitySensor(entry, data.get("jibo_ip", ""), data["name"], data.get("coordinator"))],
update_before_add=True,
)
@@ -35,8 +35,9 @@ class JiboConnectivitySensor(BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
_attr_should_poll = True
def __init__(self, entry: ConfigEntry, ip: str, name: str) -> None:
def __init__(self, entry: ConfigEntry, ip: str, name: str, coordinator) -> None:
self._ip = ip
self._coordinator = coordinator
self._attr_unique_id = f"{entry.entry_id}_connectivity"
self._attr_name = f"{name} Online"
self._attr_is_on = False
@@ -48,16 +49,20 @@ class JiboConnectivitySensor(BinarySensorEntity):
}
async def async_update(self) -> None:
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(self._ip, _JIBO_PORT),
timeout=_CONNECT_TIMEOUT,
)
writer.close()
if self._ip:
try:
await writer.wait_closed()
except Exception:
pass
self._attr_is_on = True
except (asyncio.TimeoutError, OSError):
self._attr_is_on = False
_, writer = await asyncio.wait_for(
asyncio.open_connection(self._ip, _JIBO_PORT),
timeout=_CONNECT_TIMEOUT,
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
self._attr_is_on = True
except (asyncio.TimeoutError, OSError):
self._attr_is_on = False
return
self._attr_is_on = bool(self._coordinator and self._coordinator.connected)

View File

@@ -1,8 +1,8 @@
import uuid
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .const import CONF_INSTANCE_ID, CONF_JIBO_IP, CONF_SERVER_URL, DOMAIN
class JiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -12,22 +12,31 @@ class JiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
ip = user_input["jibo_ip"].strip()
server_url = user_input[CONF_SERVER_URL].strip().rstrip("/")
name = user_input.get("name", "").strip() or "OpenJibo"
jibo_ip = user_input.get(CONF_JIBO_IP, "").strip()
await self.async_set_unique_id(ip)
instance_id = str(uuid.uuid4())
await self.async_set_unique_id(instance_id)
self._abort_if_unique_id_configured()
name = user_input.get("name", "").strip() or f"Jibo ({ip})"
return self.async_create_entry(
title=name,
data={"jibo_ip": ip, "name": name},
data={
CONF_SERVER_URL: server_url,
CONF_INSTANCE_ID: instance_id,
CONF_JIBO_IP: jibo_ip,
"name": name,
},
)
data_schema = vol.Schema({
vol.Required("name"): str,
vol.Required("jibo_ip"): str,
})
data_schema = vol.Schema(
{
vol.Required(CONF_SERVER_URL): str,
vol.Required("name"): str,
vol.Optional(CONF_JIBO_IP, default=""): str,
}
)
return self.async_show_form(
step_id="user",

View File

@@ -1,2 +1,11 @@
DOMAIN = "jibo"
PLATFORMS = ["binary_sensor"]
CONF_SERVER_URL = "server_url"
CONF_JIBO_IP = "jibo_ip"
CONF_INSTANCE_ID = "instance_id"
CONF_LINK_ID = "link_id"
CONF_JIBO_FRIENDLY_NAME = "jibo_friendly_name"
WS_PATH = "/v1/homeassistant/ws"
NOTIFICATION_ID_PREFIX = "openjibo_pairing_"

View File

@@ -0,0 +1,131 @@
import logging
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
CONF_INSTANCE_ID,
CONF_JIBO_FRIENDLY_NAME,
CONF_LINK_ID,
CONF_SERVER_URL,
DOMAIN,
NOTIFICATION_ID_PREFIX,
)
from .websocket_client import OpenJiboWebSocketClient
_LOGGER = logging.getLogger(__name__)
class JiboCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinates the OpenJibo server WebSocket connection."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN}_{entry.entry_id}",
)
self.entry = entry
self._client = OpenJiboWebSocketClient(
entry.data[CONF_SERVER_URL],
entry.data[CONF_INSTANCE_ID],
self._handle_message,
)
@property
def connected(self) -> bool:
return self._client.connected
async def async_start(self) -> None:
await self._client.start()
async def async_shutdown(self) -> None:
await self._client.stop()
async def _handle_message(self, payload: dict[str, Any]) -> None:
message_type = payload.get("type")
if message_type == "verification_code":
code = payload.get("code", "")
notification_id = f"{NOTIFICATION_ID_PREFIX}{self.entry.data[CONF_INSTANCE_ID]}"
await self.hass.services.async_call(
"persistent_notification",
"create",
{
"title": "OpenJibo Pairing Code",
"message": (
f"Your OpenJibo verification code is **{code}**. "
"Enter this code in the OpenJibo portal after verifying your Jibo."
),
"notification_id": notification_id,
},
)
self.async_set_updated_data(
{
"verification_code": code,
"paired": False,
}
)
return
if message_type == "paired":
notification_id = f"{NOTIFICATION_ID_PREFIX}{self.entry.data[CONF_INSTANCE_ID]}"
await self.hass.services.async_call(
"persistent_notification",
"dismiss",
{"notification_id": notification_id},
)
updates = {
CONF_LINK_ID: payload.get("linkId"),
CONF_JIBO_FRIENDLY_NAME: payload.get("jiboFriendlyName"),
}
self.hass.config_entries.async_update_entry(
self.entry,
data={**self.entry.data, **{k: v for k, v in updates.items() if v}},
)
self.async_set_updated_data(
{
"verification_code": None,
"paired": True,
"jibo_friendly_name": payload.get("jiboFriendlyName"),
"link_id": payload.get("linkId"),
}
)
return
if message_type == "error":
_LOGGER.error("OpenJibo server error: %s", payload.get("message"))
return
if message_type == "command":
await self._handle_command(payload.get("command"))
return
async def _handle_command(self, command: str | None) -> None:
if command == "lights_off_current_room":
await self._handle_lights_off_current_room()
return
_LOGGER.warning("OpenJibo server sent unknown command: %s", command)
async def _handle_lights_off_current_room(self) -> None:
from homeassistant.helpers import device_registry as dr
device_registry = dr.async_get(self.hass)
device = device_registry.async_get_device(identifiers={(DOMAIN, self.entry.entry_id)})
if device is None:
_LOGGER.warning("OpenJibo device entry not found; cannot turn off room lights")
return
if not device.area_id:
_LOGGER.warning("OpenJibo device has no area assigned; cannot turn off room lights")
return
await self.hass.services.async_call(
"light",
"turn_off",
target={"area_id": [device.area_id]},
)
_LOGGER.info("Turned off lights in area %s for OpenJibo device", device.area_id)

View File

@@ -0,0 +1,5 @@
{
"services": {
"say": "mdi:message"
}
}

View File

@@ -6,8 +6,8 @@
"dependencies": [],
"documentation": "https://jibohacks.zane.org/homeassistant/int",
"integration_type": "device",
"iot_class": "local_polling",
"iot_class": "cloud_push",
"issue_tracker": "https://github.com/ZaneThePython/openjibo-hacs/issues",
"requirements": [],
"version": "0.1.0.4"
"version": "0.2.0.0"
}

View File

@@ -2,20 +2,22 @@
"config": {
"step": {
"user": {
"title": "Add a Jibo Robot",
"description": "Enter the details for your Jibo robot. The robot must be running the OpenJibo custom software with all ports exposed.",
"title": "Connect OpenJibo",
"description": "Enter your OpenJibo server URL and optional local robot details. After setup, a pairing code will appear as a Home Assistant notification. Complete pairing in the OpenJibo portal.",
"data": {
"name": "Robot Name",
"jibo_ip": "IP Address"
"server_url": "OpenJibo Server URL",
"name": "Integration Name",
"jibo_ip": "Robot IP Address (optional)"
},
"data_description": {
"name": "A friendly name to identify this robot (e.g. Living Room Jibo).",
"jibo_ip": "The local IP address of the robot on your network."
"server_url": "The URL of your OpenJibo server, for example http://192.168.1.10:24605.",
"name": "A friendly name for this OpenJibo connection.",
"jibo_ip": "Optional local IP for direct TTS via the jibo.say service."
}
}
},
"abort": {
"already_configured": "A Jibo robot with this IP address is already configured."
"already_configured": "This OpenJibo connection is already configured."
}
},
"entity": {

View File

@@ -0,0 +1,124 @@
import asyncio
import json
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from urllib.parse import urlparse, urlunparse
import aiohttp
from .const import WS_PATH
_LOGGER = logging.getLogger(__name__)
MessageHandler = Callable[[dict[str, Any]], Awaitable[None]]
def build_websocket_url(server_url: str) -> str:
parsed = urlparse(server_url.strip())
scheme = "wss" if parsed.scheme == "https" else "ws"
netloc = parsed.netloc or parsed.path
path = WS_PATH if WS_PATH.startswith("/") else f"/{WS_PATH}"
return urlunparse((scheme, netloc, path, "", "", ""))
class OpenJiboWebSocketClient:
"""Maintains an outbound WebSocket to the OpenJibo server."""
def __init__(
self,
server_url: str,
instance_id: str,
on_message: MessageHandler,
) -> None:
self._server_url = server_url
self._instance_id = instance_id
self._on_message = on_message
self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._task: asyncio.Task | None = None
self._stop_event = asyncio.Event()
self._connected = False
@property
def connected(self) -> bool:
return self._connected
async def start(self) -> None:
if self._task is not None:
return
self._stop_event.clear()
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
self._stop_event.set()
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
if self._ws is not None and not self._ws.closed:
await self._ws.close()
self._ws = None
if self._session is not None and not self._session.closed:
await self._session.close()
self._session = None
self._connected = False
async def _run(self) -> None:
backoff = 1
while not self._stop_event.is_set():
try:
await self._connect_once()
backoff = 1
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - reconnect loop
_LOGGER.warning("OpenJibo WebSocket connection failed: %s", err)
self._connected = False
if self._stop_event.is_set():
break
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
async def _connect_once(self) -> None:
ws_url = build_websocket_url(self._server_url)
timeout = aiohttp.ClientTimeout(total=None, connect=15, sock_read=None)
self._session = aiohttp.ClientSession(timeout=timeout)
self._ws = await self._session.ws_connect(ws_url, heartbeat=30)
await self._ws.send_json(
{
"type": "register",
"instanceId": self._instance_id,
}
)
self._connected = True
_LOGGER.info("Connected to OpenJibo server at %s", ws_url)
while not self._stop_event.is_set():
msg = await self._ws.receive()
if msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
if msg.type != aiohttp.WSMsgType.TEXT:
continue
try:
payload = json.loads(msg.data)
except json.JSONDecodeError:
_LOGGER.warning("Ignoring non-JSON WebSocket message from OpenJibo server")
continue
await self._on_message(payload)
self._connected = False