Skip to content

Commit 2f1d788

Browse files
cmos486claude
andcommitted
fix: source_list HTML entity decoding and optional excluded_sources filter
Resolves #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e16c563 commit 2f1d788

8 files changed

Lines changed: 208 additions & 12 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,32 @@ Before installing, make sure your TV is configured:
7272
<img src="https://raw.githubusercontent.com/cmos486/Bravia-REST-API/main/images/integration_installed.jpg" alt="Integration installed in Home Assistant" width="600"/>
7373
</p>
7474

75+
### Excluded Sources
76+
77+
Optionally hide specific apps or inputs from the source list dropdown. Excluded sources
78+
are still launchable via the `bravia_rest_api.open_app` service and remain visible in the
79+
`installed_apps` attribute.
80+
81+
**Option A: UI (Options Flow)**
82+
83+
1. Go to **Settings > Devices & Services > Bravia REST API**
84+
2. Click **Configure**
85+
3. Select the sources you want to exclude from the dropdown
86+
4. Click **Submit**
87+
88+
> If the TV is off when you open the options, a text field is shown instead — enter one source name per line.
89+
90+
**Option B: YAML**
91+
92+
```yaml
93+
bravia_rest_api:
94+
excluded_sources:
95+
- "Timers & Clock"
96+
- "Eco Dashboard"
97+
```
98+
99+
> If both UI and YAML are configured, the UI selection takes priority.
100+
75101
---
76102
77103
## 🎮 Entities Created

custom_components/bravia_rest_api/__init__.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import html
56
import logging
67
from typing import Any
78

@@ -14,7 +15,15 @@
1415
from homeassistant.helpers.aiohttp_client import async_get_clientsession
1516

1617
from .bravia_client import BraviaClient, BraviaError
17-
from .const import CONF_PSK, DOMAIN, IRCC_CODES, POWER_SAVING_OFF, POWER_SAVING_PICTURE_OFF
18+
from .const import (
19+
CONF_EXCLUDED_SOURCES,
20+
CONF_PSK,
21+
DOMAIN,
22+
IRCC_CODES,
23+
POWER_SAVING_OFF,
24+
POWER_SAVING_PICTURE_OFF,
25+
YAML_CONFIG_KEY,
26+
)
1827
from .coordinator import BraviaCoordinator
1928

2029
_LOGGER = logging.getLogger(__name__)
@@ -73,6 +82,26 @@
7382
}
7483
)
7584

85+
CONFIG_SCHEMA = vol.Schema(
86+
{
87+
DOMAIN: vol.Schema(
88+
{
89+
vol.Optional(CONF_EXCLUDED_SOURCES, default=[]): vol.All(
90+
cv.ensure_list, [cv.string]
91+
),
92+
}
93+
)
94+
},
95+
extra=vol.ALLOW_EXTRA,
96+
)
97+
98+
99+
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
100+
"""Store YAML configuration if present."""
101+
if DOMAIN in config:
102+
hass.data[YAML_CONFIG_KEY] = config[DOMAIN]
103+
return True
104+
76105

77106
def _get_coordinator(hass: HomeAssistant, entity_id: str) -> BraviaCoordinator | None:
78107
"""Find the coordinator for a given entity_id."""
@@ -98,13 +127,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
98127

99128
await hass.config_entries.async_forward_entry_setups(entry, PLATFORM_LIST)
100129

130+
# Refresh entities when options change (e.g. excluded_sources)
131+
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
132+
101133
# Register services (only once, on first entry)
102134
if not hass.services.has_service(DOMAIN, SERVICE_OPEN_APP):
103135
_register_services(hass)
104136

105137
return True
106138

107139

140+
async def _async_update_listener(
141+
hass: HomeAssistant, entry: ConfigEntry
142+
) -> None:
143+
"""Handle options update without restarting the coordinator."""
144+
coordinator = hass.data[DOMAIN].get(entry.entry_id)
145+
if coordinator:
146+
coordinator.async_set_updated_data(coordinator.data)
147+
148+
108149
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
109150
"""Unload a config entry."""
110151
if unload_ok := await hass.config_entries.async_unload_platforms(
@@ -144,19 +185,19 @@ async def handle_open_app(call: ServiceCall) -> None:
144185
return
145186

146187
if not app_uri and app_name:
147-
# Look up by name (case-insensitive)
188+
# Look up by name (case-insensitive, HTML-decoded)
148189
name_lower = app_name.lower()
149190
if coordinator.data and coordinator.data.app_list:
150191
for app in coordinator.data.app_list:
151-
if app.get("title", "").lower() == name_lower:
192+
if html.unescape(app.get("title", "")).lower() == name_lower:
152193
app_uri = app.get("uri")
153194
break
154195
if not app_uri:
155196
_LOGGER.error(
156197
"App '%s' not found. Available: %s",
157198
app_name,
158199
", ".join(
159-
a.get("title", "?")
200+
html.unescape(a.get("title", "?"))
160201
for a in (coordinator.data.app_list if coordinator.data else [])
161202
),
162203
)

custom_components/bravia_rest_api/config_flow.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import html
56
import logging
67
from typing import Any
78
from urllib.parse import urlparse
@@ -10,17 +11,25 @@
1011
import voluptuous as vol
1112

1213
from homeassistant.components import ssdp
13-
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
14+
from homeassistant.config_entries import (
15+
ConfigEntry,
16+
ConfigFlow,
17+
ConfigFlowResult,
18+
OptionsFlow,
19+
)
1420
from homeassistant.const import CONF_HOST
21+
from homeassistant.core import callback
22+
from homeassistant.helpers import config_validation as cv
1523
from homeassistant.helpers.aiohttp_client import async_get_clientsession
24+
from homeassistant.helpers.selector import TextSelector, TextSelectorConfig
1625

1726
from .bravia_client import (
1827
BraviaAuthError,
1928
BraviaClient,
2029
BraviaConnectionError,
2130
BraviaError,
2231
)
23-
from .const import CONF_MAC, CONF_PSK, DOMAIN
32+
from .const import CONF_EXCLUDED_SOURCES, CONF_MAC, CONF_PSK, DOMAIN
2433

2534
_LOGGER = logging.getLogger(__name__)
2635

@@ -37,6 +46,12 @@ class BraviaRestApiConfigFlow(ConfigFlow, domain=DOMAIN):
3746

3847
VERSION = 1
3948

49+
@staticmethod
50+
@callback
51+
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
52+
"""Create the options flow."""
53+
return BraviaRestApiOptionsFlow(config_entry)
54+
4055
def __init__(self) -> None:
4156
"""Initialize the config flow."""
4257
self._discovered_host: str | None = None
@@ -151,3 +166,74 @@ async def async_step_ssdp(
151166
self.context["title_placeholders"] = {"name": model or host}
152167

153168
return await self.async_step_user()
169+
170+
171+
class BraviaRestApiOptionsFlow(OptionsFlow):
172+
"""Handle options for Bravia REST API."""
173+
174+
def __init__(self, config_entry: ConfigEntry) -> None:
175+
"""Initialize options flow."""
176+
self.config_entry = config_entry
177+
178+
async def async_step_init(
179+
self, user_input: dict[str, Any] | None = None
180+
) -> ConfigFlowResult:
181+
"""Manage the options."""
182+
if user_input is not None:
183+
excluded = user_input.get(CONF_EXCLUDED_SOURCES, [])
184+
if isinstance(excluded, str):
185+
# Text area fallback: split by newlines
186+
excluded = [s.strip() for s in excluded.splitlines() if s.strip()]
187+
return self.async_create_entry(
188+
title="", data={CONF_EXCLUDED_SOURCES: excluded}
189+
)
190+
191+
# Build available sources from coordinator
192+
coordinator = self.hass.data.get(DOMAIN, {}).get(
193+
self.config_entry.entry_id
194+
)
195+
sources: list[str] = []
196+
if coordinator and coordinator.data:
197+
data = coordinator.data
198+
for inp in data.external_inputs:
199+
custom_label = inp.get("label", "")
200+
label = inp.get("title", "")
201+
name = custom_label if custom_label else label
202+
if name:
203+
sources.append(name)
204+
for app in data.app_list:
205+
title = html.unescape(app.get("title", ""))
206+
if title:
207+
sources.append(title)
208+
209+
current_excluded = self.config_entry.options.get(
210+
CONF_EXCLUDED_SOURCES, []
211+
)
212+
213+
if sources:
214+
# Multi-select with current sources
215+
source_lower_map = {s.lower(): s for s in sources}
216+
valid_default = [
217+
source_lower_map[e.lower()]
218+
for e in current_excluded
219+
if e.lower() in source_lower_map
220+
]
221+
schema = vol.Schema(
222+
{
223+
vol.Optional(
224+
CONF_EXCLUDED_SOURCES, default=valid_default
225+
): cv.multi_select({s: s for s in sorted(sources)}),
226+
}
227+
)
228+
else:
229+
# TV off fallback: multiline text input
230+
schema = vol.Schema(
231+
{
232+
vol.Optional(
233+
CONF_EXCLUDED_SOURCES,
234+
default="\n".join(current_excluded),
235+
): TextSelector(TextSelectorConfig(multiline=True)),
236+
}
237+
)
238+
239+
return self.async_show_form(step_id="init", data_schema=schema)

custom_components/bravia_rest_api/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
CONF_PSK: Final = "psk"
88
CONF_MAC: Final = "mac"
9+
CONF_EXCLUDED_SOURCES: Final = "excluded_sources"
10+
11+
YAML_CONFIG_KEY: Final = f"{DOMAIN}_yaml"
912

1013
DEFAULT_PORT: Final = 80
1114
DEFAULT_SCAN_INTERVAL: Final = 15 # seconds

custom_components/bravia_rest_api/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"issue_tracker": "https://github.com/cmos486/Bravia-REST-API/issues",
88
"integration_type": "device",
99
"iot_class": "local_polling",
10-
"version": "1.3.1",
10+
"version": "1.3.2",
1111
"requirements": [],
1212
"homeassistant": "2024.1.0",
1313
"ssdp": [

custom_components/bravia_rest_api/media_player.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import html
67
import logging
78
import socket
89
from typing import Any
@@ -18,7 +19,7 @@
1819
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1920

2021
from .bravia_client import BraviaError
21-
from .const import CONF_MAC, DOMAIN, WOL_PORT
22+
from .const import CONF_EXCLUDED_SOURCES, CONF_MAC, DOMAIN, WOL_PORT, YAML_CONFIG_KEY
2223
from .coordinator import BraviaCoordinator
2324
from .entity import BraviaEntity
2425

@@ -96,7 +97,7 @@ def _build_source_map(self) -> None:
9697
self._sources[name] = {"uri": uri, "type": SOURCE_TYPE_INPUT}
9798

9899
for app in data.app_list:
99-
title = app.get("title", "")
100+
title = html.unescape(app.get("title", ""))
100101
uri = app.get("uri", "")
101102
if title and uri:
102103
self._sources[title] = {"uri": uri, "type": SOURCE_TYPE_APP}
@@ -141,13 +142,24 @@ def source(self) -> str | None:
141142
if src["uri"] == uri:
142143
return name
143144

144-
return title or uri or None
145+
return html.unescape(title) if title else uri or None
146+
147+
def _get_excluded_sources(self) -> set[str]:
148+
"""Get excluded sources (case-insensitive). Options flow wins over YAML."""
149+
excluded = self._entry.options.get(CONF_EXCLUDED_SOURCES)
150+
if excluded is None:
151+
yaml_cfg = self.hass.data.get(YAML_CONFIG_KEY, {})
152+
excluded = yaml_cfg.get(CONF_EXCLUDED_SOURCES, [])
153+
return {s.lower() for s in excluded}
145154

146155
@property
147156
def source_list(self) -> list[str]:
148157
"""Return the list of available sources."""
149158
self._build_source_map()
150-
return list(self._sources.keys())
159+
excluded = self._get_excluded_sources()
160+
if not excluded:
161+
return list(self._sources.keys())
162+
return [name for name in self._sources if name.lower() not in excluded]
151163

152164
@property
153165
def media_title(self) -> str | None:
@@ -164,7 +176,7 @@ def extra_state_attributes(self) -> dict[str, Any]:
164176
data = self.coordinator.data
165177
if data and data.app_list:
166178
attrs["installed_apps"] = {
167-
app.get("title", "Unknown"): app.get("uri", "")
179+
html.unescape(app.get("title", "Unknown")): app.get("uri", "")
168180
for app in data.app_list
169181
if app.get("title") and app.get("uri")
170182
}

custom_components/bravia_rest_api/strings.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@
2323
"cannot_connect": "Cannot connect to the discovered TV."
2424
}
2525
},
26+
"options": {
27+
"step": {
28+
"init": {
29+
"title": "Bravia REST API Options",
30+
"description": "Configure source list filtering.",
31+
"data": {
32+
"excluded_sources": "Excluded Sources"
33+
},
34+
"data_description": {
35+
"excluded_sources": "Sources to hide from the source list dropdown. If the TV is off, enter one source name per line."
36+
}
37+
}
38+
}
39+
},
2640
"entity": {
2741
"button": {
2842
"reboot": {

custom_components/bravia_rest_api/translations/es.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@
2323
"cannot_connect": "No se puede conectar a la TV detectada."
2424
}
2525
},
26+
"options": {
27+
"step": {
28+
"init": {
29+
"title": "Opciones Bravia REST API",
30+
"description": "Configura el filtrado de la lista de fuentes.",
31+
"data": {
32+
"excluded_sources": "Fuentes Excluidas"
33+
},
34+
"data_description": {
35+
"excluded_sources": "Fuentes a ocultar del desplegable de fuentes. Si la TV esta apagada, introduce un nombre de fuente por linea."
36+
}
37+
}
38+
}
39+
},
2640
"entity": {
2741
"button": {
2842
"reboot": {

0 commit comments

Comments
 (0)