amniotic 1.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- amniotic-1.3.4/PKG-INFO +97 -0
- amniotic-1.3.4/README.md +73 -0
- amniotic-1.3.4/amniotic/__init__.py +0 -0
- amniotic-1.3.4/amniotic/api.py +48 -0
- amniotic-1.3.4/amniotic/client.py +44 -0
- amniotic-1.3.4/amniotic/controls.py +260 -0
- amniotic-1.3.4/amniotic/device.py +144 -0
- amniotic-1.3.4/amniotic/entrypoint.py +7 -0
- amniotic-1.3.4/amniotic/obs.py +15 -0
- amniotic-1.3.4/amniotic/paths.py +33 -0
- amniotic-1.3.4/amniotic/recording.py +140 -0
- amniotic-1.3.4/amniotic/sapi_sb.py +15 -0
- amniotic-1.3.4/amniotic/settings.py +67 -0
- amniotic-1.3.4/amniotic/theme.py +221 -0
- amniotic-1.3.4/amniotic/v0/__init__.py +0 -0
- amniotic-1.3.4/amniotic/v0/audio.py +760 -0
- amniotic-1.3.4/amniotic/v0/config.py +132 -0
- amniotic-1.3.4/amniotic/v0/control.py +938 -0
- amniotic-1.3.4/amniotic/v0/device.py +78 -0
- amniotic-1.3.4/amniotic/v0/loop.py +311 -0
- amniotic-1.3.4/amniotic/v0/sensor.py +273 -0
- amniotic-1.3.4/amniotic/v0/start.py +37 -0
- amniotic-1.3.4/amniotic/v0/tools.py +108 -0
- amniotic-1.3.4/amniotic/version +1 -0
- amniotic-1.3.4/amniotic/version.py +4 -0
- amniotic-1.3.4/amniotic.egg-info/PKG-INFO +97 -0
- amniotic-1.3.4/amniotic.egg-info/SOURCES.txt +32 -0
- amniotic-1.3.4/amniotic.egg-info/dependency_links.txt +1 -0
- amniotic-1.3.4/amniotic.egg-info/entry_points.txt +2 -0
- amniotic-1.3.4/amniotic.egg-info/requires.txt +4 -0
- amniotic-1.3.4/amniotic.egg-info/top_level.txt +1 -0
- amniotic-1.3.4/pyproject.toml +3 -0
- amniotic-1.3.4/setup.cfg +4 -0
- amniotic-1.3.4/setup.py +16 -0
amniotic-1.3.4/PKG-INFO
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amniotic
|
|
3
|
+
Version: 1.3.4
|
|
4
|
+
Summary: A multi-output ambient sound mixer for Home Assistant
|
|
5
|
+
Home-page: https://github.com/None/amniotic
|
|
6
|
+
Author: Frontmatter
|
|
7
|
+
Author-email: innovative.fowler@mask.pro.fmtr.dev
|
|
8
|
+
License: Copyright © 2025 Frontmatter. All rights reserved.
|
|
9
|
+
Keywords: ambient sound audio white noise masking sleep
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: fmtr.tools[api,av,caching,debug,ha.api,http,logging,mqtt,path.app,sets,tabular,version.dev,yaml]
|
|
12
|
+
Requires-Dist: haco
|
|
13
|
+
Provides-Extra: all
|
|
14
|
+
Dynamic: author
|
|
15
|
+
Dynamic: author-email
|
|
16
|
+
Dynamic: description
|
|
17
|
+
Dynamic: description-content-type
|
|
18
|
+
Dynamic: home-page
|
|
19
|
+
Dynamic: keywords
|
|
20
|
+
Dynamic: license
|
|
21
|
+
Dynamic: provides-extra
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: summary
|
|
24
|
+
|
|
25
|
+
# Amniotic
|
|
26
|
+
|
|
27
|
+
A multi-output ambient sound mixer for Home Assistant.
|
|
28
|
+
|
|
29
|
+
Amniotic lets you use a single device to create on-the-fly, custom ambient audio mixes - e.g. mixing Waterfall sounds with Birdsong on one media player entity, while playing Fireplace sounds from a second audio device - to suit your tastes and environment.
|
|
30
|
+
|
|
31
|
+
The library integrates with Home Assistant via MQTT as a new device, allowing you to create and control ambient mixes from the Home Assistant interface.
|
|
32
|
+
|
|
33
|
+
### Why Would I Want Such a Thing?
|
|
34
|
+
|
|
35
|
+
I won't explain the general reasons for introducing non-musical sounds into one's environment, but if you find [sound masking](https://en.wikipedia.org/wiki/Sound_masking) helps you concentrate in noisy environments, if you're (as I am) slightly [misophonic](https://www.webmd.com/mental-health/what-is-misophonia), if you use [white noise machines](https://en.wikipedia.org/wiki/White_noise_machine) to induce sleep or relaxation, or if you just think sound is an important factor in setting a pleasant ambience, then you might find Amniotic useful.
|
|
36
|
+
|
|
37
|
+
### Can't I do This with Spotify, Volumio, HifiBerry etc.?
|
|
38
|
+
|
|
39
|
+
Since those systems are intended for music, they aren't designed for playing or mixing multiple streams simultaneously with a single device, even if set up in multi-room configurations. Also, the streaming services often won't allow a single account to play multiple streams, even
|
|
40
|
+
_if_ multiple devices are used.
|
|
41
|
+
|
|
42
|
+
Anyway, those limitations motivated this library.
|
|
43
|
+
|
|
44
|
+
There are two ways to install and run Amniotic:
|
|
45
|
+
|
|
46
|
+
- On the Home Assistant machine itself, as an add-on.
|
|
47
|
+
- Install manually, on a separate machine.
|
|
48
|
+
|
|
49
|
+
## Home Assistant Addon
|
|
50
|
+
|
|
51
|
+
To add as an Addon, click here:
|
|
52
|
+
|
|
53
|
+
[](https://fmtr.link/amniotic/addon-install)
|
|
54
|
+
|
|
55
|
+
## Dashboard
|
|
56
|
+
|
|
57
|
+
[Lovelace Dashboard](https://fmtr.link/amniotic/doc/dashboard)
|
|
58
|
+
|
|
59
|
+

|
|
60
|
+
|
|
61
|
+
## Getting Started
|
|
62
|
+
|
|
63
|
+
Better documentation is coming soon. Currently, the easiest workflow is the following:
|
|
64
|
+
|
|
65
|
+
- Install as an Addon, using the button above.
|
|
66
|
+
- If you're using a non-default address for Home Assistant on your network (i.e. not
|
|
67
|
+
`homeassistant.local`), set that in the Addon configuration.
|
|
68
|
+
- Add some audio files to the Home Assistant `/media/Amniotic` directory.
|
|
69
|
+
|
|
70
|
+
#### Adding the Dashboard to Lovelace
|
|
71
|
+
|
|
72
|
+
- Find the [Lovelace Dashboard View here](https://fmtr.link/amniotic/doc/dashboard).
|
|
73
|
+
- Click on the Copy icon to "Copy raw file"
|
|
74
|
+
- In your Home Assistant UI, navigate to a dashboard you'd like to add the View to.
|
|
75
|
+
- Click on the Edit Dashboard icon.
|
|
76
|
+
- Click the `+` to add a new View.
|
|
77
|
+
- Click on the three dots in the top right corner of the new View editor, and select `Edit in YAML`.
|
|
78
|
+
- Overwrite the _entire_ edit box with the contents of your clipboard.
|
|
79
|
+
- Click `Save`.
|
|
80
|
+
- Click `Done` in the dashboard editor.
|
|
81
|
+
|
|
82
|
+
#### Using the Dashboard
|
|
83
|
+
- Select a Recording from the dropdown.
|
|
84
|
+
- Toggle to Enable the Recording.
|
|
85
|
+
- Select a Media Player to stream the Theme to. Note: Your media player needs to support streaming from a basic HTTP stream, which most should.
|
|
86
|
+
- Click Stream to Media Player.
|
|
87
|
+
- Your Theme should start playing on your Media Player.
|
|
88
|
+
- You can now Enable additional Recordings in the Theme, modify their volume, etc., to create a custom mix.
|
|
89
|
+
- Note: there's also a Current Theme URL, for if you want to manually paste stream to a non-HA player, like a phone or something.
|
|
90
|
+
|
|
91
|
+
## Do I need a fancy Sonos-type Speaker? Can't I just use a Raspberry Pi, etc?
|
|
92
|
+
|
|
93
|
+
- You can use basically any device with audio hardware. You just need to allow Home Assistant to see it as a Media Player entity.
|
|
94
|
+
- In Home Assistant, install the [VLC Telnet integration](https://www.home-assistant.io/integrations/vlc_telnet).
|
|
95
|
+
- On your Device, install VLC, and start it in telnet mode, e.g.
|
|
96
|
+
`vlc -I telnet --telnet-password password --telnet-host 0.0.0.0:4212`
|
|
97
|
+
- Add your device to the VLC Telnet integration.
|
amniotic-1.3.4/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Amniotic
|
|
2
|
+
|
|
3
|
+
A multi-output ambient sound mixer for Home Assistant.
|
|
4
|
+
|
|
5
|
+
Amniotic lets you use a single device to create on-the-fly, custom ambient audio mixes - e.g. mixing Waterfall sounds with Birdsong on one media player entity, while playing Fireplace sounds from a second audio device - to suit your tastes and environment.
|
|
6
|
+
|
|
7
|
+
The library integrates with Home Assistant via MQTT as a new device, allowing you to create and control ambient mixes from the Home Assistant interface.
|
|
8
|
+
|
|
9
|
+
### Why Would I Want Such a Thing?
|
|
10
|
+
|
|
11
|
+
I won't explain the general reasons for introducing non-musical sounds into one's environment, but if you find [sound masking](https://en.wikipedia.org/wiki/Sound_masking) helps you concentrate in noisy environments, if you're (as I am) slightly [misophonic](https://www.webmd.com/mental-health/what-is-misophonia), if you use [white noise machines](https://en.wikipedia.org/wiki/White_noise_machine) to induce sleep or relaxation, or if you just think sound is an important factor in setting a pleasant ambience, then you might find Amniotic useful.
|
|
12
|
+
|
|
13
|
+
### Can't I do This with Spotify, Volumio, HifiBerry etc.?
|
|
14
|
+
|
|
15
|
+
Since those systems are intended for music, they aren't designed for playing or mixing multiple streams simultaneously with a single device, even if set up in multi-room configurations. Also, the streaming services often won't allow a single account to play multiple streams, even
|
|
16
|
+
_if_ multiple devices are used.
|
|
17
|
+
|
|
18
|
+
Anyway, those limitations motivated this library.
|
|
19
|
+
|
|
20
|
+
There are two ways to install and run Amniotic:
|
|
21
|
+
|
|
22
|
+
- On the Home Assistant machine itself, as an add-on.
|
|
23
|
+
- Install manually, on a separate machine.
|
|
24
|
+
|
|
25
|
+
## Home Assistant Addon
|
|
26
|
+
|
|
27
|
+
To add as an Addon, click here:
|
|
28
|
+
|
|
29
|
+
[](https://fmtr.link/amniotic/addon-install)
|
|
30
|
+
|
|
31
|
+
## Dashboard
|
|
32
|
+
|
|
33
|
+
[Lovelace Dashboard](https://fmtr.link/amniotic/doc/dashboard)
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
## Getting Started
|
|
38
|
+
|
|
39
|
+
Better documentation is coming soon. Currently, the easiest workflow is the following:
|
|
40
|
+
|
|
41
|
+
- Install as an Addon, using the button above.
|
|
42
|
+
- If you're using a non-default address for Home Assistant on your network (i.e. not
|
|
43
|
+
`homeassistant.local`), set that in the Addon configuration.
|
|
44
|
+
- Add some audio files to the Home Assistant `/media/Amniotic` directory.
|
|
45
|
+
|
|
46
|
+
#### Adding the Dashboard to Lovelace
|
|
47
|
+
|
|
48
|
+
- Find the [Lovelace Dashboard View here](https://fmtr.link/amniotic/doc/dashboard).
|
|
49
|
+
- Click on the Copy icon to "Copy raw file"
|
|
50
|
+
- In your Home Assistant UI, navigate to a dashboard you'd like to add the View to.
|
|
51
|
+
- Click on the Edit Dashboard icon.
|
|
52
|
+
- Click the `+` to add a new View.
|
|
53
|
+
- Click on the three dots in the top right corner of the new View editor, and select `Edit in YAML`.
|
|
54
|
+
- Overwrite the _entire_ edit box with the contents of your clipboard.
|
|
55
|
+
- Click `Save`.
|
|
56
|
+
- Click `Done` in the dashboard editor.
|
|
57
|
+
|
|
58
|
+
#### Using the Dashboard
|
|
59
|
+
- Select a Recording from the dropdown.
|
|
60
|
+
- Toggle to Enable the Recording.
|
|
61
|
+
- Select a Media Player to stream the Theme to. Note: Your media player needs to support streaming from a basic HTTP stream, which most should.
|
|
62
|
+
- Click Stream to Media Player.
|
|
63
|
+
- Your Theme should start playing on your Media Player.
|
|
64
|
+
- You can now Enable additional Recordings in the Theme, modify their volume, etc., to create a custom mix.
|
|
65
|
+
- Note: there's also a Current Theme URL, for if you want to manually paste stream to a non-HA player, like a phone or something.
|
|
66
|
+
|
|
67
|
+
## Do I need a fancy Sonos-type Speaker? Can't I just use a Raspberry Pi, etc?
|
|
68
|
+
|
|
69
|
+
- You can use basically any device with audio hardware. You just need to allow Home Assistant to see it as a Media Player entity.
|
|
70
|
+
- In Home Assistant, install the [VLC Telnet integration](https://www.home-assistant.io/integrations/vlc_telnet).
|
|
71
|
+
- On your Device, install VLC, and start it in telnet mode, e.g.
|
|
72
|
+
`vlc -I telnet --telnet-password password --telnet-host 0.0.0.0:4212`
|
|
73
|
+
- Add your device to the VLC Telnet integration.
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from fastapi.responses import StreamingResponse
|
|
3
|
+
from starlette.requests import Request
|
|
4
|
+
|
|
5
|
+
from amniotic.obs import logger
|
|
6
|
+
from amniotic.theme import ThemeDefinition
|
|
7
|
+
from amniotic.version import __version__
|
|
8
|
+
from fmtr.tools import api, mqtt
|
|
9
|
+
|
|
10
|
+
for name in ["uvicorn.access", "uvicorn.error", "uvicorn"]:
|
|
11
|
+
lgr = logging.getLogger(name)
|
|
12
|
+
lgr.handlers.clear()
|
|
13
|
+
lgr.propagate = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiAmniotic(api.Base):
|
|
18
|
+
TITLE = f'Amniotic {__version__} Streaming API'
|
|
19
|
+
URL_DOCS = '/'
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: mqtt.Client):
|
|
22
|
+
super().__init__()
|
|
23
|
+
|
|
24
|
+
self.client = client
|
|
25
|
+
|
|
26
|
+
def get_endpoints(self):
|
|
27
|
+
endpoints = [
|
|
28
|
+
api.Endpoint(method_http=self.app.get, path='/stream/{id}', method=self.stream),
|
|
29
|
+
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
return endpoints
|
|
33
|
+
|
|
34
|
+
async def stream(self, id: str, request: Request):
|
|
35
|
+
logger.info(f'Got streaming audio request {id=} {request.client=}')
|
|
36
|
+
theme_def: ThemeDefinition = self.client.device.themes.id[id]
|
|
37
|
+
stream = theme_def.get_stream()
|
|
38
|
+
|
|
39
|
+
if not stream.is_enabled:
|
|
40
|
+
logger.warning(f'Theme "{theme_def.name}" is streaming, but it has no recordings enabled. The stream will be silent. Enable some recordings to hear output.')
|
|
41
|
+
|
|
42
|
+
response = StreamingResponse(stream, media_type="audio/mpeg")
|
|
43
|
+
return response
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == '__main__':
|
|
48
|
+
ApiAmniotic.launch()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from amniotic.api import ApiAmniotic
|
|
4
|
+
from amniotic.device import Amniotic
|
|
5
|
+
from amniotic.obs import logger
|
|
6
|
+
from fmtr.tools import http
|
|
7
|
+
from haco.client import ClientHaco
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClientAmniotic(ClientHaco):
|
|
11
|
+
"""
|
|
12
|
+
Take an extra API argument, and gather with super.start
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
API_CLASS = ApiAmniotic
|
|
16
|
+
|
|
17
|
+
def __init__(self, device: Amniotic, *args, **kwargs):
|
|
18
|
+
super().__init__(device=device, *args, **kwargs)
|
|
19
|
+
|
|
20
|
+
@logger.instrument('Connecting MQTT client to {self._client.username}@{self._hostname}:{self._port}...')
|
|
21
|
+
async def start(self):
|
|
22
|
+
await asyncio.gather(
|
|
23
|
+
super().start(),
|
|
24
|
+
self.API_CLASS.launch_async(self)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
@logger.instrument('Instantiating MQTT client from Supervisor API...')
|
|
29
|
+
def from_supervisor(cls, device: Amniotic, **kwargs):
|
|
30
|
+
from amniotic.settings import settings
|
|
31
|
+
|
|
32
|
+
with http.Client() as client:
|
|
33
|
+
response = client.get(
|
|
34
|
+
f"{settings.ha_supervisor_api}/services/mqtt",
|
|
35
|
+
headers={
|
|
36
|
+
"Authorization": f"Bearer {settings.token}",
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
data = response.json().get("data", {})
|
|
42
|
+
|
|
43
|
+
self = cls(device=device, hostname=data['host'], port=data['port'], username=data['username'], password=data['password'], **kwargs)
|
|
44
|
+
return self
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from amniotic.obs import logger
|
|
4
|
+
from amniotic.recording import RecordingThemeInstance
|
|
5
|
+
from amniotic.theme import ThemeDefinition
|
|
6
|
+
from fmtr.tools import http
|
|
7
|
+
from haco.button import Button
|
|
8
|
+
from haco.control import Control
|
|
9
|
+
from haco.number import Number
|
|
10
|
+
from haco.select import Select
|
|
11
|
+
from haco.sensor import Sensor
|
|
12
|
+
from haco.switch import Switch
|
|
13
|
+
from haco.text import Text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(kw_only=True)
|
|
17
|
+
class ThemeRelativeControl(Control):
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def themes(self):
|
|
21
|
+
return self.device.themes
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def theme(self):
|
|
25
|
+
return self.themes.current
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def instances(self):
|
|
29
|
+
return self.theme.instances
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def instance(self):
|
|
33
|
+
return self.instances.current
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(kw_only=True)
|
|
37
|
+
class SelectTheme(Select, ThemeRelativeControl):
|
|
38
|
+
icon: str = 'access-point'
|
|
39
|
+
name: str = 'Theme'
|
|
40
|
+
|
|
41
|
+
@logger.instrument('Setting Theme to "{value}"...')
|
|
42
|
+
async def command(self, value):
|
|
43
|
+
theme = self.themes.name[value]
|
|
44
|
+
self.themes.current = theme
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def state(self, value=None):
|
|
49
|
+
|
|
50
|
+
if self.theme not in self.themes:
|
|
51
|
+
if self.themes:
|
|
52
|
+
self.themes.current = next(iter(self.themes))
|
|
53
|
+
else:
|
|
54
|
+
logger.warning('No themes exist. Creating default...')
|
|
55
|
+
theme = ThemeDefinition(amniotic=self.device, name="Default")
|
|
56
|
+
self.themes.append(theme)
|
|
57
|
+
self.themes.current = theme
|
|
58
|
+
self.themes.save()
|
|
59
|
+
|
|
60
|
+
options = sorted(self.themes.name.keys())
|
|
61
|
+
if self.options != options:
|
|
62
|
+
self.options = options
|
|
63
|
+
await self.announce()
|
|
64
|
+
|
|
65
|
+
name = self.theme.name
|
|
66
|
+
|
|
67
|
+
await self.device.select_recording.state()
|
|
68
|
+
await self.device.sns_url.state()
|
|
69
|
+
return name
|
|
70
|
+
|
|
71
|
+
@dataclass(kw_only=True)
|
|
72
|
+
class SelectRecording(Select, ThemeRelativeControl):
|
|
73
|
+
icon: str = 'waveform'
|
|
74
|
+
name: str = 'Recording'
|
|
75
|
+
|
|
76
|
+
@logger.instrument('Setting Theme "{self.theme.name}" current recording instance to "{value}"...')
|
|
77
|
+
async def command(self, value):
|
|
78
|
+
|
|
79
|
+
instance = self.instances.name.get(value)
|
|
80
|
+
if not instance:
|
|
81
|
+
logger.info(f'Creating new recording instance "{value}" for Theme "{self.theme.name}"...')
|
|
82
|
+
meta = self.device.metas.name[value]
|
|
83
|
+
instance = RecordingThemeInstance(path=meta.path_str, device=self.device)
|
|
84
|
+
self.instances.append(instance)
|
|
85
|
+
|
|
86
|
+
self.instances.current = instance
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
async def state(self, value=None):
|
|
90
|
+
|
|
91
|
+
options = sorted(self.device.metas.name.keys())
|
|
92
|
+
if self.options != options:
|
|
93
|
+
self.options = options
|
|
94
|
+
await self.announce()
|
|
95
|
+
|
|
96
|
+
if not self.instance:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
await self.device.swt_play.state()
|
|
100
|
+
await self.device.nbr_volume.state()
|
|
101
|
+
return self.instance.name
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(kw_only=True)
|
|
106
|
+
class EnableRecording(Switch, ThemeRelativeControl):
|
|
107
|
+
icon: str = 'playlist-plus'
|
|
108
|
+
name: str = 'Enable Recording'
|
|
109
|
+
|
|
110
|
+
@logger.instrument('Toggling {value=} recording instance "{self.instances.current.name}" for Theme "{self.theme.name}"...')
|
|
111
|
+
async def command(self, value):
|
|
112
|
+
|
|
113
|
+
if not self.instance:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
self.instance.is_enabled = value
|
|
117
|
+
self.themes.save()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def state(self, value=None):
|
|
121
|
+
|
|
122
|
+
if not self.instance:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
return self.instance.is_enabled
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(kw_only=True)
|
|
130
|
+
class NumberVolume(Number, ThemeRelativeControl):
|
|
131
|
+
icon: str = 'volume-medium'
|
|
132
|
+
name: str = 'Recording Volume'
|
|
133
|
+
|
|
134
|
+
@logger.instrument('Setting volume to {value} for recording instance "{self.instances.current.name}" for Theme "{self.theme.name}"...')
|
|
135
|
+
async def command(self, value):
|
|
136
|
+
|
|
137
|
+
if not self.instance:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
self.instance.volume = value / 100
|
|
141
|
+
self.themes.save()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def state(self, value=None):
|
|
145
|
+
|
|
146
|
+
if not self.instance:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
return int(self.instance.volume * 100)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(kw_only=True)
|
|
155
|
+
class SelectMediaPlayer(Select, ThemeRelativeControl):
|
|
156
|
+
icon: str = 'cast-audio'
|
|
157
|
+
name: str = 'Media Player'
|
|
158
|
+
|
|
159
|
+
@logger.instrument('Selecting Media Player "{value}" for Theme "{self.theme.name}"...')
|
|
160
|
+
async def command(self, value):
|
|
161
|
+
state = self.device.media_player_states.friendly_name[value]
|
|
162
|
+
self.device.media_player_states.current = state
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
async def state(self, value):
|
|
166
|
+
player = self.device.media_player_states.current
|
|
167
|
+
if player:
|
|
168
|
+
return player.friendly_name
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(kw_only=True)
|
|
173
|
+
class StreamURL(Sensor, ThemeRelativeControl):
|
|
174
|
+
icon: str = 'link-variant'
|
|
175
|
+
name: str = 'Stream URL'
|
|
176
|
+
|
|
177
|
+
async def state(self, value=None):
|
|
178
|
+
return self.theme.url
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(kw_only=True)
|
|
182
|
+
class PlayStreamButton(Button, ThemeRelativeControl):
|
|
183
|
+
icon: str = 'play-network'
|
|
184
|
+
name: str = 'Stream'
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def url_api(self):
|
|
188
|
+
from amniotic.settings import settings
|
|
189
|
+
|
|
190
|
+
return f"{settings.ha_core_api}/services/media_player/play_media"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def command(self, value):
|
|
194
|
+
|
|
195
|
+
if not self.instance:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
state = self.device.media_player_states.current
|
|
199
|
+
|
|
200
|
+
if not state:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
with logger.span(f'Posting request to HA API {self.url_api} {state.entity_id=} {self.theme.url=}') as span:
|
|
204
|
+
try:
|
|
205
|
+
await self.post(state)
|
|
206
|
+
except Exception as exception:
|
|
207
|
+
logger.error(f'Error posting to HA API: {repr(exception)}.')
|
|
208
|
+
span.record_exception(exception=exception)
|
|
209
|
+
|
|
210
|
+
async def post(self, state):
|
|
211
|
+
from amniotic.settings import settings
|
|
212
|
+
|
|
213
|
+
response = http.client.post(
|
|
214
|
+
self.url_api,
|
|
215
|
+
headers={
|
|
216
|
+
"Authorization": f"Bearer {settings.token}",
|
|
217
|
+
"Content-Type": "application/json",
|
|
218
|
+
},
|
|
219
|
+
json={
|
|
220
|
+
"entity_id": state.entity_id,
|
|
221
|
+
"media_content_id": self.theme.url,
|
|
222
|
+
"media_content_type": "music",
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
response.raise_for_status()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(kw_only=True)
|
|
230
|
+
class NewTheme(Text, ThemeRelativeControl):
|
|
231
|
+
icon: str = 'access-point-plus'
|
|
232
|
+
name: str = 'New Theme'
|
|
233
|
+
|
|
234
|
+
@logger.instrument('Creating new Theme "{value}"...')
|
|
235
|
+
async def command(self, value):
|
|
236
|
+
if not value:
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
theme = ThemeDefinition(amniotic=self.device, name=value)
|
|
240
|
+
self.themes.append(theme)
|
|
241
|
+
self.themes.current = theme
|
|
242
|
+
self.themes.save()
|
|
243
|
+
|
|
244
|
+
await self.device.select_theme.state()
|
|
245
|
+
|
|
246
|
+
async def state(self, value=None):
|
|
247
|
+
return 'New'
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(kw_only=True)
|
|
251
|
+
class DeleteTheme(Button, ThemeRelativeControl):
|
|
252
|
+
icon: str = 'access-point-remove'
|
|
253
|
+
name: str = 'Delete Current Theme'
|
|
254
|
+
|
|
255
|
+
@logger.instrument('Deleting Theme "{self.theme.name}"...')
|
|
256
|
+
async def command(self, value):
|
|
257
|
+
self.themes.remove(self.theme)
|
|
258
|
+
self.themes.save()
|
|
259
|
+
await self.device.select_theme.state()
|
|
260
|
+
return value
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from dataclasses import field, fields
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from typing import Self
|
|
6
|
+
|
|
7
|
+
import homeassistant_api
|
|
8
|
+
|
|
9
|
+
from amniotic.controls import SelectTheme, SelectRecording, EnableRecording, NumberVolume, SelectMediaPlayer, PlayStreamButton, StreamURL, NewTheme, DeleteTheme
|
|
10
|
+
from amniotic.obs import logger
|
|
11
|
+
from amniotic.recording import RecordingMetadata
|
|
12
|
+
from amniotic.theme import ThemeDefinition, IndexThemes
|
|
13
|
+
from fmtr.tools import Path
|
|
14
|
+
from fmtr.tools.iterator_tools import IndexList, IterDiffer
|
|
15
|
+
from haco.device import Device
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class MediaState:
|
|
20
|
+
entity_id: str
|
|
21
|
+
state: str
|
|
22
|
+
friendly_name: str | None = None
|
|
23
|
+
supported_features: int | None = None
|
|
24
|
+
|
|
25
|
+
def __post_init__(self):
|
|
26
|
+
self.friendly_name = self.friendly_name or self.entity_id
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_state(cls, state) -> Self:
|
|
30
|
+
data = state.model_dump()
|
|
31
|
+
data |= data.pop('attributes')
|
|
32
|
+
allowed = {f.name for f in fields(cls)}
|
|
33
|
+
filtered = {k: v for k, v in data.items() if k in allowed}
|
|
34
|
+
self = cls(**filtered)
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(kw_only=True)
|
|
39
|
+
class Amniotic(Device):
|
|
40
|
+
themes: IndexList[ThemeDefinition] = field(default_factory=IndexList, metadata=dict(exclude=True))
|
|
41
|
+
metas: IndexList[RecordingMetadata] = field(default_factory=IndexList, metadata=dict(exclude=True))
|
|
42
|
+
|
|
43
|
+
client_ha: homeassistant_api.Client | None = field(default=None, metadata=dict(exclude=True))
|
|
44
|
+
|
|
45
|
+
path_audio: Path = field(metadata=dict(exclude=True))
|
|
46
|
+
path_audio_schedule_duration: int = field(default=10, metadata=dict(exclude=True))
|
|
47
|
+
|
|
48
|
+
path_audio_schedule_task: asyncio.Task | None = field(default=None, metadata=dict(exclude=True))
|
|
49
|
+
|
|
50
|
+
def __post_init__(self):
|
|
51
|
+
if not self.path_audio.exists():
|
|
52
|
+
logger.warning(f'Audio path "{self.path_audio}" does not exist. Will be created.')
|
|
53
|
+
self.path_audio.mkdir()
|
|
54
|
+
self.metas = IndexList()
|
|
55
|
+
self.refresh_metas()
|
|
56
|
+
|
|
57
|
+
if not self.metas:
|
|
58
|
+
logger.warning(f'No audio files found in "{self.path_audio}". You will need to add some before you can stream anything.')
|
|
59
|
+
|
|
60
|
+
self.themes = IndexThemes.load(self)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
media_players_data = [state for state in self.client_ha.get_states() if state.entity_id.startswith("media_player.")]
|
|
64
|
+
self.media_player_states = IndexList(MediaState.from_state(data) for data in media_players_data)
|
|
65
|
+
|
|
66
|
+
self.controls = [
|
|
67
|
+
self.select_theme,
|
|
68
|
+
self.select_recording,
|
|
69
|
+
self.btn_delete_theme,
|
|
70
|
+
self.txt_new_theme,
|
|
71
|
+
self.swt_play,
|
|
72
|
+
self.nbr_volume,
|
|
73
|
+
self.select_media_player,
|
|
74
|
+
self.btn_play,
|
|
75
|
+
self.sns_url
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@cached_property
|
|
80
|
+
def select_theme(self):
|
|
81
|
+
return SelectTheme(options=[str(defin.name) for defin in self.themes])
|
|
82
|
+
|
|
83
|
+
@cached_property
|
|
84
|
+
def select_recording(self):
|
|
85
|
+
return SelectRecording(options=sorted(self.metas.name.keys()))
|
|
86
|
+
|
|
87
|
+
@cached_property
|
|
88
|
+
def select_media_player(self):
|
|
89
|
+
return SelectMediaPlayer(options=list(self.media_player_states.friendly_name.keys()))
|
|
90
|
+
|
|
91
|
+
@cached_property
|
|
92
|
+
def swt_play(self):
|
|
93
|
+
return EnableRecording()
|
|
94
|
+
|
|
95
|
+
@cached_property
|
|
96
|
+
def btn_play(self):
|
|
97
|
+
return PlayStreamButton()
|
|
98
|
+
|
|
99
|
+
@cached_property
|
|
100
|
+
def sns_url(self):
|
|
101
|
+
return StreamURL()
|
|
102
|
+
|
|
103
|
+
@cached_property
|
|
104
|
+
def nbr_volume(self):
|
|
105
|
+
return NumberVolume()
|
|
106
|
+
|
|
107
|
+
@cached_property
|
|
108
|
+
def txt_new_theme(self):
|
|
109
|
+
return NewTheme()
|
|
110
|
+
|
|
111
|
+
@cached_property
|
|
112
|
+
def btn_delete_theme(self):
|
|
113
|
+
return DeleteTheme()
|
|
114
|
+
|
|
115
|
+
def refresh_metas(self) -> bool:
|
|
116
|
+
|
|
117
|
+
paths_existing = self.metas.path.keys()
|
|
118
|
+
paths_disk = {path for path in self.path_audio.iterdir() if path.is_file()}
|
|
119
|
+
|
|
120
|
+
diff = IterDiffer(paths_existing, paths_disk)
|
|
121
|
+
|
|
122
|
+
for path in diff.added:
|
|
123
|
+
logger.info(f'Adding new recording: "{path}"...')
|
|
124
|
+
meta = RecordingMetadata(path)
|
|
125
|
+
self.metas.append(meta)
|
|
126
|
+
if not self.metas.current:
|
|
127
|
+
self.metas.current = meta
|
|
128
|
+
|
|
129
|
+
return diff.is_changed
|
|
130
|
+
|
|
131
|
+
@logger.instrument('Starting audio file monitoring task. Duration: {self.path_audio_schedule_duration}. Directory: "{self.path_audio}"...')
|
|
132
|
+
async def refresh_metas_task(self):
|
|
133
|
+
while True:
|
|
134
|
+
await asyncio.sleep(self.path_audio_schedule_duration)
|
|
135
|
+
|
|
136
|
+
logger.debug(f'Syncing recordings in "{self.path_audio}"...')
|
|
137
|
+
is_changed = self.refresh_metas()
|
|
138
|
+
if is_changed:
|
|
139
|
+
await self.select_recording.state()
|
|
140
|
+
|
|
141
|
+
async def initialise(self):
|
|
142
|
+
await super().initialise()
|
|
143
|
+
if not self.path_audio_schedule_task:
|
|
144
|
+
self.path_audio_schedule_task = asyncio.create_task(self.refresh_metas_task())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import logging as logging_native
|
|
2
|
+
|
|
3
|
+
from amniotic.paths import paths
|
|
4
|
+
from amniotic.version import __version__
|
|
5
|
+
from fmtr.tools import logging, debug, Constants
|
|
6
|
+
from fmtr.tools.environment_tools import get_bool
|
|
7
|
+
|
|
8
|
+
debug.trace()
|
|
9
|
+
|
|
10
|
+
logger = logging.get_logger(
|
|
11
|
+
name=paths.name_ns,
|
|
12
|
+
stream=Constants.DEVELOPMENT,
|
|
13
|
+
version=__version__,
|
|
14
|
+
level=logging_native.DEBUG if get_bool(Constants.FMTR_DEV_KEY, default=False) else logging_native.INFO # todo: fix runtime environment for addons
|
|
15
|
+
)
|