mirror of
https://github.com/ZaneThePython/openjibo-haint.git
synced 2026-06-21 05:36:15 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15fbc39402 | |||
| b53c0caf5e | |||
| 17baf699e7 | |||
| cf6bc5d2bf | |||
| a996a00a40 | |||
| 91dcaec103 |
@@ -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")
|
||||
|
||||
@@ -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,6 +49,7 @@ class JiboConnectivitySensor(BinarySensorEntity):
|
||||
}
|
||||
|
||||
async def async_update(self) -> None:
|
||||
if self._ip:
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self._ip, _JIBO_PORT),
|
||||
@@ -61,3 +63,6 @@ class JiboConnectivitySensor(BinarySensorEntity):
|
||||
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)
|
||||
|
||||
@@ -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({
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SERVER_URL): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Required("jibo_ip"): str,
|
||||
})
|
||||
vol.Optional(CONF_JIBO_IP, default=""): str,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -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_"
|
||||
|
||||
298
custom_components/jibo/coordinator.py
Normal file
298
custom_components/jibo/coordinator.py
Normal file
@@ -0,0 +1,298 @@
|
||||
import logging
|
||||
import re
|
||||
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__)
|
||||
_LIGHT_SUFFIXES = (" light", " lights", " lamp", " lamps")
|
||||
|
||||
|
||||
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,
|
||||
entry.data.get(CONF_LINK_ID),
|
||||
)
|
||||
|
||||
@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":
|
||||
if self.entry.data.get(CONF_LINK_ID):
|
||||
_LOGGER.debug("Ignoring verification code; Home Assistant is already paired")
|
||||
return
|
||||
|
||||
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 == "unpaired":
|
||||
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},
|
||||
)
|
||||
|
||||
cleared_data = {
|
||||
key: value
|
||||
for key, value in self.entry.data.items()
|
||||
if key not in {CONF_LINK_ID, CONF_JIBO_FRIENDLY_NAME}
|
||||
}
|
||||
self.hass.config_entries.async_update_entry(self.entry, data=cleared_data)
|
||||
self._client.clear_link_id()
|
||||
self.async_set_updated_data(
|
||||
{
|
||||
"verification_code": None,
|
||||
"paired": False,
|
||||
"jibo_friendly_name": None,
|
||||
"link_id": None,
|
||||
}
|
||||
)
|
||||
await self._client.force_reconnect()
|
||||
return
|
||||
|
||||
if message_type == "error":
|
||||
_LOGGER.error("OpenJibo server error: %s", payload.get("message"))
|
||||
return
|
||||
|
||||
if message_type == "command":
|
||||
await self._handle_command(payload)
|
||||
return
|
||||
|
||||
async def _handle_command(self, payload: dict[str, Any]) -> None:
|
||||
command = payload.get("command")
|
||||
if command == "lights_off_current_room":
|
||||
await self._handle_lights_room("turn_off")
|
||||
return
|
||||
|
||||
if command == "lights_on_current_room":
|
||||
await self._handle_lights_room("turn_on")
|
||||
return
|
||||
|
||||
if command == "lights_off_named":
|
||||
await self._handle_lights_named("turn_off", payload.get("targetName"))
|
||||
return
|
||||
|
||||
if command == "lights_on_named":
|
||||
await self._handle_lights_named("turn_on", payload.get("targetName"))
|
||||
return
|
||||
|
||||
_LOGGER.warning("OpenJibo server sent unknown command: %s", command)
|
||||
|
||||
async def _handle_lights_room(self, service: str) -> None:
|
||||
area_id = self._get_jibo_area_id()
|
||||
if area_id is None:
|
||||
return
|
||||
|
||||
await self.hass.services.async_call(
|
||||
"light",
|
||||
service,
|
||||
target={"area_id": [area_id]},
|
||||
)
|
||||
_LOGGER.info("Called light.%s for area %s", service, area_id)
|
||||
|
||||
async def _handle_lights_named(self, service: str, target_name: str | None) -> None:
|
||||
if not target_name:
|
||||
_LOGGER.warning("OpenJibo named light command missing targetName")
|
||||
return
|
||||
|
||||
area_id = self._get_jibo_area_id()
|
||||
entity_id = self._find_matching_light(target_name, area_id)
|
||||
if entity_id is None:
|
||||
_LOGGER.warning("No light matched target %r", target_name)
|
||||
return
|
||||
|
||||
await self.hass.services.async_call(
|
||||
"light",
|
||||
service,
|
||||
{"entity_id": entity_id},
|
||||
)
|
||||
_LOGGER.info("Called light.%s for entity %s (target %r)", service, entity_id, target_name)
|
||||
|
||||
def _get_jibo_area_id(self) -> str | None:
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, self.entry.entry_id)})
|
||||
if device is not None and device.area_id:
|
||||
return device.area_id
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
for entity in entity_registry.entities.values():
|
||||
if entity.config_entry_id != self.entry.entry_id or entity.platform != DOMAIN:
|
||||
continue
|
||||
|
||||
area_id = self._resolve_entity_area_id(entity_registry, device_registry, entity)
|
||||
if area_id:
|
||||
_LOGGER.info("Resolved OpenJibo area %s from entity %s", area_id, entity.entity_id)
|
||||
return area_id
|
||||
|
||||
if device is None:
|
||||
_LOGGER.warning("OpenJibo device entry not found; cannot control room lights")
|
||||
else:
|
||||
_LOGGER.warning("OpenJibo device has no area assigned; cannot control room lights")
|
||||
return None
|
||||
|
||||
def _resolve_entity_area_id(self, entity_registry, device_registry, entity) -> str | None:
|
||||
if entity.area_id:
|
||||
return entity.area_id
|
||||
|
||||
if not entity.device_id:
|
||||
return None
|
||||
|
||||
device = device_registry.async_get(entity.device_id)
|
||||
if device is None or not device.area_id:
|
||||
return None
|
||||
|
||||
return device.area_id
|
||||
|
||||
def _find_matching_light(self, target_name: str, area_id: str | None) -> str | None:
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
normalized_target = _normalize_light_name(target_name)
|
||||
if not normalized_target:
|
||||
return None
|
||||
|
||||
area_candidates = self._list_light_entity_ids(entity_registry, area_id)
|
||||
match = self._match_light_entity(normalized_target, area_candidates)
|
||||
if match is not None:
|
||||
return match
|
||||
|
||||
if area_id is not None:
|
||||
all_candidates = self._list_light_entity_ids(entity_registry, None)
|
||||
return self._match_light_entity(normalized_target, all_candidates)
|
||||
|
||||
return None
|
||||
|
||||
def _list_light_entity_ids(
|
||||
self,
|
||||
entity_registry: Any,
|
||||
area_id: str | None,
|
||||
) -> list[str]:
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
candidates: list[str] = []
|
||||
for entity in entity_registry.entities.values():
|
||||
if entity.domain != "light":
|
||||
continue
|
||||
|
||||
if area_id is not None:
|
||||
entity_area_id = self._resolve_entity_area_id(entity_registry, device_registry, entity)
|
||||
if entity_area_id != area_id:
|
||||
continue
|
||||
|
||||
candidates.append(entity.entity_id)
|
||||
return candidates
|
||||
|
||||
def _match_light_entity(self, normalized_target: str, entity_ids: list[str]) -> str | None:
|
||||
exact_match: str | None = None
|
||||
partial_match: str | None = None
|
||||
|
||||
for entity_id in entity_ids:
|
||||
state = self.hass.states.get(entity_id)
|
||||
friendly_name = state.name if state is not None else entity_id
|
||||
normalized_friendly = _normalize_light_name(friendly_name)
|
||||
if not normalized_friendly:
|
||||
continue
|
||||
|
||||
if normalized_friendly == normalized_target:
|
||||
exact_match = entity_id
|
||||
break
|
||||
|
||||
if (
|
||||
normalized_target in normalized_friendly
|
||||
or normalized_friendly in normalized_target
|
||||
) and partial_match is None:
|
||||
partial_match = entity_id
|
||||
|
||||
return exact_match or partial_match
|
||||
|
||||
|
||||
def _normalize_light_name(value: str) -> str:
|
||||
normalized = value.lower().strip()
|
||||
normalized = normalized.replace("'", "").replace("’", "")
|
||||
normalized = re.sub(r"\s+", " ", normalized)
|
||||
for suffix in _LIGHT_SUFFIXES:
|
||||
if normalized.endswith(suffix):
|
||||
normalized = normalized[: -len(suffix)].strip()
|
||||
return normalized
|
||||
5
custom_components/jibo/icons.json
Normal file
5
custom_components/jibo/icons.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"services": {
|
||||
"say": "mdi:message"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
134
custom_components/jibo/websocket_client.py
Normal file
134
custom_components/jibo/websocket_client.py
Normal file
@@ -0,0 +1,134 @@
|
||||
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,
|
||||
link_id: str | None = None,
|
||||
) -> None:
|
||||
self._server_url = server_url
|
||||
self._instance_id = instance_id
|
||||
self._link_id = link_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
|
||||
|
||||
def clear_link_id(self) -> None:
|
||||
self._link_id = None
|
||||
|
||||
async def force_reconnect(self) -> None:
|
||||
if self._ws is not None and not self._ws.closed:
|
||||
await self._ws.close()
|
||||
|
||||
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,
|
||||
**({"linkId": self._link_id} if self._link_id else {}),
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user