Skip to content

Commit 091eed1

Browse files
committed
First Version
1 parent 67b3de7 commit 091eed1

File tree

11 files changed

+1035
-12
lines changed

11 files changed

+1035
-12
lines changed

custom_components/hass.agent/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

custom_components/hass.agent/manifest.json

Lines changed: 0 additions & 11 deletions
This file was deleted.
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""The HASS.Agent integration."""
2+
from __future__ import annotations
3+
import json
4+
import logging
5+
import requests
6+
from homeassistant.helpers import device_registry as dr
7+
from homeassistant.components.mqtt.models import ReceiveMessage
8+
from homeassistant.components.mqtt.subscription import (
9+
async_prepare_subscribe_topics,
10+
async_subscribe_topics,
11+
async_unsubscribe_topics,
12+
)
13+
14+
from homeassistant.config_entries import ConfigEntry
15+
from homeassistant.const import CONF_ID, CONF_NAME, CONF_URL, Platform
16+
from homeassistant.core import HomeAssistant
17+
from homeassistant.helpers import discovery
18+
19+
from .const import DOMAIN
20+
21+
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
22+
23+
_logger = logging.getLogger(__name__)
24+
25+
26+
def update_device_info(hass: HomeAssistant, entry: ConfigEntry, new_device_info):
27+
device_registry = dr.async_get(hass)
28+
device_registry.async_get_or_create(
29+
config_entry_id=entry.entry_id,
30+
identifiers={(DOMAIN, entry.unique_id)},
31+
name=new_device_info["device"]["name"],
32+
manufacturer=new_device_info["device"]["manufacturer"],
33+
model=new_device_info["device"]["model"],
34+
sw_version=new_device_info["device"]["sw_version"],
35+
)
36+
37+
38+
async def handle_apis_changed(hass: HomeAssistant, entry: ConfigEntry, apis):
39+
if apis is not None:
40+
41+
device_registry = dr.async_get(hass)
42+
device = device_registry.async_get_device(
43+
identifiers={(DOMAIN, entry.unique_id)}
44+
)
45+
46+
media_player = apis.get("media_player", False)
47+
is_media_player_loaded = hass.data[DOMAIN][entry.entry_id]["loaded"][
48+
"media_player"
49+
]
50+
51+
notifications = apis.get("notifications", False)
52+
53+
is_notifications_loaded = hass.data[DOMAIN][entry.entry_id]["loaded"][
54+
"notifications"
55+
]
56+
57+
if media_player and is_media_player_loaded is False:
58+
_logger.debug("loading media_player for device: %s", device.name)
59+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
60+
61+
hass.data[DOMAIN][entry.entry_id]["loaded"]["media_player"] = True
62+
else:
63+
if is_media_player_loaded:
64+
_logger.debug(
65+
"unloading media_player for device: %s",
66+
device.name,
67+
)
68+
await hass.config_entries.async_forward_entry_unload(
69+
entry, Platform.MEDIA_PLAYER
70+
)
71+
72+
hass.data[DOMAIN][entry.entry_id]["loaded"]["media_player"] = False
73+
74+
if notifications and is_notifications_loaded is False:
75+
_logger.debug("loading notify for device: %s", device.name)
76+
77+
hass.async_create_task(
78+
discovery.async_load_platform(
79+
hass,
80+
Platform.NOTIFY,
81+
DOMAIN,
82+
{CONF_ID: entry.entry_id, CONF_NAME: device.name},
83+
{},
84+
)
85+
)
86+
hass.data[DOMAIN][entry.entry_id]["loaded"]["notifications"] = True
87+
else:
88+
if is_notifications_loaded:
89+
_logger.debug("unloading notify for device: %s", device.name)
90+
await hass.config_entries.async_unload_platforms(
91+
entry, [Platform.NOTIFY]
92+
)
93+
94+
hass.data[DOMAIN][entry.entry_id]["loaded"]["notifications"] = False
95+
96+
97+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
98+
"""Set up HASS.Agent from a config entry."""
99+
100+
hass.data.setdefault(DOMAIN, {})
101+
102+
hass.data[DOMAIN].setdefault(
103+
entry.entry_id,
104+
{
105+
"internal_mqtt": {},
106+
"apis": {},
107+
"mqtt": {},
108+
"loaded": {"media_player": False, "notifications": False},
109+
},
110+
)
111+
112+
print(entry.options)
113+
114+
url = entry.data.get(CONF_URL, None)
115+
116+
if url is not None:
117+
def get_device_info():
118+
return requests.get(f"{url}/info", timeout=10)
119+
120+
response = await hass.async_add_executor_job(get_device_info)
121+
122+
response_json = response.json()
123+
124+
update_device_info(hass, entry, response_json)
125+
126+
apis = {
127+
"notifications": True,
128+
"media_player": False, # unsupported for the moment
129+
}
130+
131+
hass.async_create_task(handle_apis_changed(hass, entry, apis))
132+
hass.data[DOMAIN][entry.entry_id]["apis"] = apis
133+
134+
else:
135+
device_name = entry.data["device"]["name"]
136+
137+
sub_state = hass.data[DOMAIN][entry.entry_id]["internal_mqtt"]
138+
139+
def updated(message: ReceiveMessage):
140+
payload = json.loads(message.payload)
141+
cached = hass.data[DOMAIN][entry.entry_id]["apis"]
142+
apis = payload["apis"]
143+
144+
update_device_info(hass, entry, payload)
145+
146+
if cached != apis:
147+
hass.async_create_task(handle_apis_changed(hass, entry, apis))
148+
hass.data[DOMAIN][entry.entry_id]["apis"] = apis
149+
150+
sub_state = async_prepare_subscribe_topics(
151+
hass,
152+
sub_state,
153+
{
154+
f"{entry.unique_id}-apis": {
155+
"topic": f"hass.agent/devices/{device_name}",
156+
"msg_callback": updated,
157+
"qos": 0,
158+
}
159+
},
160+
)
161+
162+
await async_subscribe_topics(hass, sub_state)
163+
164+
hass.data[DOMAIN][entry.entry_id]["internal_mqtt"] = sub_state
165+
166+
return True
167+
168+
169+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
170+
"""Unload a config entry."""
171+
172+
# known issue: notify does not always unload
173+
174+
loaded = hass.data[DOMAIN][entry.entry_id].get("loaded", None)
175+
176+
if loaded is not None:
177+
notifications = loaded.get("notifications", False)
178+
179+
media_player = loaded.get("media_player", False)
180+
181+
if notifications:
182+
if unload_ok := await hass.config_entries.async_unload_platforms(
183+
entry, [Platform.NOTIFY]
184+
):
185+
_logger.debug("unloaded %s for %s", "notify", entry.unique_id)
186+
187+
if media_player:
188+
if unload_ok := await hass.config_entries.async_unload_platforms(
189+
entry, [Platform.MEDIA_PLAYER]
190+
):
191+
_logger.debug("unloaded %s for %s", "media_player", entry.unique_id)
192+
else:
193+
_logger.warning("config entry (%s) with has no apis loaded?", entry.entry_id)
194+
195+
url = entry.data.get(CONF_URL, None)
196+
if url is None:
197+
async_unsubscribe_topics(
198+
hass, hass.data[DOMAIN][entry.entry_id]["internal_mqtt"]
199+
)
200+
201+
hass.data[DOMAIN].pop(entry.entry_id)
202+
203+
return unload_ok
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Config flow for HASS.Agent"""
2+
from __future__ import annotations
3+
import json
4+
import logging
5+
import requests
6+
import voluptuous as vol
7+
8+
from typing import Any
9+
from homeassistant.components.notify import ATTR_TITLE_DEFAULT
10+
from homeassistant.core import callback
11+
12+
from homeassistant import config_entries
13+
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, CONF_URL
14+
from homeassistant.data_entry_flow import FlowResult
15+
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
16+
17+
from .const import DOMAIN, CONF_DEFAULT_NOTIFICATION_TITLE
18+
19+
_logger = logging.getLogger(__name__)
20+
21+
22+
class OptionsFlowHandler(config_entries.OptionsFlow):
23+
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
24+
"""Initialize options flow."""
25+
self.config_entry = config_entry
26+
27+
async def async_step_init(
28+
self, user_input: dict[str, Any] | None = None
29+
) -> FlowResult:
30+
"""Manage the options."""
31+
if user_input is not None:
32+
user_input[CONF_DEFAULT_NOTIFICATION_TITLE] = user_input[
33+
CONF_DEFAULT_NOTIFICATION_TITLE
34+
].strip()
35+
36+
return self.async_create_entry(title="", data=user_input)
37+
38+
return self.async_show_form(
39+
step_id="init",
40+
data_schema=vol.Schema(
41+
{
42+
vol.Optional(
43+
CONF_DEFAULT_NOTIFICATION_TITLE,
44+
default=self.config_entry.options.get(
45+
CONF_DEFAULT_NOTIFICATION_TITLE, ATTR_TITLE_DEFAULT
46+
),
47+
): str
48+
}
49+
),
50+
)
51+
52+
53+
class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
54+
"""Handle a config flow."""
55+
56+
VERSION = 1
57+
58+
def __init__(self) -> None:
59+
"""Initialize flow."""
60+
self._device_name = ""
61+
self._data = {}
62+
63+
@staticmethod
64+
@callback
65+
def async_get_options_flow(
66+
config_entry: config_entries.ConfigEntry,
67+
) -> config_entries.OptionsFlow:
68+
"""Create the options flow."""
69+
return OptionsFlowHandler(config_entry)
70+
71+
async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult:
72+
"""Handle a flow initialized by MQTT discovery."""
73+
device_name = discovery_info.topic.split("hass.agent/devices/")[1]
74+
75+
payload = json.loads(discovery_info.payload)
76+
77+
serial_number = payload["serial_number"]
78+
79+
_logger.debug(
80+
"found device. Name: %s, Serial Number: %s", device_name, serial_number
81+
)
82+
83+
self._data = {"device": payload["device"], "apis": payload["apis"]}
84+
85+
for config in self._async_current_entries():
86+
if config.unique_id == serial_number:
87+
return self.async_abort(reason="already_configured")
88+
89+
await self.async_set_unique_id(serial_number)
90+
91+
# "hass.agent/devices/#" is hardcoded in HASS.Agent's manifest
92+
assert discovery_info.subscribed_topic == "hass.agent/devices/#"
93+
94+
self._device_name = device_name
95+
96+
return await self.async_step_confirm()
97+
98+
async def async_step_local_api(
99+
self, user_input: dict[str, Any] | None = None
100+
) -> FlowResult:
101+
102+
errors = {}
103+
104+
if user_input is not None:
105+
host = user_input[CONF_HOST]
106+
port = user_input[CONF_PORT]
107+
use_ssl = user_input[CONF_SSL]
108+
109+
protocol = "https" if use_ssl else "http"
110+
111+
url = f"{protocol}://{host}:{port}"
112+
113+
# serial number!
114+
try:
115+
116+
def get_device_info():
117+
return requests.get(f"{url}/info", timeout=10)
118+
119+
response = await self.hass.async_add_executor_job(get_device_info)
120+
121+
response_json = response.json()
122+
123+
await self.async_set_unique_id(response_json["serial_number"])
124+
125+
return self.async_create_entry(
126+
title=response_json["device"]["name"],
127+
data={CONF_URL: url},
128+
options={CONF_DEFAULT_NOTIFICATION_TITLE: ATTR_TITLE_DEFAULT},
129+
)
130+
131+
except Exception:
132+
errors["base"] = "cannot_connect"
133+
134+
return self.async_show_form(
135+
step_id="local_api",
136+
data_schema=vol.Schema(
137+
# pylint: disable=no-value-for-parameter
138+
{
139+
vol.Required(CONF_HOST): str,
140+
vol.Required(CONF_PORT, default=5115): int,
141+
vol.Required(CONF_SSL): bool,
142+
}
143+
),
144+
errors=errors,
145+
)
146+
147+
async def async_step_user(
148+
self, user_input: dict[str, Any] | None = None
149+
) -> FlowResult:
150+
return await self.async_step_local_api()
151+
152+
async def async_step_confirm(
153+
self, user_input: dict[str, Any] | None = None
154+
) -> FlowResult:
155+
"""Confirm the setup."""
156+
157+
if user_input is not None:
158+
return self.async_create_entry(
159+
title=self._device_name,
160+
data=self._data,
161+
options={CONF_DEFAULT_NOTIFICATION_TITLE: ATTR_TITLE_DEFAULT},
162+
)
163+
164+
placeholders = {CONF_NAME: self._device_name}
165+
166+
self.context["title_placeholders"] = placeholders
167+
168+
self._set_confirm_only()
169+
170+
return self.async_show_form(
171+
step_id="confirm",
172+
description_placeholders=placeholders,
173+
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Constants for the HASS.Agent integration."""
2+
3+
DOMAIN = "hass_agent"
4+
5+
CONF_ACTION = "action"
6+
CONF_DEVICE_NAME = "device_name"
7+
CONF_DEFAULT_NOTIFICATION_TITLE = "default_notification_title"

0 commit comments

Comments
 (0)