Skip to content

Commit

Permalink
feat: Introduce Riot audio tracks; small refactorings; get 'favorites…
Browse files Browse the repository at this point in the history
…' to work w/ Riot devices (#22)
  • Loading branch information
ChrisCarini committed Apr 6, 2024
1 parent 79eb176 commit 2ecfee8
Show file tree
Hide file tree
Showing 11 changed files with 116 additions and 38 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ dist/
src/hatch_rest_api.egg-info/
build/
hatch_rest_api-aws_mqtt.log
activate
2 changes: 2 additions & 0 deletions src/hatch_rest_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@
REST_MINI_AUDIO_TRACKS,
RestPlusAudioTrack,
REST_PLUS_AUDIO_TRACKS,
RIoTAudioTrack,
REST_IOT_AUDIO_TRACKS
)
88 changes: 63 additions & 25 deletions src/hatch_rest_api/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,33 +35,71 @@ class RestPlusAudioTrack(Enum):
RockABye = 14


REST_MINI_AUDIO_TRACKS = [
RestMiniAudioTrack.NONE,
RestMiniAudioTrack.WhiteNoise,
RestMiniAudioTrack.Ocean,
RestMiniAudioTrack.Rain,
RestMiniAudioTrack.Water,
RestMiniAudioTrack.Wind,
RestMiniAudioTrack.Birds,
RestMiniAudioTrack.Dryer,
RestMiniAudioTrack.Heartbeat,
]
class RIoTAudioTrack(Enum):
NONE = 0
BrownNoise = 10200
WhiteNoise = 10137
Ocean = 10138
Thunderstorm = 10146
Rain = 10139
Water = 10142
Wind = 10141
Heartbeat = 10144
Vacuum = 10198
Dryer = 10143
Fan = 10145
ForestLake = 10082
CalmSea = 10056
Crickets = 10148
CampfireLake = 10195
Birds = 10140
Brahms = 10192
Twinkle = 10193
RockABye = 10194

@classmethod
def sound_url_map(cls):
"""
Hard-coded list, as some of these values are not returned by the 'sounds' API. These were found from manually browsing the app and playing each
song, collecting the necessary values (name, id, url) from the Home Assistant debug logs for the `ha_hatch` custom component integration.
Ideally, the API would return everything, but since Hatch does not appear to have public API documentation, this is the best we can do for now.
If the API returns different URLs for any of the media (wav files), this map will automatically be updated below.
See https://github.com/dahlb/ha_hatch/issues/95#issuecomment-2017905731 for more details.
"""
sound_map = {
# @formatter:off
RIoTAudioTrack.BrownNoise.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/Bqk8q7mjFcSa8B1Ovgllp/e9701ae7df057a31b89a4cd2830ef0dc/Brown_Noise_2_20210412.wav",
RIoTAudioTrack.WhiteNoise.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/2XkUiUT4vu1E69WMT3bxPo/099169855661de3b439135ad2fbd8098/003_pinknoise16.wav",
RIoTAudioTrack.Ocean.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/3R5xnLn3hFpC6LGyDemp2U/15bab94907d16d34aaf5ce3cf5f27624/Crashing_Ocean_Waves_20210412.wav",
RIoTAudioTrack.Thunderstorm.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/6orVWuV5mD15gNXHrBMgk4/ec9c0dab057698072870efc72d8d41fa/Thunderstorm_20210412.wav",
RIoTAudioTrack.Rain.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/2K1xgB9CuO4tWuIxAOQ9p3/0d70a8f8b39d9f35c775f4e83923228f/Steady_Rain_20210412.wav",
RIoTAudioTrack.Water.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/6SZPz15cBTaKWiXEtH2hwh/93f26a88f355bf4ca57f2a24fd6af510/002_waterstreamsmallclose16.wav",
RIoTAudioTrack.Wind.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/6PG39YsVGqAE8CXdUZ2LJV/e869572e1a7423c086dfcaedda33a868/006_wind16.wav",
RIoTAudioTrack.Heartbeat.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/2DydcI6HZ5KsqtnLWlSoNr/f2e93d60ffa12cf5fb303ed010e5df1d/001_heartbeat.wav",
RIoTAudioTrack.Vacuum.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/5mg3e3BtpIn0YaQfOVVNRJ/528e94cfd7232481637fd3ce7c7141f2/Industrial_Vacuum_Cleaner_20191220.wav",
RIoTAudioTrack.Dryer.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/4BsUei9xw0Qd1qrLOiPUCg/dc159c335c3fefa5c684b75e155eeed3/004_dryerclothes16.wav",
RIoTAudioTrack.Fan.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/ndDbe0uTgVEiTBukiSIVP/14aede254b6bbfcff108c18b76d75f6a/FanNoise_20191122.wav",
RIoTAudioTrack.ForestLake.value: "https://downloads.ctfassets.net/hlsdh3zwyrtx/2WgzZNttwX5RK4twPtMCsS/64de4333300711282b42046020fc3aa0/Forest_Lake_20191220.wav",
RIoTAudioTrack.CalmSea.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/1LelwPIVm5YZle7WP42u2X/b26f1d8a35b4c083a0bb65c9e323b7a7/Calm_Sea_20191220.wav",
RIoTAudioTrack.Crickets.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/5X1S7xtEHyZab67wRbsEda/92f8bc6c927a384bd2262ebc6999465a/010_crickets16.wav",
RIoTAudioTrack.CampfireLake.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/6Gb9MNlL9VcMcUmo4jzCSv/c457b63210359467e729fe7c1d624edd/Campfire_Lake_2_20210412.wav",
RIoTAudioTrack.Birds.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/7zIxpw8gUhJQeI7fLaxNpz/0da0956663ac277e30886b256b1ade08/Morning_Birds_20210412.wav",
RIoTAudioTrack.Brahms.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/2XXRwK0Xqw1KLBr28RIkSe/ee6af976c9980823389134eeded7f07b/011_brahms16.wav",
RIoTAudioTrack.Twinkle.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/69qMR6Wp2hPD7gk7hSfRl5/25af4cefe997d5ba4070e71ae21e7eb3/013_twinkle16.wav",
RIoTAudioTrack.RockABye.value: "https://assets.ctfassets.net/hlsdh3zwyrtx/7lY2LJerpBhO7vravoQ14J/debcf202883c61eaa384ee826dec4026/014_rockabye16.wav",
# @formatter:on
}
# TODO: Move the below asserts into a unit test (project needs unit tests added).
assert len(sound_map) == len(RIoTAudioTrack) - 1, "Missing sound URL for one or more RIotAudioTrack"
assert set(sound_map.keys()) == {track.value for track in RIoTAudioTrack if track != RIoTAudioTrack.NONE}, "Each RIotAudioTrack must have a unique sound URL in the map"
return sound_map

REST_PLUS_AUDIO_TRACKS = [
RestPlusAudioTrack.NONE,
RestPlusAudioTrack.Stream,
RestPlusAudioTrack.PinkNoise,
RestPlusAudioTrack.Dryer,
RestPlusAudioTrack.Ocean,
RestPlusAudioTrack.Wind,
RestPlusAudioTrack.Rain,
RestPlusAudioTrack.Bird,
RestPlusAudioTrack.Crickets,
RestPlusAudioTrack.Brahms,
RestPlusAudioTrack.Twinkle,
RestPlusAudioTrack.RockABye,
]

REST_MINI_AUDIO_TRACKS = list(RestMiniAudioTrack)

REST_PLUS_AUDIO_TRACKS = list(RestPlusAudioTrack)

REST_IOT_AUDIO_TRACKS = list(RIoTAudioTrack)

RIOT_FLAGS_CLOCK_24_HOUR = 2048
RIOT_FLAGS_CLOCK_ON = 32768
2 changes: 1 addition & 1 deletion src/hatch_rest_api/hatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async def token(self, auth_token: str):

async def favorites(self, auth_token: str, mac: str):
url = API_URL + "service/app/routine/v2/fetch"
params = {"macAddress": mac, "types": "favorite"}
params = {"macAddress": mac}
response: ClientResponse = (
await self._get_request_with_logging_and_errors_raised(
url=url, auth_token=auth_token, params=params
Expand Down
9 changes: 7 additions & 2 deletions src/hatch_rest_api/rest_mini.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ class RestMini(ShadowClientSubscriberMixin):
firmware_version: str = None
is_online: bool = None

is_playing: bool = None
audio_track: RestMiniAudioTrack = None
volume: int = None

current_playing: str = "none"

def __repr__(self):
return {
"device_name": self.device_name,
Expand Down Expand Up @@ -42,13 +43,17 @@ def _update_local_state(self, state):
safely_get_json_value(state, "current.sound.id")
)
if safely_get_json_value(state, "current.playing") is not None:
self.is_playing = safely_get_json_value(state, "current.playing") != "none"
self.current_playing = safely_get_json_value(state, "current.playing")
if safely_get_json_value(state, "current.sound.v") is not None:
self.volume = convert_to_percentage(
safely_get_json_value(state, "current.sound.v")
)
self.publish_updates()

@property
def is_playing(self) -> bool:
return self.current_playing != "none"

def set_volume(self, percentage: int):
self._update(
{
Expand Down
2 changes: 1 addition & 1 deletion src/hatch_rest_api/rest_plus.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
class RestPlus(ShadowClientSubscriberMixin):
firmware_version: str = None
audio_track: RestPlusAudioTrack = None
volume: int = None
volume: int = 0

is_on: bool = None
battery_level: int = None
Expand Down
3 changes: 2 additions & 1 deletion src/hatch_rest_api/restoreiot.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def __repr__(self):
"is_online": self.is_online,
"is_on": self.is_on,
"is_playing": self.is_playing,
"audio_track": self.audio_track,
"volume": self.volume,
"red": self.red,
"green": self.green,
Expand Down Expand Up @@ -152,7 +153,7 @@ def turn_clock_off(self):
# favorite_name_id is expected to be a string of name-id since name alone isn't unique
def set_favorite(self, favorite_name_id: str):
_LOGGER.debug(f"Setting favorite: {favorite_name_id}")
fav_id = int(favorite_name_id.split("-")[1])
fav_id = int(favorite_name_id.rsplit("-", 1)[1])
self._update({"current": {"srId": fav_id, "step": 1, "playing": "routine"}})

def turn_off(self):
Expand Down
30 changes: 26 additions & 4 deletions src/hatch_rest_api/riot.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
convert_from_hex,
convert_to_hex,
)
from .const import RIOT_FLAGS_CLOCK_ON, RIOT_FLAGS_CLOCK_24_HOUR
from .const import RIOT_FLAGS_CLOCK_ON, RIOT_FLAGS_CLOCK_24_HOUR, RIoTAudioTrack
from .shadow_client_subscriber import ShadowClientSubscriberMixin

_LOGGER = logging.getLogger(__name__)


class RestIot(ShadowClientSubscriberMixin):
audio_track: str = None
audio_track: RIoTAudioTrack = None
firmware_version: str = None
volume: int = 0

Expand Down Expand Up @@ -63,6 +63,7 @@ def _update_local_state(self, state):
)
if safely_get_json_value(state, "current.sound.id", int) is not None:
self.sound_id = safely_get_json_value(state, "current.sound.id", int)
self.audio_track = RIoTAudioTrack(self.sound_id)
if safely_get_json_value(state, "current.color.id") is not None:
self.color_id = safely_get_json_value(state, "current.color.id", int)
if safely_get_json_value(state, "current.color.w") is not None:
Expand Down Expand Up @@ -103,6 +104,8 @@ def __repr__(self):
"is_on": self.is_on,
"battery_level": self.battery_level,
"is_playing": self.is_playing,
"audio_track": self.audio_track,
"sound_id": self.sound_id,
"volume": self.volume,
"red": self.red,
"green": self.green,
Expand Down Expand Up @@ -178,9 +181,28 @@ def turn_clock_off(self):
# favorite_name_id is expected to be a string of name-id since name alone isn't unique
def set_favorite(self, favorite_name_id: str):
_LOGGER.debug(f"Setting favorite: {favorite_name_id}")
fav_id = int(favorite_name_id.split("-")[1])
fav_id = int(favorite_name_id.rsplit("-", 1)[1])
self._update({"current": {"srId": fav_id, "step": 1, "playing": "routine"}})

def set_audio_track(self, audio_track: RIoTAudioTrack):
_LOGGER.debug(f"Setting audio track: {audio_track}")
if audio_track == RIoTAudioTrack.NONE:
self.turn_off()
return

sound_url_map = RIoTAudioTrack.sound_url_map()
# update the map with any changes from the API
sound_url_map.update({
sound.get('id'): sound.get('wavUrl') for sound in self.sounds
})
_LOGGER.debug(f'Available Sounds: {sound_url_map}')
self._update({"current": {"playing": "remote", "step": 1, "sound": {
"id": audio_track.value,
"url": sound_url_map[audio_track.value],
"mute": False,
"until": "indefinite",
}}})

def set_sound(self, sound: SoundContent, duration: int = 0, until="indefinite"):
"""
Pass a SoundContent item from self.sounds
Expand All @@ -202,7 +224,7 @@ def set_sound(self, sound: SoundContent, duration: int = 0, until="indefinite"):
}
)

def set_sound_url(self, sound_url: str):
def set_sound_url(self, sound_url: str = 'http://codeskulptor-demos.commondatastorage.googleapis.com/GalaxyInvaders/theme_01.mp3'):
"""
appears to work with some but not all public wav and mp3 urls
Expand Down
12 changes: 10 additions & 2 deletions src/hatch_rest_api/shadow_client_subscriber.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import logging
from typing import Optional

from awscrt import mqtt
from awsiot import iotshadow
from awsiot.iotshadow import (
Expand All @@ -25,9 +27,13 @@ def __init__(
thing_name: str,
mac: str,
shadow_client: IotShadowClient,
favorites: list = [],
sounds: "list[SoundContent|dict]" = [],
favorites: list | None = None,
sounds: list[SoundContent | dict] | None = None,
):
if favorites is None:
favorites = []
if sounds is None:
sounds = []
self.device_name = device_name
self.thing_name = thing_name
self.mac = mac
Expand Down Expand Up @@ -74,9 +80,11 @@ def on_get_shadow_accepted(response: GetShadowResponse):
def _on_update_shadow_accepted(self, response: UpdateShadowResponse):
_LOGGER.debug(f"update {self.device_name}, RESPONSE: {response}")
if response.version < self.document_version:
_LOGGER.debug(f'ignoring update {self.device_name}, response version: {response.version} < document version: {self.document_version}')
return
if response.state:
if response.state.reported:
_LOGGER.debug(f'updating {self.device_name} local state: {response.state.reported}')
self.document_version = response.version
self._update_local_state(response.state.reported)

Expand Down
4 changes: 2 additions & 2 deletions src/hatch_rest_api/stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,5 @@ def output():
if api:
await api.cleanup_client_session()


asyncio.run(testing())
if __name__ == "__main__":
asyncio.run(testing())
1 change: 1 addition & 0 deletions src/hatch_rest_api/util_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def create_rest_devices(iot_device):
mac=iot_device["macAddress"],
shadow_client=shadow_client,
favorites=routines_map[iot_device["macAddress"]],
sounds=sounds,
)
else:
return RestMini(
Expand Down

0 comments on commit 2ecfee8

Please sign in to comment.