Skip to content

Commit d04afd8

Browse files
committed
feat: absolute volume, picture modes, sleep timer, SSDP discovery
Volume: - Improved coordinator volume parsing with fallback to first target - set_volume_level already converts 0.0-1.0 to absolute value correctly Picture Mode: - New select entity for scene/picture mode (Standard, Vivid, Cinema, etc.) - Added getSceneSetting/setSceneSetting to API client - Modes discovered at runtime, defaults as fallback Sleep Timer: - New select entity with Off, 15, 30, 45, 60, 90, 120 min options - Added getSleepTimerSettings/setSleepTimerSettings to API client SSDP Discovery: - TV auto-detected on local network via UPnP/SSDP - Service type: urn:schemas-sony-com:service:ScalarWebAPI:1 - Config flow pre-fills IP when TV discovered - Tries to fetch model info without PSK for discovery notification Bump version to 1.3.0
1 parent ca7eca0 commit d04afd8

8 files changed

Lines changed: 258 additions & 15 deletions

File tree

custom_components/bravia_rest_api/bravia_client.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,34 @@ async def set_scene_setting(self, scene: str) -> None:
498498
SERVICE_VIDEO_SCREEN, "setSceneSetting", [{"scene": scene}]
499499
)
500500

501+
async def get_scene_setting(self) -> str:
502+
"""Get current scene/picture mode setting."""
503+
result = await self._request(SERVICE_VIDEO_SCREEN, "getSceneSetting")
504+
if result and isinstance(result[0], dict):
505+
return result[0].get("scene", "")
506+
if result and isinstance(result[0], str):
507+
return result[0]
508+
return ""
509+
510+
# ------------------------------------------------------------------
511+
# Sleep timer (system service)
512+
# ------------------------------------------------------------------
513+
514+
async def get_sleep_timer_settings(self) -> list[dict[str, Any]]:
515+
"""Get sleep timer settings."""
516+
result = await self._request(
517+
SERVICE_SYSTEM, "getSleepTimerSettings", [{"target": ""}]
518+
)
519+
return result[0] if result and isinstance(result[0], list) else result
520+
521+
async def set_sleep_timer_settings(
522+
self, settings: list[dict[str, str]]
523+
) -> None:
524+
"""Set sleep timer settings."""
525+
await self._request(
526+
SERVICE_SYSTEM, "setSleepTimerSettings", [{"settings": settings}]
527+
)
528+
501529
# ------------------------------------------------------------------
502530
# IRCC (infrared remote control)
503531
# ------------------------------------------------------------------

custom_components/bravia_rest_api/config_flow.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
import logging
66
from typing import Any
7+
from urllib.parse import urlparse
78

89
import aiohttp
910
import voluptuous as vol
1011

12+
from homeassistant.components import ssdp
1113
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
1214
from homeassistant.const import CONF_HOST
1315
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -30,11 +32,16 @@
3032
)
3133

3234

33-
class SonyBraviaProConfigFlow(ConfigFlow, domain=DOMAIN):
35+
class BraviaRestApiConfigFlow(ConfigFlow, domain=DOMAIN):
3436
"""Handle a config flow for Bravia REST API."""
3537

3638
VERSION = 1
3739

40+
def __init__(self) -> None:
41+
"""Initialize the config flow."""
42+
self._discovered_host: str | None = None
43+
self._discovered_model: str | None = None
44+
3845
async def async_step_user(
3946
self, user_input: dict[str, Any] | None = None
4047
) -> ConfigFlowResult:
@@ -82,8 +89,65 @@ async def async_step_user(
8289
},
8390
)
8491

92+
# Pre-fill host if discovered via SSDP
93+
schema = STEP_USER_DATA_SCHEMA
94+
if self._discovered_host:
95+
schema = vol.Schema(
96+
{
97+
vol.Required(CONF_HOST, default=self._discovered_host): str,
98+
vol.Required(CONF_PSK): str,
99+
}
100+
)
101+
85102
return self.async_show_form(
86103
step_id="user",
87-
data_schema=STEP_USER_DATA_SCHEMA,
104+
data_schema=schema,
88105
errors=errors,
106+
description_placeholders={"model": self._discovered_model or ""},
107+
)
108+
109+
async def async_step_ssdp(
110+
self, discovery_info: ssdp.SsdpServiceInfo
111+
) -> ConfigFlowResult:
112+
"""Handle SSDP discovery of a Sony Bravia TV."""
113+
# Extract host from SSDP location URL
114+
location = discovery_info.ssdp_location or ""
115+
parsed = urlparse(location)
116+
host = parsed.hostname or ""
117+
118+
if not host:
119+
return self.async_abort(reason="cannot_connect")
120+
121+
_LOGGER.info(
122+
"Discovered Sony Bravia TV via SSDP at %s: %s",
123+
host,
124+
discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "Unknown"),
89125
)
126+
127+
# Try to get system info to find unique_id
128+
session = async_get_clientsession(self.hass)
129+
# Try without PSK for read-only endpoints
130+
client = BraviaClient(host, "", session)
131+
132+
try:
133+
system_info = await client.get_system_info()
134+
serial = system_info.get("serial", "")
135+
mac = system_info.get("macAddr", "")
136+
model = system_info.get("model", "")
137+
unique_id = serial or mac or host
138+
except BraviaError:
139+
# System info may require PSK — use host as fallback
140+
unique_id = host
141+
model = discovery_info.upnp.get(
142+
ssdp.ATTR_UPNP_MODEL_NAME, "Sony Bravia"
143+
)
144+
145+
await self.async_set_unique_id(unique_id)
146+
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
147+
148+
self._discovered_host = host
149+
self._discovered_model = model
150+
151+
self.context["title_placeholders"] = {"name": model or host}
152+
153+
return await self.async_step_user()

custom_components/bravia_rest_api/const.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,29 @@
7676
270: "270\u00b0",
7777
}
7878

79+
# Sleep timer options (value -> display label)
80+
SLEEP_TIMER_OPTIONS: Final = {
81+
"off": "Off",
82+
"15": "15 min",
83+
"30": "30 min",
84+
"45": "45 min",
85+
"60": "60 min",
86+
"90": "90 min",
87+
"120": "120 min",
88+
}
89+
90+
# Default picture modes (fallback — actual modes discovered at runtime)
91+
DEFAULT_PICTURE_MODES: Final = [
92+
"Standard",
93+
"Vivid",
94+
"Cinema",
95+
"Custom",
96+
"Game",
97+
"Graphics",
98+
"Photo",
99+
"Sports",
100+
]
101+
79102
# WoL
80103
WOL_PORT: Final = 9
81104

custom_components/bravia_rest_api/coordinator.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,20 @@ async def _async_update_data(self) -> BraviaState:
124124
# TV is on — fetch detailed state
125125
try:
126126
volume_info = await self.client.get_volume_info()
127+
# Prefer "speaker" or "" target, fall back to first available
128+
chosen = None
127129
for item in volume_info:
128-
if isinstance(item, dict) and item.get("target") in (
129-
"speaker",
130-
"",
131-
):
132-
state.volume = item.get("volume")
133-
state.is_muted = item.get("mute", False)
134-
state.max_volume = item.get("maxVolume", 100)
135-
state.min_volume = item.get("minVolume", 0)
136-
break
130+
if isinstance(item, dict):
131+
if item.get("target") in ("speaker", ""):
132+
chosen = item
133+
break
134+
if chosen is None:
135+
chosen = item
136+
if chosen:
137+
state.volume = chosen.get("volume")
138+
state.is_muted = chosen.get("mute", False)
139+
state.max_volume = chosen.get("maxVolume", 100)
140+
state.min_volume = chosen.get("minVolume", 0)
137141
except BraviaError as err:
138142
_LOGGER.debug("Could not fetch volume info: %s", err)
139143

custom_components/bravia_rest_api/manifest.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
"issue_tracker": "https://github.com/cmos486/Bravia-REST-API/issues",
88
"integration_type": "device",
99
"iot_class": "local_polling",
10-
"version": "1.2.1",
10+
"version": "1.3.0",
1111
"requirements": [],
12-
"homeassistant": "2024.1.0"
12+
"homeassistant": "2024.1.0",
13+
"ssdp": [
14+
{
15+
"st": "urn:schemas-sony-com:service:ScalarWebAPI:1"
16+
}
17+
]
1318
}

custom_components/bravia_rest_api/select.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111

1212
from .bravia_client import BraviaError
1313
from .const import (
14+
DEFAULT_PICTURE_MODES,
1415
DOMAIN,
1516
SCREEN_ROTATION_OPTIONS,
17+
SLEEP_TIMER_OPTIONS,
1618
SOUND_OUTPUT_OPTIONS,
1719
)
1820
from .coordinator import BraviaCoordinator
@@ -32,6 +34,8 @@ async def async_setup_entry(
3234
[
3335
BraviaSoundOutputSelect(coordinator, entry),
3436
BraviaScreenRotationSelect(coordinator, entry),
37+
BraviaPictureModeSelect(coordinator, entry),
38+
BraviaSleepTimerSelect(coordinator, entry),
3539
]
3640
)
3741

@@ -142,3 +146,104 @@ async def async_added_to_hass(self) -> None:
142146
self._current_angle = await self.coordinator.client.get_screen_rotation()
143147
except BraviaError:
144148
pass
149+
150+
151+
class BraviaPictureModeSelect(BraviaEntity, SelectEntity):
152+
"""Select entity for picture mode (scene setting)."""
153+
154+
_attr_translation_key = "picture_mode"
155+
_attr_icon = "mdi:image-filter-hdr"
156+
157+
def __init__(
158+
self,
159+
coordinator: BraviaCoordinator,
160+
entry: ConfigEntry,
161+
) -> None:
162+
super().__init__(coordinator, entry)
163+
self._attr_unique_id = f"{entry.unique_id}_picture_mode"
164+
self._attr_options = list(DEFAULT_PICTURE_MODES)
165+
self._current: str | None = None
166+
167+
@property
168+
def current_option(self) -> str | None:
169+
"""Return current picture mode."""
170+
return self._current
171+
172+
async def async_select_option(self, option: str) -> None:
173+
"""Set picture mode."""
174+
try:
175+
await self.coordinator.client.set_scene_setting(option.lower())
176+
self._current = option
177+
except BraviaError as err:
178+
_LOGGER.error("Failed to set picture mode: %s", err)
179+
180+
async def async_added_to_hass(self) -> None:
181+
"""Fetch current picture mode and discover available modes."""
182+
await super().async_added_to_hass()
183+
try:
184+
current = await self.coordinator.client.get_scene_setting()
185+
if current:
186+
self._current = current.capitalize()
187+
# Ensure current mode is in options
188+
if self._current not in self._attr_options:
189+
self._attr_options.append(self._current)
190+
except BraviaError:
191+
pass
192+
193+
194+
class BraviaSleepTimerSelect(BraviaEntity, SelectEntity):
195+
"""Select entity for sleep timer."""
196+
197+
_attr_translation_key = "sleep_timer"
198+
_attr_icon = "mdi:timer-outline"
199+
200+
def __init__(
201+
self,
202+
coordinator: BraviaCoordinator,
203+
entry: ConfigEntry,
204+
) -> None:
205+
super().__init__(coordinator, entry)
206+
self._attr_unique_id = f"{entry.unique_id}_sleep_timer"
207+
self._attr_options = list(SLEEP_TIMER_OPTIONS.values())
208+
self._current_value: str = "off"
209+
210+
@property
211+
def current_option(self) -> str | None:
212+
"""Return current sleep timer setting."""
213+
return SLEEP_TIMER_OPTIONS.get(self._current_value)
214+
215+
async def async_select_option(self, option: str) -> None:
216+
"""Set sleep timer."""
217+
# Reverse lookup: display name -> API value
218+
api_value = None
219+
for key, label in SLEEP_TIMER_OPTIONS.items():
220+
if label == option:
221+
api_value = key
222+
break
223+
224+
if api_value is None:
225+
_LOGGER.error("Unknown sleep timer option: %s", option)
226+
return
227+
228+
try:
229+
await self.coordinator.client.set_sleep_timer_settings(
230+
[{"target": "sleepTimer", "value": api_value}]
231+
)
232+
self._current_value = api_value
233+
except BraviaError as err:
234+
_LOGGER.error("Failed to set sleep timer: %s", err)
235+
236+
async def async_added_to_hass(self) -> None:
237+
"""Fetch initial sleep timer state."""
238+
await super().async_added_to_hass()
239+
try:
240+
settings = await self.coordinator.client.get_sleep_timer_settings()
241+
if settings:
242+
for setting in settings:
243+
if isinstance(setting, dict):
244+
value = str(setting.get("currentValue", "off"))
245+
if value in SLEEP_TIMER_OPTIONS:
246+
self._current_value = value
247+
break
248+
except BraviaError:
249+
pass

custom_components/bravia_rest_api/strings.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"invalid_auth": "Invalid Pre-Shared Key. Check the PSK configured on your TV."
2020
},
2121
"abort": {
22-
"already_configured": "This TV is already configured."
22+
"already_configured": "This TV is already configured.",
23+
"cannot_connect": "Cannot connect to the discovered TV."
2324
}
2425
},
2526
"entity": {
@@ -43,6 +44,12 @@
4344
},
4445
"screen_rotation": {
4546
"name": "Screen Rotation"
47+
},
48+
"picture_mode": {
49+
"name": "Picture Mode"
50+
},
51+
"sleep_timer": {
52+
"name": "Sleep Timer"
4653
}
4754
},
4855
"sensor": {

custom_components/bravia_rest_api/translations/es.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"invalid_auth": "Clave Pre-Compartida inv\u00e1lida. Verifica el PSK configurado en tu TV."
2020
},
2121
"abort": {
22-
"already_configured": "Esta TV ya est\u00e1 configurada."
22+
"already_configured": "Esta TV ya est\u00e1 configurada.",
23+
"cannot_connect": "No se puede conectar a la TV detectada."
2324
}
2425
},
2526
"entity": {
@@ -43,6 +44,12 @@
4344
},
4445
"screen_rotation": {
4546
"name": "Rotaci\u00f3n de Pantalla"
47+
},
48+
"picture_mode": {
49+
"name": "Modo de Imagen"
50+
},
51+
"sleep_timer": {
52+
"name": "Temporizador de Sue\u00f1o"
4653
}
4754
},
4855
"sensor": {

0 commit comments

Comments
 (0)