Skip to content

Commit a20ee59

Browse files
authored
Merge pull request #220 from dinki/dev
Push Dev to Main
2 parents 2238ec4 + d59b410 commit a20ee59

File tree

11 files changed

+340
-37
lines changed

11 files changed

+340
-37
lines changed

custom_components/view_assist/config_flow.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@
5858
CONF_MIC_DEVICE,
5959
CONF_MIC_UNMUTE,
6060
CONF_MUSIC,
61+
CONF_MUSIC_MODE_AUTO,
62+
CONF_MUSIC_MODE_TIMEOUT,
6163
CONF_MUSICPLAYER_DEVICE,
64+
CONF_ORIENTATION_SENSOR,
6265
CONF_ROTATE_BACKGROUND_INTERVAL,
6366
CONF_ROTATE_BACKGROUND_LINKED_ENTITY,
6467
CONF_ROTATE_BACKGROUND_PATH,
@@ -131,6 +134,9 @@
131134
vol.Optional(CONF_INTENT_DEVICE, default=vol.UNDEFINED): EntitySelector(
132135
EntitySelectorConfig(domain=SENSOR_DOMAIN)
133136
),
137+
vol.Optional(CONF_ORIENTATION_SENSOR, default=vol.UNDEFINED): EntitySelector(
138+
EntitySelectorConfig(domain=SENSOR_DOMAIN)
139+
),
134140
}
135141
)
136142

@@ -155,12 +161,14 @@ def get_display_id(device_id: str) -> str | None:
155161

156162
entity_registry = er.async_get(hass)
157163
entities = er.async_entries_for_device(entity_registry, device_id)
164+
158165
return {
159166
CONF_MIC_DEVICE: get_entity("assist_satellite", "", entities) or "",
160167
CONF_MEDIAPLAYER_DEVICE: get_entity("media_player", "", entities) or "",
161168
CONF_MUSICPLAYER_DEVICE: get_entity("media_player", "", entities) or "",
162169
CONF_INTENT_DEVICE: get_entity("sensor", "intent", entities) or "",
163170
CONF_DISPLAY_DEVICE: get_display_id(device_id) or "",
171+
CONF_ORIENTATION_SENSOR: get_entity("sensor", "orientation", entities) or "",
164172
}
165173

166174

@@ -351,6 +359,16 @@ async def get_dashboard_options_schema(
351359
mode=NumberSelectorMode.BOX,
352360
)
353361
),
362+
vol.Optional(CONF_MUSIC_MODE_AUTO): BooleanSelector(),
363+
vol.Optional(CONF_MUSIC_MODE_TIMEOUT): NumberSelector(
364+
NumberSelectorConfig(
365+
min=0,
366+
max=3600,
367+
step=1,
368+
mode=NumberSelectorMode.BOX,
369+
unit_of_measurement="seconds",
370+
)
371+
),
354372
}
355373
)
356374

custom_components/view_assist/const.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
{
4949
"name": "View Assist Helper",
5050
"filename": "view_assist.js",
51-
"version": "1.0.24",
51+
"version": "1.0.25",
5252
},
5353
]
5454
# mins between checks for updated versions of dashboard and views
@@ -64,6 +64,7 @@ class VAMode(StrEnum):
6464
HOLD = "hold"
6565
NIGHT = "night"
6666
ROTATE = "rotate"
67+
GAME = "game"
6768

6869

6970
VAMODE_REVERTS = {
@@ -85,6 +86,7 @@ class VAMode(StrEnum):
8586
CONF_MUSICPLAYER_DEVICE = "musicplayer_device"
8687
CONF_DISPLAY_DEVICE = "display_device"
8788
CONF_INTENT_DEVICE = "intent_device"
89+
CONF_ORIENTATION_SENSOR = "orientation_sensor"
8890

8991
CONF_DASHBOARD = "dashboard"
9092
CONF_HOME = "home"
@@ -115,6 +117,8 @@ class VAMode(StrEnum):
115117
CONF_USE_ANNOUNCE = "use_announce"
116118
CONF_MIC_UNMUTE = "micunmute"
117119
CONF_DUCKING_VOLUME = "ducking_volume"
120+
CONF_MUSIC_MODE_AUTO = "music_mode_auto"
121+
CONF_MUSIC_MODE_TIMEOUT = "music_mode_timeout"
118122

119123
CONF_ENABLE_UPDATES = "enable_updates"
120124
CONF_TRANSLATION_ENGINE = "translation_engine"
@@ -165,6 +169,8 @@ class VAMode(StrEnum):
165169
CONF_USE_ANNOUNCE: "off",
166170
CONF_MIC_UNMUTE: "off",
167171
CONF_DUCKING_VOLUME: 70,
172+
CONF_MUSIC_MODE_AUTO: False,
173+
CONF_MUSIC_MODE_TIMEOUT: 300,
168174
# Default integration options
169175
CONF_ENABLE_UPDATES: True,
170176
# Default developer otions

custom_components/view_assist/core/websocket.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,9 @@ def _get_event_data(self) -> dict[str, Any]:
298298
"timers": timer_info,
299299
"background": data.dashboard.background_settings.background,
300300
"dashboard": data.dashboard.dashboard,
301-
"home": data.dashboard.home,
301+
"home": data.dashboard.home
302+
if not data.runtime_config_overrides.home
303+
else data.runtime_config_overrides.home,
302304
"music": data.dashboard.music,
303305
"intent": data.dashboard.intent,
304306
"hide_sidebar": data.dashboard.display_settings.screen_mode

custom_components/view_assist/devices/entity_listeners.py

Lines changed: 217 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
from ..const import ( # noqa: TID252
2828
CC_CONVERSATION_ENDED_EVENT,
2929
CYCLE_VIEWS,
30+
CONF_MUSIC_MODE_AUTO,
31+
CONF_MUSIC_MODE_TIMEOUT,
3032
DEVICES,
3133
DOMAIN,
3234
ESPHOME_DOMAIN,
@@ -94,16 +96,21 @@ def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None:
9496
"""Initialise."""
9597
self.hass = hass
9698
self.config = config
97-
self.mic_integration = get_config_entry_by_entity_id(
98-
hass, config.runtime_data.core.mic_device or ""
99-
).domain
99+
self.mic_integration = None
100100
self.music_player_entity = config.runtime_data.core.musicplayer_device
101101
self.music_player_volume: float = 0.0
102102
self.is_ducked: bool = False
103103
self.ducking_task: asyncio.Task | None = None
104104

105+
if mic_device := get_config_entry_by_entity_id(
106+
hass, config.runtime_data.core.mic_device
107+
):
108+
self.mic_integration = mic_device.domain
109+
105110
def register_listeners(self) -> None:
106111
"""Register the state change listener for assist/mic status entities."""
112+
if not self.mic_integration:
113+
return
107114

108115
if self.mic_integration == HASSMIC_DOMAIN:
109116
assist_entity_id = get_hassmic_pipeline_status_entity_id(
@@ -306,14 +313,20 @@ async def do_overlay_event(self, state: str) -> None:
306313
elif state in ["start", "intent-processing"]:
307314
state = AssistSatelliteState.PROCESSING
308315

316+
assist_prompt = (
317+
self.config.runtime_data.dashboard.display_settings.assist_prompt
318+
if not self.config.runtime_data.runtime_config_overrides.assist_prompt
319+
else self.config.runtime_data.runtime_config_overrides.assist_prompt
320+
)
321+
309322
async_dispatcher_send(
310323
self.hass,
311324
f"{DOMAIN}_{self.config.entry_id}_event",
312325
VAEvent(
313326
VAEventType.ASSIST_LISTENING,
314327
{
315328
"state": state,
316-
"style": self.config.runtime_data.dashboard.display_settings.assist_prompt,
329+
"style": assist_prompt,
317330
},
318331
),
319332
)
@@ -412,9 +425,7 @@ def on_mode_state_change(self, new_mode: str) -> None:
412425
if new_mode == VAMode.NORMAL:
413426
# Add navigate to default view
414427
if self.navigation_manager:
415-
self.navigation_manager.browser_navigate(
416-
self.config.runtime_data.dashboard.home
417-
)
428+
self.navigation_manager.navigate_home()
418429
elif new_mode == VAMode.MUSIC:
419430
# Add navigate to music view
420431
if self.navigation_manager:
@@ -441,6 +452,11 @@ def __init__(self, hass: HomeAssistant, config: VAConfigEntry) -> None:
441452
self.config = config
442453
self.entity_id: str | None = None
443454

455+
# Music mode auto-switching configuration
456+
self.music_mode_auto = config.options.get(CONF_MUSIC_MODE_AUTO, False)
457+
self.music_mode_timeout = config.options.get(CONF_MUSIC_MODE_TIMEOUT, 300)
458+
self.music_timeout_task: asyncio.Task | None = None
459+
444460
def register_listeners(self) -> None:
445461
"""Register the state change listener for entities."""
446462
# Add microphone mute switch listener
@@ -469,6 +485,25 @@ def register_listeners(self) -> None:
469485
)
470486
)
471487

488+
# Add music player state listener for auto mode switching
489+
if self._should_monitor_music_player():
490+
musicplayer_device = self.config.runtime_data.core.musicplayer_device
491+
492+
# Check initial state, enter music mode if already playing
493+
if musicplayer_state := self.hass.states.get(musicplayer_device):
494+
if musicplayer_state.state == MediaPlayerState.PLAYING:
495+
if self._is_music_content(musicplayer_state):
496+
self._handle_music_started()
497+
498+
# Add listener for future state changes
499+
self.config.async_on_unload(
500+
async_track_state_change_event(
501+
self.hass,
502+
musicplayer_device,
503+
self._async_on_musicplayer_device_state_change,
504+
)
505+
)
506+
472507
def _add_entity_state_listener(
473508
self, entity_id: str, listener: Callable[[Event[EventStateChangedData]], None]
474509
) -> None:
@@ -675,6 +710,181 @@ def _async_cc_on_conversation_ended_handler(self, event: Event):
675710
event.data["device_id"],
676711
)
677712

713+
def _should_monitor_music_player(self) -> bool:
714+
"""Check if music player monitoring should be enabled."""
715+
musicplayer = self.config.runtime_data.core.musicplayer_device
716+
717+
if not musicplayer:
718+
return False
719+
720+
# Only monitor if at least one feature is enabled
721+
if not self.music_mode_auto and self.music_mode_timeout <= 0:
722+
return False
723+
724+
return True
725+
726+
def _is_music_content(self, state_obj: State) -> bool:
727+
"""Check if the media content type is an audio entertainment type."""
728+
media_content_type = state_obj.attributes.get("media_content_type")
729+
730+
allowed_types = (
731+
"music",
732+
"podcast",
733+
"episode",
734+
"track",
735+
"album",
736+
"playlist",
737+
"artist",
738+
"composer",
739+
"contributing_artist",
740+
"channel",
741+
"channels",
742+
)
743+
744+
return media_content_type in allowed_types
745+
746+
@callback
747+
def _async_on_musicplayer_device_state_change(
748+
self, event: Event[EventStateChangedData]
749+
) -> None:
750+
"""Handle music player state changes for auto mode switching."""
751+
if not self._validate_event(event):
752+
return
753+
754+
new_state_obj = event.data.get("new_state")
755+
old_state_obj = event.data.get("old_state")
756+
757+
new_state = new_state_obj.state
758+
old_state = old_state_obj.state if old_state_obj else None
759+
760+
if old_state == new_state:
761+
return
762+
763+
# Music started playing
764+
if new_state == MediaPlayerState.PLAYING:
765+
if not self._is_music_content(new_state_obj):
766+
return
767+
768+
self._handle_music_started()
769+
# Music stopped/paused
770+
elif new_state in (
771+
MediaPlayerState.IDLE,
772+
MediaPlayerState.PAUSED,
773+
MediaPlayerState.OFF,
774+
):
775+
self._handle_music_stopped()
776+
777+
def _handle_music_started(self) -> None:
778+
"""Handle music playback started - transition to music mode."""
779+
# Only auto-enter if feature is enabled
780+
if not self.music_mode_auto:
781+
return
782+
783+
current_mode = self._get_current_mode()
784+
785+
# Don't override these modes
786+
if current_mode in (VAMode.HOLD, VAMode.GAME):
787+
return
788+
789+
_LOGGER.info(
790+
"Music playback started on %s, switching to music mode",
791+
self.config.runtime_data.core.name,
792+
)
793+
794+
# Cancel any pending timeout task
795+
self._cancel_music_timeout_task()
796+
797+
# Update mode to music
798+
self._set_mode(VAMode.MUSIC)
799+
800+
def _handle_music_stopped(self) -> None:
801+
"""Handle music playback stopped - schedule transition to default mode."""
802+
current_mode = self._get_current_mode()
803+
804+
if current_mode != VAMode.MUSIC:
805+
return
806+
807+
if self.music_mode_timeout <= 0:
808+
return
809+
810+
default_mode = self._get_default_mode()
811+
_LOGGER.info(
812+
"Music playback stopped on %s, scheduling return to default mode '%s' in %d seconds",
813+
self.config.runtime_data.core.name,
814+
default_mode,
815+
self.music_mode_timeout,
816+
)
817+
818+
# Cancel any existing timeout task
819+
self._cancel_music_timeout_task()
820+
821+
# Schedule new timeout task
822+
self.music_timeout_task = self.config.async_create_background_task(
823+
self.hass,
824+
self._music_mode_timeout_handler(),
825+
name=f"Music Mode Timeout - {self.config.runtime_data.core.name}",
826+
)
827+
828+
async def _music_mode_timeout_handler(self) -> None:
829+
"""Handle music mode timeout - transition back to default mode."""
830+
try:
831+
# Wait for timeout duration
832+
await asyncio.sleep(min(self.music_mode_timeout, 3600))
833+
834+
# Verify mode is still music before transitioning
835+
current_mode = self._get_current_mode()
836+
if current_mode != VAMode.MUSIC:
837+
return
838+
839+
default_mode = self._get_default_mode()
840+
_LOGGER.info(
841+
"Music mode timeout expired for %s, returning to default mode '%s'",
842+
self.config.runtime_data.core.name,
843+
default_mode,
844+
)
845+
846+
# Update mode to default
847+
self._set_mode(default_mode)
848+
849+
except asyncio.CancelledError:
850+
raise
851+
852+
def _cancel_music_timeout_task(self) -> None:
853+
"""Cancel any existing music mode timeout task."""
854+
if self.music_timeout_task and not self.music_timeout_task.done():
855+
self.music_timeout_task.cancel()
856+
self.music_timeout_task = None
857+
858+
def _get_current_mode(self) -> str:
859+
"""Get the current mode from the sensor entity."""
860+
sensor_entity = get_sensor_entity_from_instance(
861+
self.hass, self.config.entry_id
862+
)
863+
if sensor_entity and (state := self.hass.states.get(sensor_entity)):
864+
return state.attributes.get("mode", VAMode.NORMAL)
865+
return VAMode.NORMAL
866+
867+
def _get_default_mode(self) -> str:
868+
"""Get the configured default mode from config options."""
869+
return self.config.options.get("mode", VAMode.NORMAL)
870+
871+
def _set_mode(self, mode: str) -> None:
872+
"""Set the mode using the view_assist.set_state service."""
873+
sensor_entity = get_sensor_entity_from_instance(
874+
self.hass, self.config.entry_id
875+
)
876+
if sensor_entity:
877+
self.hass.async_create_task(
878+
self.hass.services.async_call(
879+
DOMAIN,
880+
"set_state",
881+
{
882+
"entity_id": sensor_entity,
883+
"mode": mode,
884+
},
885+
)
886+
)
887+
678888
def _update_sensor_entity(self, updates: dict) -> None:
679889
"""Update sensor entity attributes."""
680890
self.config.runtime_data.extra_data.update(updates)

0 commit comments

Comments
 (0)