Skip to content

Commit 386a9c3

Browse files
author
Ted Roberts
committed
Add background switch/select support with BKG API integration (v0.2.27)
1 parent e6279d7 commit 386a9c3

8 files changed

Lines changed: 267 additions & 5 deletions

File tree

custom_components/novastar_h/api.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,13 @@ class NovastarState:
6161
ftb_active: bool = False # Fade to black (blackout) active
6262
freeze_active: bool = False # Screen freeze active
6363
current_preset_id: int = -1 # -1 means no preset active
64+
background_enabled: bool = False
65+
background_id: int = 0
6466
screens: list[NovastarScreen] = field(default_factory=list)
6567
presets: list[NovastarPreset] = field(default_factory=list)
6668
inputs: list[dict[str, Any]] = field(default_factory=list)
6769
layers: list[dict[str, Any]] = field(default_factory=list)
70+
backgrounds: list[dict[str, Any]] = field(default_factory=list)
6871

6972

7073
class NovastarClient:
@@ -109,6 +112,9 @@ def __init__(
109112
self._last_preset_id: int | None = None
110113
self._force_refresh_input_details = False
111114
self._force_refresh_layer_details = False
115+
self._background_list_cache: list[dict[str, Any]] = []
116+
self._background_refresh_counter = 0
117+
self._force_refresh_backgrounds = False
112118

113119
@property
114120
def host(self) -> str:
@@ -196,7 +202,7 @@ def _build_request(self, body: dict[str, Any]) -> dict[str, Any]:
196202

197203
async def _async_request(
198204
self, endpoint: str, body: dict[str, Any]
199-
) -> dict[str, Any] | None:
205+
) -> Any | None:
200206
"""Send POST request to Novastar API.
201207
202208
Args:
@@ -235,7 +241,9 @@ async def _async_request(
235241
body_data = data.get("body") or data.get("data") or {}
236242
if self._encryption and isinstance(body_data, str):
237243
return self._decrypt_body(body_data)
238-
return body_data if isinstance(body_data, dict) else {}
244+
if isinstance(body_data, (dict, list)):
245+
return body_data
246+
return {}
239247

240248
except aiohttp.ClientError as ex:
241249
_LOGGER.debug("Connection error to %s: %s", url, ex)
@@ -431,9 +439,44 @@ async def async_get_state(
431439
state.signal_status = temp_data.get("signal_status")
432440
state.inputs = await self.async_get_inputs_with_details(device_id)
433441
state.layers = await self.async_get_layers_with_details(device_id, screen_id)
442+
state.backgrounds = await self.async_get_background_list(device_id)
434443

435444
return state
436445

446+
async def async_get_background_list(
447+
self, device_id: int = 0
448+
) -> list[dict[str, Any]]:
449+
"""Get available backgrounds from bkg/readAllList with lightweight caching."""
450+
self._background_refresh_counter += 1
451+
periodic_refresh = self._background_refresh_counter % 12 == 0
452+
should_refresh = periodic_refresh or self._force_refresh_backgrounds
453+
454+
if not self._background_list_cache or should_refresh:
455+
self._force_refresh_backgrounds = False
456+
data = await self._async_request("bkg/readAllList", {"deviceId": device_id})
457+
if isinstance(data, list):
458+
parsed: list[dict[str, Any]] = []
459+
for item in data:
460+
if not isinstance(item, dict):
461+
continue
462+
bkg_id = item.get("bkgId")
463+
if not isinstance(bkg_id, int):
464+
continue
465+
general = item.get("general")
466+
name = item.get("name")
467+
if isinstance(general, dict) and isinstance(general.get("name"), str):
468+
name = general.get("name")
469+
parsed.append(
470+
{
471+
"bkgId": bkg_id,
472+
"name": name if isinstance(name, str) else f"BKG {bkg_id}",
473+
}
474+
)
475+
parsed.sort(key=lambda item: item.get("bkgId", 0))
476+
self._background_list_cache = parsed
477+
478+
return list(self._background_list_cache)
479+
437480
async def async_get_input_list(self, device_id: int = 0) -> list[dict[str, Any]]:
438481
"""Read all available inputs from input/readList."""
439482
data = await self._async_request("input/readList", {"deviceId": device_id})
@@ -654,7 +697,7 @@ async def async_get_device_status_info(
654697

655698
async def async_send_raw_command(
656699
self, endpoint: str, body: dict[str, Any]
657-
) -> dict[str, Any] | None:
700+
) -> Any | None:
658701
"""Send a raw API command.
659702
660703
Args:
@@ -666,6 +709,26 @@ async def async_send_raw_command(
666709
"""
667710
return await self._async_request(endpoint, body)
668711

712+
async def async_set_background(
713+
self,
714+
background_id: int,
715+
enabled: bool,
716+
screen_id: int = 0,
717+
device_id: int = 0,
718+
) -> bool:
719+
"""Set screen background using screen/writeBKG."""
720+
payload = {
721+
"screenId": int(screen_id),
722+
"deviceId": int(device_id),
723+
"enable": 0 if enabled else 1,
724+
"bkgId": max(0, int(background_id)),
725+
}
726+
data = await self._async_request("screen/writeBKG", payload)
727+
if data is not None:
728+
self._force_refresh_backgrounds = True
729+
return True
730+
return False
731+
669732
async def async_set_layer_source(
670733
self,
671734
layer_id: int,

custom_components/novastar_h/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
DEFAULT_SCREEN_ID = 0
2121
DEFAULT_ENCRYPTION = False
2222
DEFAULT_ALLOW_RAW_COMMANDS = False
23-
DEFAULT_LAYER_SELECT_PREPOPULATE_COUNT = 4
23+
DEFAULT_LAYER_SELECT_PREPOPULATE_COUNT = 3
2424

2525
PLATFORMS: list[Platform] = [
2626
Platform.SWITCH,

custom_components/novastar_h/coordinator.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ def __init__(
3030
self._screen_id = screen_id
3131
self._ftb_active = False # Track FTB state locally (API doesn't expose read)
3232
self._freeze_active = False # Track freeze state locally
33+
self._background_enabled = False
34+
self._background_id = 0
3335
super().__init__(
3436
hass,
3537
_LOGGER,
@@ -112,10 +114,47 @@ async def async_set_layer_source(
112114
await self.async_request_refresh()
113115
return result
114116

117+
async def async_set_background_enabled(self, enabled: bool) -> bool:
118+
"""Enable or disable current selected background."""
119+
result = await self._client.async_set_background(
120+
background_id=self._background_id,
121+
enabled=enabled,
122+
screen_id=self._screen_id,
123+
device_id=self._device_id,
124+
)
125+
if result:
126+
self._background_enabled = enabled
127+
if self.data:
128+
self.data.background_enabled = enabled
129+
self.data.background_id = self._background_id
130+
self.async_set_updated_data(self.data)
131+
await self.async_request_refresh()
132+
return result
133+
134+
async def async_set_background(self, background_id: int, enabled: bool = True) -> bool:
135+
"""Set active background id and optional enabled state."""
136+
result = await self._client.async_set_background(
137+
background_id=background_id,
138+
enabled=enabled,
139+
screen_id=self._screen_id,
140+
device_id=self._device_id,
141+
)
142+
if result:
143+
self._background_id = max(0, int(background_id))
144+
self._background_enabled = enabled
145+
if self.data:
146+
self.data.background_id = self._background_id
147+
self.data.background_enabled = self._background_enabled
148+
self.async_set_updated_data(self.data)
149+
await self.async_request_refresh()
150+
return result
151+
115152
async def _async_update_data(self) -> NovastarState:
116153
"""Fetch data from the device."""
117154
state = await self._client.async_get_state(self._screen_id, self._device_id)
118155
# Preserve locally tracked states
119156
state.ftb_active = self._ftb_active
120157
state.freeze_active = self._freeze_active
158+
state.background_enabled = self._background_enabled
159+
state.background_id = self._background_id
121160
return state

custom_components/novastar_h/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@
2121
}
2222
],
2323
"zeroconf": ["_novastar._tcp.local."],
24-
"version": "0.2.26"
24+
"version": "0.2.27"
2525
}

custom_components/novastar_h/select.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ def _layer_input_id(layer: dict[str, Any]) -> int | None:
6363
return _coerce_int(input_id)
6464

6565

66+
def _background_label(background: dict[str, Any]) -> str:
67+
"""Build stable display label for one background item."""
68+
bkg_id = _coerce_int(background.get("bkgId"))
69+
name = background.get("name")
70+
if isinstance(name, str) and name.strip():
71+
clean_name = name.strip()
72+
elif bkg_id is not None:
73+
clean_name = f"BKG {bkg_id}"
74+
else:
75+
clean_name = "Background"
76+
77+
if bkg_id is not None:
78+
return f"{clean_name} ({bkg_id})"
79+
return clean_name
80+
81+
6682
async def async_setup_entry(
6783
hass: HomeAssistant,
6884
entry: ConfigEntry,
@@ -81,6 +97,7 @@ async def async_setup_entry(
8197
layer_count = _coerce_int(layer_count) or DEFAULT_LAYER_SELECT_PREPOPULATE_COUNT
8298

8399
entities: list[SelectEntity] = [NovastarPresetSelect(entry, coordinator, device_info)]
100+
entities.append(NovastarBackgroundSelect(entry, coordinator, device_info))
84101
entities.extend(
85102
[
86103
NovastarLayerSourceSelect(entry, coordinator, device_info, layer_id)
@@ -317,3 +334,101 @@ async def async_select_option(self, option: str) -> None:
317334
slot_id=slot_id or 0,
318335
crop_id=255,
319336
)
337+
338+
339+
class NovastarBackgroundSelect(CoordinatorEntity[NovastarCoordinator], SelectEntity):
340+
"""Select entity for active background."""
341+
342+
_attr_has_entity_name = True
343+
_attr_name = "Background"
344+
_attr_translation_key = "background"
345+
346+
def __init__(
347+
self,
348+
entry: ConfigEntry,
349+
coordinator: NovastarCoordinator,
350+
device_info: NovastarDeviceInfo,
351+
) -> None:
352+
"""Initialize background select."""
353+
super().__init__(coordinator)
354+
self._entry = entry
355+
self._device_info = device_info
356+
self._attr_unique_id = f"{entry.entry_id}_background"
357+
358+
@property
359+
def device_info(self):
360+
"""Return device info."""
361+
model = "H Series"
362+
if self._device_info.model_id:
363+
model = f"H Series (Model {self._device_info.model_id})"
364+
return {
365+
"identifiers": {(DOMAIN, self._entry.entry_id)},
366+
"manufacturer": "Novastar",
367+
"model": model,
368+
"name": self._entry.data.get(CONF_NAME, DEFAULT_NAME),
369+
"sw_version": self._device_info.firmware,
370+
"serial_number": self._device_info.serial,
371+
}
372+
373+
@property
374+
def available(self) -> bool:
375+
"""Return True if entity is available."""
376+
return self.coordinator.last_update_success
377+
378+
def _background_map(self) -> dict[str, int]:
379+
"""Return label->background_id map."""
380+
if not self.coordinator.data:
381+
return {}
382+
383+
mapped: dict[str, int] = {}
384+
for background in self.coordinator.data.backgrounds:
385+
bkg_id = _coerce_int(background.get("bkgId"))
386+
if bkg_id is None:
387+
continue
388+
mapped[_background_label(background)] = bkg_id
389+
return dict(sorted(mapped.items(), key=lambda item: item[0]))
390+
391+
@property
392+
def options(self) -> list[str]:
393+
"""Return available background options."""
394+
options = list(self._background_map().keys())
395+
current = self.current_option
396+
if current and current not in options:
397+
options.append(current)
398+
if not options:
399+
options.append("Background 0")
400+
return options
401+
402+
@property
403+
def current_option(self) -> str | None:
404+
"""Return currently selected background option."""
405+
if not self.coordinator.data:
406+
return None
407+
408+
current_id = _coerce_int(self.coordinator.data.background_id)
409+
if current_id is None:
410+
return None
411+
412+
for label, bkg_id in self._background_map().items():
413+
if bkg_id == current_id:
414+
return label
415+
416+
return f"Background {current_id}"
417+
418+
async def async_select_option(self, option: str) -> None:
419+
"""Set active background and enable it."""
420+
bkg_id = self._background_map().get(option)
421+
if bkg_id is None:
422+
text = option.strip()
423+
try:
424+
if text.startswith("Background "):
425+
bkg_id = int(text.replace("Background ", "").strip())
426+
elif text.startswith("BKG "):
427+
bkg_id = int(text.replace("BKG ", "").strip())
428+
except ValueError:
429+
return
430+
431+
if bkg_id is None:
432+
return
433+
434+
await self.coordinator.async_set_background(background_id=bkg_id, enabled=True)

custom_components/novastar_h/strings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,18 @@
107107
},
108108
"freeze": {
109109
"name": "Freeze Screen"
110+
},
111+
"background": {
112+
"name": "Background"
110113
}
111114
},
112115
"select": {
113116
"preset": {
114117
"name": "Preset"
115118
},
119+
"background": {
120+
"name": "Background"
121+
},
116122
"layer_source": {
117123
"name": "Layer Source"
118124
}

custom_components/novastar_h/switch.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ async def async_setup_entry(
2626
entities = [
2727
NovastarFTBSwitch(entry, coordinator, device_info),
2828
NovastarFreezeSwitch(entry, coordinator, device_info),
29+
NovastarBackgroundSwitch(entry, coordinator, device_info),
2930
]
3031
async_add_entities(entities)
3132

@@ -138,3 +139,35 @@ async def async_turn_on(self, **kwargs: Any) -> None:
138139
async def async_turn_off(self, **kwargs: Any) -> None:
139140
"""Unfreeze screen."""
140141
await self.coordinator.async_set_freeze(freeze=False)
142+
143+
144+
class NovastarBackgroundSwitch(NovastarSwitchBase):
145+
"""Switch for background display control."""
146+
147+
_attr_name = "Background"
148+
_attr_translation_key = "background"
149+
150+
def __init__(
151+
self,
152+
entry: ConfigEntry,
153+
coordinator: NovastarCoordinator,
154+
device_info: NovastarDeviceInfo,
155+
) -> None:
156+
"""Initialize background switch."""
157+
super().__init__(entry, coordinator, device_info)
158+
self._attr_unique_id = f"{entry.entry_id}_background"
159+
160+
@property
161+
def is_on(self) -> bool:
162+
"""Return True if background is enabled."""
163+
if self.coordinator.data:
164+
return self.coordinator.data.background_enabled
165+
return False
166+
167+
async def async_turn_on(self, **kwargs: Any) -> None:
168+
"""Enable background."""
169+
await self.coordinator.async_set_background_enabled(enabled=True)
170+
171+
async def async_turn_off(self, **kwargs: Any) -> None:
172+
"""Disable background."""
173+
await self.coordinator.async_set_background_enabled(enabled=False)

0 commit comments

Comments
 (0)