Compare commits
61 Commits
32b1c54fbc
...
deploy/mus
| Author | SHA1 | Date | |
|---|---|---|---|
| ec9d617ad8 | |||
| 6ccb3e458f | |||
| 83d16e0058 | |||
| ebab03b700 | |||
| 3fea56d4e1 | |||
| e1c10f5408 | |||
| 0ef5a04cb9 | |||
| 3cdad1e714 | |||
| 2514ecbc35 | |||
| cc3db44c4e | |||
| a7fb56c9fe | |||
| fa3d92189c | |||
| dd14f5d901 | |||
| 274085b92e | |||
| f3337975c2 | |||
| e6f6b15cc7 | |||
| f3f7082fb8 | |||
| 36e93be79b | |||
| b6342cc117 | |||
| fd6283718c | |||
| 75b0eed080 | |||
| a69db98241 | |||
| e243862769 | |||
| b8f9f6d537 | |||
| 57ead93497 | |||
| 7f5e2733c2 | |||
| f7a5d24d8d | |||
| a5210fead2 | |||
| 498243af46 | |||
| f97de193d8 | |||
| 7e5fd5ab75 | |||
| fbf03a9847 | |||
| df89acd9a8 | |||
| 829ea89386 | |||
| a3b2c0ce2f | |||
| ba7f082f48 | |||
| 69119bb72a | |||
| 747c390303 | |||
| 57afc32f4a | |||
| 2e0e6ad199 | |||
| 8aed3b022b | |||
| a8ed350aec | |||
| edb6e5e027 | |||
| d44c24ec97 | |||
| 55bcc9448c | |||
| 1cb3387fbe | |||
| 7aa7fe4693 | |||
| bd8925a278 | |||
|
|
b2c060fcc9 | ||
|
|
11bf0505fb | ||
|
|
ef038a29a4 | ||
|
|
d5a4bbb7e2 | ||
|
|
6128d8706d | ||
|
|
6850c9646e | ||
|
|
828d1b3360 | ||
|
|
804940bfac | ||
|
|
a67c6078c5 | ||
|
|
42f0701c0b | ||
|
|
4c5db49c61 | ||
|
|
1458793d74 | ||
|
|
1a17d49599 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,5 +1,14 @@
|
|||||||
generated_3d
|
generated_3d
|
||||||
venv
|
venv
|
||||||
|
.venv
|
||||||
build
|
build
|
||||||
|
*.egg-info
|
||||||
*.FCStd1
|
*.FCStd1
|
||||||
*.blend1
|
*.blend1
|
||||||
|
__pycache__
|
||||||
|
.ipynb_checkpoints
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
.envrc
|
||||||
|
.direnv
|
||||||
|
|||||||
129
docs/REPO_OVERVIEW.md
Normal file
129
docs/REPO_OVERVIEW.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# MusicMouse — repo overview
|
||||||
|
|
||||||
|
Orientation doc for AI agents (or humans) working on this repo for the first time.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
MusicMouse is a DIY, Toniebox-style physical music player for kids, shaped like a mouse
|
||||||
|
and living on a shelf. Small 3D-printed animal figurines (fox, owl, dog, elephant,
|
||||||
|
squirrel, crocodile, rabbit, snowman, puppy — see `hardware/3dprints/figures/`) each
|
||||||
|
carry an RFID tag. Placing a figurine on the mouse starts that figure's playlist. The
|
||||||
|
mouse also has a rotary encoder and capacitive touch areas (ears/feet) for
|
||||||
|
volume/skip control, addressable RGBW LED rings with animated effects, and MQTT/Home
|
||||||
|
Assistant integration.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `python-backend/` | Python host application — the main runtime. Has its own `README.md` with the architecture; start there for backend work. |
|
||||||
|
| `web/` | React + TypeScript front-end (Vite). Browse the whole library and play any of it from a browser. Built output is served by `python-backend` itself. |
|
||||||
|
| `esp-firmware/` | ESP32 firmware (C++, Arduino framework via PlatformIO). Reads the RFID reader and buttons, drives the LED strips, talks to `python-backend` over serial. |
|
||||||
|
| `hardware/` | 3D-print models for the figurines and enclosure (FreeCAD/Blender/OBJ/STL), a Fritzing electronics sketch, datasheets, and `pinout.md` (RFID reader + button-board wiring). |
|
||||||
|
| `claude-design/` | The interaction and visual spec the web front-end was built from ("Dolphin Beats"), as a standalone HTML mockup with hardcoded data. `web/` is the real implementation; the mockup is kept as the reference for the keyboard model and the styling. It also contains a second page, "Mein Zimmer" (room lights), which is **not** implemented. |
|
||||||
|
|
||||||
|
## How the pieces talk to each other
|
||||||
|
|
||||||
|
- **ESP32 firmware ↔ `python-backend`**: a length-prefixed binary protocol over serial.
|
||||||
|
Frames are `uint32 magic | uint8 type | uint16 size | payload`, little-endian, with
|
||||||
|
firmware log text interleaved on the same link. The Python side lives in
|
||||||
|
`python-backend/musicmouse/devices/wire.py`, the firmware side in
|
||||||
|
`esp-firmware/src/Messages.h`.
|
||||||
|
|
||||||
|
The contract is hand-duplicated in two languages. `tests/test_wire.py` parses
|
||||||
|
`Messages.h` and fails if the message ids drift, and `tests/test_effects.py` pins the
|
||||||
|
exact bytes of every effect payload — so a firmware change that breaks the host now
|
||||||
|
breaks a test instead of just the LEDs.
|
||||||
|
|
||||||
|
- **`python-backend` ↔ `web/`**: JSON over HTTP for the library and for commands, plus a
|
||||||
|
push-only websocket at `/api/ws` for state. Commands emit exactly the same *intents*
|
||||||
|
the physical buttons emit, so the web UI has no privileged path — and no way to get
|
||||||
|
out of step with a figure someone puts on the reader. See
|
||||||
|
`python-backend/README.md` for the endpoint list.
|
||||||
|
|
||||||
|
- **`python-backend` ↔ Home Assistant**: MQTT only. The backend publishes three
|
||||||
|
discoverable lights, a player sensor, a volume number, transport buttons, device
|
||||||
|
triggers for every button and touch area, and a tag scanner for the RFID reader. It
|
||||||
|
does *not* call Home Assistant services directly any more; behaviour like "the left
|
||||||
|
ear turns the room light pink" is an HA automation. See `python-backend/README.md`
|
||||||
|
for the trigger topics and the old colour mapping.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
On a machine with no mouse attached — real audio and a real web UI, no serial port:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --no-hardware
|
||||||
|
```
|
||||||
|
|
||||||
|
`general.serial_port: simulate` does the same thing from the config, and
|
||||||
|
`general.alsa_device: simulate` swaps in a silent player. Both warn at startup, and
|
||||||
|
both keys are required - omitting one is an error rather than an implicit simulation.
|
||||||
|
|
||||||
|
Or with no hardware *and* no audio:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --simulate
|
||||||
|
```
|
||||||
|
|
||||||
|
The simulator runs the whole app against a fake serial link and a fake player, either
|
||||||
|
interactively or from a scenario file. `python-backend/musicmouse.service` is the
|
||||||
|
systemd unit for the device.
|
||||||
|
|
||||||
|
For front-end work, run the backend (either way above) and then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd web && npm install && npm run dev # http://localhost:5173, /api proxied to :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` writes `web/dist`, which `general.web.static_dir` points at in
|
||||||
|
production so one process serves both the UI and the API.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
`python-backend/config.yml.example` documents the schema. In short:
|
||||||
|
|
||||||
|
- `general.{library.*, serial_port, baudrate, reconnect_interval, alsa_device,
|
||||||
|
min_volume, max_volume, initial_volume, volume_increment, button_leds_brightness,
|
||||||
|
audio_extensions, mqtt.*, web.*}`
|
||||||
|
- `figures.<name>.{id, colors}` — `id` is a 5-byte hex RFID tag, unique per figure;
|
||||||
|
`colors` is exactly four (`primary, secondary, bg, accent`), each `"#rrggbb"` or
|
||||||
|
`"wNN"`.
|
||||||
|
|
||||||
|
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||||
|
names, not settings, because each has quirks the scanner knows about:
|
||||||
|
|
||||||
|
```
|
||||||
|
<root>/Figuren/<figure name>/ one folder per figurine
|
||||||
|
<root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
|
<root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
|
<root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
|
```
|
||||||
|
|
||||||
|
Config is validated with pydantic: unknown keys are errors, and every problem is
|
||||||
|
reported at once.
|
||||||
|
|
||||||
|
## Notes for agents
|
||||||
|
|
||||||
|
- The backend has `pytest`, `ruff` and `mypy --strict` configured in
|
||||||
|
`python-backend/pyproject.toml`, and no CI. Run all three before proposing changes.
|
||||||
|
- Prefer adding a scenario in `python-backend/scenarios/` over a hand-rolled test when
|
||||||
|
the behaviour is end-to-end — those files are executed by the test suite.
|
||||||
|
- `python-backend/notebooks/` is university course material on chord recognition, not
|
||||||
|
part of the app.
|
||||||
|
- The firmware has no automated tests beyond a PlatformIO `native` env for LED effects.
|
||||||
|
- The front-end has `vitest` over the pure modules (`web/src/lib/`) and `tsc --noEmit`;
|
||||||
|
there is no component-level test harness. `npm run test` and `npx tsc --noEmit` are
|
||||||
|
the two checks.
|
||||||
|
- The library index is cached under `general.library.cache`. It is keyed by file mtime
|
||||||
|
and size, so **a change to how the scanner derives a title, artist or series is
|
||||||
|
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
||||||
|
`musicmouse/library/cache.py` when you touch that logic.
|
||||||
|
- Track analysis (librosa) runs in a pool of worker *processes* - see
|
||||||
|
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
|
||||||
|
picklable, and an analyzer that records state in its own instance (a test double
|
||||||
|
counting calls) only behaves as written with `analysis_workers=1`.
|
||||||
@@ -3,5 +3,8 @@
|
|||||||
// for the documentation about the extensions.json format
|
// for the documentation about the extensions.json format
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"platformio.platformio-ide"
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -43,5 +43,15 @@
|
|||||||
"cinttypes": "cpp",
|
"cinttypes": "cpp",
|
||||||
"utility": "cpp",
|
"utility": "cpp",
|
||||||
"typeinfo": "cpp"
|
"typeinfo": "cpp"
|
||||||
|
},
|
||||||
|
"vsmqtt.brokerProfiles": [
|
||||||
|
{
|
||||||
|
"name": "homeassistant",
|
||||||
|
"host": "homeassistant",
|
||||||
|
"port": 1883,
|
||||||
|
"username": "musicmouse",
|
||||||
|
"clientId": "vsmqtt_client",
|
||||||
|
"password": "KNLEFLZF94yA6Zhj141",
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ enum class EffectId
|
|||||||
RANDOM_TWO_COLOR_INTERPOLATION,
|
RANDOM_TWO_COLOR_INTERPOLATION,
|
||||||
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
SWIPE_AND_CHANGE, // combination of ALEXA_SWIPE and RANDOM_TWO_COLOR_INTERPOLATION
|
||||||
REVERSE_SWIPE,
|
REVERSE_SWIPE,
|
||||||
|
STATIC_DETAILED,
|
||||||
};
|
};
|
||||||
|
|
||||||
template <EffectId id>
|
template <EffectId id>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "effects/Common.h"
|
#include "effects/Common.h"
|
||||||
#include "helpers/ColorRGBW.h"
|
#include "helpers/ColorRGBW.h"
|
||||||
|
|
||||||
|
#pragma pack(push, 1)
|
||||||
struct EffectStaticConfig
|
struct EffectStaticConfig
|
||||||
{
|
{
|
||||||
EffectStaticConfig(const ColorRGBW &c = ColorRGBW{0, 0, 0, 0}, uint16_t beg = 0, uint16_t en = 0)
|
EffectStaticConfig(const ColorRGBW &c = ColorRGBW{0, 0, 0, 0}, uint16_t beg = 0, uint16_t en = 0)
|
||||||
@@ -12,12 +13,14 @@ struct EffectStaticConfig
|
|||||||
uint16_t begin = 0;
|
uint16_t begin = 0;
|
||||||
uint16_t end = 0;
|
uint16_t end = 0;
|
||||||
};
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
template <typename TLedStrip>
|
template <typename TLedStrip>
|
||||||
class EffectStatic
|
class EffectStatic
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
||||||
|
using ConfigType = EffectStaticConfig;
|
||||||
|
|
||||||
EffectStatic(const EffectStaticConfig &cfg, TLedStrip &ledStrip)
|
EffectStatic(const EffectStaticConfig &cfg, TLedStrip &ledStrip)
|
||||||
: config_(cfg),
|
: config_(cfg),
|
||||||
107
esp-firmware/lib/ledtl/effects/StaticDetailed.h
Normal file
107
esp-firmware/lib/ledtl/effects/StaticDetailed.h
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "effects/Common.h"
|
||||||
|
#include "helpers/ColorRGBW.h"
|
||||||
|
#include "helpers/ColorConversions.h"
|
||||||
|
|
||||||
|
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct EffectStaticDetailedConfig
|
||||||
|
{
|
||||||
|
EffectStaticDetailedConfig(const ColorRGBW &c = ColorRGBW{0, 0, 0, 0}, uint16_t beg = 0, uint16_t en = 0)
|
||||||
|
: color(c), begin(beg), end(en) {}
|
||||||
|
|
||||||
|
ColorRGBW color;
|
||||||
|
uint16_t increment = 1;
|
||||||
|
float begin = 0.0f;
|
||||||
|
float end = 0.0f;
|
||||||
|
float transition_time_in_ms = 0.0f;
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
|
template <typename TLedStrip>
|
||||||
|
class EffectStaticDetailed
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static constexpr auto NUM_LEDS = numLeds<TLedStrip>();
|
||||||
|
static constexpr int DELAY_MS = 10;
|
||||||
|
using ConfigType = EffectStaticDetailedConfig;
|
||||||
|
|
||||||
|
EffectStaticDetailed(const EffectStaticDetailedConfig &cfg, TLedStrip &ledStrip)
|
||||||
|
: config_(cfg),
|
||||||
|
ledStrip_(ledStrip)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < NUM_LEDS; ++i)
|
||||||
|
state_[i] = getLedRGBW(ledStrip_, i);
|
||||||
|
|
||||||
|
beginIdx_ = constrain(static_cast<int>(cfg.begin * NUM_LEDS + 0.5f), 0, NUM_LEDS - 1);
|
||||||
|
endIdx_ = constrain(static_cast<int>(cfg.end * NUM_LEDS + 0.5f), 0, NUM_LEDS - 1);
|
||||||
|
while (endIdx_ < beginIdx_)
|
||||||
|
endIdx_ += NUM_LEDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool finished() const { return finished_; }
|
||||||
|
|
||||||
|
int operator()()
|
||||||
|
{
|
||||||
|
if (finished_)
|
||||||
|
return 1000000;
|
||||||
|
|
||||||
|
const float progress = config_.transition_time_in_ms > 0.0f ? static_cast<float>(DELAY_MS * calls_) / config_.transition_time_in_ms : 1.f;
|
||||||
|
|
||||||
|
// Finished case
|
||||||
|
if (config_.transition_time_in_ms <= 0.0f || progress >= 1.0)
|
||||||
|
{
|
||||||
|
finished_ = true;
|
||||||
|
clear(ledStrip_);
|
||||||
|
for (int i = beginIdx_; i < endIdx_; i += config_.increment)
|
||||||
|
setLedRGBW(ledStrip_, i % NUM_LEDS, config_.color);
|
||||||
|
return 10000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-progress case
|
||||||
|
clear(ledStrip_);
|
||||||
|
for (int i = beginIdx_; i < endIdx_; i += config_.increment)
|
||||||
|
{
|
||||||
|
const auto idx = i % NUM_LEDS;
|
||||||
|
ColorRGBW newColor = ColorRGBW::interpolate(state_[idx], config_.color, progress);
|
||||||
|
setLedRGBW(ledStrip_, idx, newColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
++calls_;
|
||||||
|
return DELAY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static int int_interpolate(int prev, int next, float progress)
|
||||||
|
{
|
||||||
|
return static_cast<float>(prev) * (1 - progress) +
|
||||||
|
static_cast<float>(next) * progress;
|
||||||
|
}
|
||||||
|
EffectStaticDetailedConfig config_;
|
||||||
|
TLedStrip &ledStrip_;
|
||||||
|
ColorRGBW state_[NUM_LEDS];
|
||||||
|
int beginIdx_;
|
||||||
|
int endIdx_;
|
||||||
|
int calls_ = 0;
|
||||||
|
bool finished_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Traits
|
||||||
|
template <>
|
||||||
|
struct EffectIdToConfig<EffectId::STATIC_DETAILED>
|
||||||
|
{
|
||||||
|
using type = EffectStaticDetailedConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <>
|
||||||
|
struct EffectConfigToId<EffectStaticDetailedConfig>
|
||||||
|
{
|
||||||
|
static constexpr auto id = EffectId::STATIC_DETAILED;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <typename TLedStrip>
|
||||||
|
struct EffectIdToClass<EffectId::STATIC_DETAILED, TLedStrip>
|
||||||
|
{
|
||||||
|
using type = EffectStaticDetailed<TLedStrip>;
|
||||||
|
};
|
||||||
6
esp-firmware/lib/ledtl/effects/swipe.py
Normal file
6
esp-firmware/lib/ledtl/effects/swipe.py
Normal file
File diff suppressed because one or more lines are too long
26
esp-firmware/lib/ledtl/helpers/ColorRGBW.h
Normal file
26
esp-firmware/lib/ledtl/helpers/ColorRGBW.h
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
struct ColorRGBW
|
||||||
|
{
|
||||||
|
uint8_t r, g, b, w;
|
||||||
|
|
||||||
|
ColorRGBW operator*(float s) const
|
||||||
|
{
|
||||||
|
return {uint8_t(s * r),
|
||||||
|
uint8_t(s * g),
|
||||||
|
uint8_t(s * b),
|
||||||
|
uint8_t(s * w)};
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline ColorRGBW interpolate(const ColorRGBW &c1, const ColorRGBW &c2, float f)
|
||||||
|
{
|
||||||
|
return ColorRGBW{
|
||||||
|
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.r) + f * static_cast<float>(c2.r)),
|
||||||
|
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.g) + f * static_cast<float>(c2.g)),
|
||||||
|
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.b) + f * static_cast<float>(c2.b)),
|
||||||
|
static_cast<uint8_t>((1.0f - f) * static_cast<float>(c1.w) + f * static_cast<float>(c2.w)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -104,14 +104,22 @@ enum class MessageHostToFw : uint8_t
|
|||||||
LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 3,
|
LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 3,
|
||||||
LED_WHEEL_EFFECT_SWIPE_AND_CHANGE = 4,
|
LED_WHEEL_EFFECT_SWIPE_AND_CHANGE = 4,
|
||||||
LED_WHEEL_EFFECT_REVERSE_SWIPE = 5,
|
LED_WHEEL_EFFECT_REVERSE_SWIPE = 5,
|
||||||
|
|
||||||
MOUSE_LED_EFFECT_STATIC = 6,
|
MOUSE_LED_EFFECT_STATIC = 6,
|
||||||
MOUSE_LED_EFFECT_CIRCULAR = 7,
|
MOUSE_LED_EFFECT_CIRCULAR = 7,
|
||||||
MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 8,
|
MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 8,
|
||||||
MOUSE_LED_EFFECT_SWIPE_AND_CHANGE = 9,
|
MOUSE_LED_EFFECT_SWIPE_AND_CHANGE = 9,
|
||||||
MOUSE_LED_EFFECT_REVERSE_SWIPE = 10,
|
MOUSE_LED_EFFECT_REVERSE_SWIPE = 10,
|
||||||
|
|
||||||
PREV_BUTTON_LED = 20,
|
SHELF_LED_EFFECT_STATIC = 15,
|
||||||
NEXT_BUTTON_LED = 21,
|
SHELF_LED_EFFECT_CIRCULAR = 16,
|
||||||
|
SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION = 17,
|
||||||
|
SHELF_LED_EFFECT_SWIPE_AND_CHANGE = 18,
|
||||||
|
SHELF_LED_EFFECT_REVERSE_SWIPE = 19,
|
||||||
|
SHELF_LED_EFFECT_STATIC_DETAILED = 20,
|
||||||
|
|
||||||
|
PREV_BUTTON_LED = 21,
|
||||||
|
NEXT_BUTTON_LED = 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
template <>
|
template <>
|
||||||
@@ -158,8 +166,21 @@ void sendMessageToHost(const TMessage &msg)
|
|||||||
Serial.write((uint8_t *)&msg, sizeof(msg));
|
Serial.write((uint8_t *)&msg, sizeof(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
template <typename LedTask1, typename LedTask2>
|
template <typename TEffectConfig, typename TLedTask>
|
||||||
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, uint8_t ledChannelLeft, uint8_t ledChannelRight)
|
inline bool handleLedEffect(TLedTask *ledTask, MessageHostToFw msgType, MessageHostToFw incomingMsgType, uint8_t *msgBuffer)
|
||||||
|
{
|
||||||
|
if (msgType == incomingMsgType)
|
||||||
|
{
|
||||||
|
auto cfg = reinterpret_cast<TEffectConfig *>(msgBuffer);
|
||||||
|
ledTask->startEffect(*cfg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename LedTask1, typename LedTask2, typename LedTaskShelf>
|
||||||
|
inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *ledTaskMouse, LedTaskShelf *ledTaskShelf, uint8_t ledChannelLeft, uint8_t ledChannelRight)
|
||||||
{
|
{
|
||||||
if (Serial.available() < sizeof(MAGIC_TOKEN_FW_TO_HOST) + sizeof(MessageHostToFw) + sizeof(uint16_t))
|
if (Serial.available() < sizeof(MAGIC_TOKEN_FW_TO_HOST) + sizeof(MessageHostToFw) + sizeof(uint16_t))
|
||||||
return;
|
return;
|
||||||
@@ -180,65 +201,36 @@ inline void handleIncomingMessagesFromHost(LedTask1 *ledTaskCircle, LedTask2 *le
|
|||||||
|
|
||||||
static constexpr int maxIncomingBufferSize = 1024;
|
static constexpr int maxIncomingBufferSize = 1024;
|
||||||
static uint8_t msgBuffer[maxIncomingBufferSize];
|
static uint8_t msgBuffer[maxIncomingBufferSize];
|
||||||
|
|
||||||
if (msgSize < maxIncomingBufferSize)
|
if (msgSize < maxIncomingBufferSize)
|
||||||
{
|
{
|
||||||
Serial.readBytes(msgBuffer, msgSize);
|
Serial.readBytes(msgBuffer, msgSize);
|
||||||
if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_STATIC)
|
|
||||||
{
|
// clang-format off
|
||||||
auto cfg = reinterpret_cast<EffectStaticConfig *>(msgBuffer);
|
// LED Circle
|
||||||
ledTaskCircle->startEffect(*cfg);
|
if(handleLedEffect<EffectStaticConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_STATIC, msgType, msgBuffer)) {}
|
||||||
}
|
else if(handleLedEffect<EffectAlexaSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_ALEXA_SWIPE, msgType, msgBuffer)) {}
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_ALEXA_SWIPE)
|
else if(handleLedEffect<EffectCircularConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_CIRCULAR, msgType, msgBuffer)) {}
|
||||||
{
|
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
||||||
auto cfg = reinterpret_cast<EffectAlexaSwipeConfig *>(msgBuffer);
|
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
||||||
ledTaskCircle->startEffect(*cfg);
|
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskCircle, MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_CIRCULAR)
|
// Mouse LEDs
|
||||||
{
|
else if(handleLedEffect<EffectStaticConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
|
||||||
auto cfg = reinterpret_cast<EffectCircularConfig *>(msgBuffer);
|
else if(handleLedEffect<EffectCircularConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_CIRCULAR, msgType, msgBuffer)) {}
|
||||||
ledTaskCircle->startEffect(*cfg);
|
else if(handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
||||||
}
|
else if(handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION)
|
else if(handleLedEffect<EffectReverseSwipeConfig >(ledTaskMouse, MessageHostToFw::MOUSE_LED_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectRandomTwoColorInterpolationConfig *>(msgBuffer);
|
// Shelf LEDs
|
||||||
ledTaskCircle->startEffect(*cfg);
|
else if (handleLedEffect<EffectStaticConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_STATIC, msgType, msgBuffer)) {}
|
||||||
}
|
else if (handleLedEffect<EffectCircularConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_CIRCULAR, msgType, msgBuffer)) {}
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_SWIPE_AND_CHANGE)
|
else if (handleLedEffect<EffectRandomTwoColorInterpolationConfig>(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION, msgType, msgBuffer)) {}
|
||||||
{
|
else if (handleLedEffect<EffectSwipeAndChangeConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_SWIPE_AND_CHANGE, msgType, msgBuffer)) {}
|
||||||
auto cfg = reinterpret_cast<EffectSwipeAndChangeConfig *>(msgBuffer);
|
else if (handleLedEffect<EffectReverseSwipeConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_REVERSE_SWIPE, msgType, msgBuffer)) {}
|
||||||
ledTaskCircle->startEffect(*cfg);
|
else if (handleLedEffect<EffectStaticDetailedConfig >(ledTaskShelf, MessageHostToFw::SHELF_LED_EFFECT_STATIC_DETAILED, msgType, msgBuffer)) {}
|
||||||
}
|
// clang-format on
|
||||||
else if (msgType == MessageHostToFw::LED_WHEEL_EFFECT_REVERSE_SWIPE)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectReverseSwipeConfig *>(msgBuffer);
|
|
||||||
ledTaskCircle->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
//
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_STATIC)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectStaticConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_CIRCULAR)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectCircularConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_RANDOM_TWO_COLOR_INTERPOLATION)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectRandomTwoColorInterpolationConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_SWIPE_AND_CHANGE)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectSwipeAndChangeConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::MOUSE_LED_EFFECT_REVERSE_SWIPE)
|
|
||||||
{
|
|
||||||
auto cfg = reinterpret_cast<EffectReverseSwipeConfig *>(msgBuffer);
|
|
||||||
ledTaskMouse->startEffect(*cfg);
|
|
||||||
}
|
|
||||||
else if (msgType == MessageHostToFw::PREV_BUTTON_LED)
|
else if (msgType == MessageHostToFw::PREV_BUTTON_LED)
|
||||||
{
|
{
|
||||||
float *val = reinterpret_cast<float *>(msgBuffer);
|
float *val = reinterpret_cast<float *>(msgBuffer);
|
||||||
@@ -77,6 +77,7 @@ void _led_task_func(void *params)
|
|||||||
// clang-format off
|
// clang-format off
|
||||||
if (dispatchEffectId<EffectId::CIRCULAR >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
if (dispatchEffectId<EffectId::CIRCULAR >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::STATIC >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::STATIC >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
|
else if (dispatchEffectId<EffectId::STATIC_DETAILED >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::ALEXA_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::ALEXA_SWIPE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::RANDOM_TWO_COLOR_INTERPOLATION>(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
else if (dispatchEffectId<EffectId::SWIPE_AND_CHANGE >(id, effectFunction, ledStrip, msgBuffer, effectStorage)) {}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "drivers/Esp32DriverRGBW.h"
|
#include "drivers/Esp32DriverRGBW.h"
|
||||||
#include "effects/Circular.h"
|
#include "effects/Circular.h"
|
||||||
#include "effects/Static.h"
|
#include "effects/Static.h"
|
||||||
|
#include "effects/StaticDetailed.h"
|
||||||
#include "effects/AlexaSwipe.h"
|
#include "effects/AlexaSwipe.h"
|
||||||
#include "effects/RandomTwoColorInterpolation.h"
|
#include "effects/RandomTwoColorInterpolation.h"
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ void setupMouseLeds()
|
|||||||
|
|
||||||
// -------------------------------------------------- Shelf Leds -------------------------------------------
|
// -------------------------------------------------- Shelf Leds -------------------------------------------
|
||||||
|
|
||||||
LedStripRGBW<250> ledStripShelf;
|
LedStripRGBW<252> ledStripShelf;
|
||||||
Esp32DriverRGBW ledDriverShelf;
|
Esp32DriverRGBW ledDriverShelf;
|
||||||
LedTask<decltype(ledStripShelf)> ledTaskShelf;
|
LedTask<decltype(ledStripShelf)> ledTaskShelf;
|
||||||
|
|
||||||
@@ -166,7 +167,7 @@ void setupShelfLeds()
|
|||||||
{
|
{
|
||||||
ledDriverShelf.begin(17, 2);
|
ledDriverShelf.begin(17, 2);
|
||||||
ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
|
ledTaskShelf.begin(ledStripShelf, ledDriverShelf);
|
||||||
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 30}, 0, 0});
|
ledTaskShelf.startEffect(EffectStaticConfig{ColorRGBW{0, 0, 0, 0}, 0, 0});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------- Touch Buttons ----------------------------------------
|
// -------------------------------------------------- Touch Buttons ----------------------------------------
|
||||||
@@ -233,7 +234,7 @@ void setup()
|
|||||||
|
|
||||||
void loop()
|
void loop()
|
||||||
{
|
{
|
||||||
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, 0, 1);
|
handleIncomingMessagesFromHost(&ledTaskCircle, &ledTaskMouse, &ledTaskShelf, 0, 1);
|
||||||
handleTouchInputs();
|
handleTouchInputs();
|
||||||
handleRotaryEncoder();
|
handleRotaryEncoder();
|
||||||
handleButtons();
|
handleButtons();
|
||||||
40
esp-firmware/todo.md
Normal file
40
esp-firmware/todo.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
- button hintergrund beleuchtung [ok]
|
||||||
|
|
||||||
|
- playlisten
|
||||||
|
- runterladen
|
||||||
|
|
||||||
|
- befestigung im regal
|
||||||
|
- winkel
|
||||||
|
- mehrfachsteckdose
|
||||||
|
- lan kabel
|
||||||
|
|
||||||
|
- effekt kanal fuer audioeffekte
|
||||||
|
- "boing" etc runterladen
|
||||||
|
|
||||||
|
- Fernbedienung wenn empfaenger da
|
||||||
|
- HA regeln fuer standard
|
||||||
|
|
||||||
|
|
||||||
|
- ansible cleanup
|
||||||
|
- lirc
|
||||||
|
- musicmouse kanal
|
||||||
|
- musicmouse effect kanal
|
||||||
|
|
||||||
|
- home assistant anbindung
|
||||||
|
- events an HA (figur, button press, ...)
|
||||||
|
- mouse & ring leds von HA
|
||||||
|
- HA device control (led fluter, rollos)
|
||||||
|
- regal licht von HA aus
|
||||||
|
|
||||||
|
- Regal LEDs
|
||||||
|
- kabel von musikmaus
|
||||||
|
- Leisten zuschneiden
|
||||||
|
- kabel auf richtige laenge zuschneiden
|
||||||
|
- Kabel loeten
|
||||||
|
- im Arbeitszimmer testen
|
||||||
|
- Bonus: Ecken drucken
|
||||||
|
|
||||||
|
- Effekte Regal LEDs
|
||||||
|
|
||||||
|
- Musik-abhaengige Effekte
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
|
|
||||||
Reader
|
|
||||||
----------
|
|
||||||
|
|
||||||
- GND black
|
|
||||||
- RST blue 3.3V
|
|
||||||
- 3.3V red
|
|
||||||
- MISO brown 21
|
|
||||||
- SDA green 19
|
|
||||||
- SCK yellow 18
|
|
||||||
- MOSI orange 5
|
|
||||||
- IRQ green single cable not connected
|
|
||||||
|
|
||||||
|
|
||||||
Button Board:
|
|
||||||
-------------
|
|
||||||
|
|
||||||
- rot in | white 13
|
|
||||||
- btn2 led | grey 12
|
|
||||||
- btn2 in | purple 14
|
|
||||||
- rotB | blue 27
|
|
||||||
- rotA | green 26
|
|
||||||
- btn1 in | yellow 25
|
|
||||||
- btn1 led | orange 33
|
|
||||||
|
|
||||||
rot="rotary encoder"
|
|
||||||
in=button sense in
|
|
||||||
led = 5V pwm
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Firmware Planning
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
- input commands:
|
|
||||||
- led: effect + parameters
|
|
||||||
- off
|
|
||||||
- single color
|
|
||||||
- multiple color HSV fade, list of colors with timings
|
|
||||||
- circular motion (already exists)
|
|
||||||
- chained events? e.g. circle two times then fade
|
|
||||||
- effects:
|
|
||||||
- welle fuer an und aus
|
|
||||||
- breathe waehrend an, oder farbgradient
|
|
||||||
-
|
|
||||||
- output infos:
|
|
||||||
- nfc read: with id
|
|
||||||
- nfc remove
|
|
||||||
- button presses, (possible also long press, double click, etc)
|
|
||||||
- rotary encoder up down + current numeric state
|
|
||||||
- on led effect end?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
TODO
|
|
||||||
----
|
|
||||||
|
|
||||||
1) case redesign
|
|
||||||
- slightly smaller led ring (10mm -> 9mm) [ok]
|
|
||||||
- thicker top of inner ring, but cutouts for reader [ok]
|
|
||||||
- adjust reader stands position [ok]
|
|
||||||
- bottom for led ring snap-in [ok]
|
|
||||||
- bottom for inner ring [ok]
|
|
||||||
- stands for own "pcb" [ok]
|
|
||||||
- 2 cutouts for cables [ok]
|
|
||||||
- checks, compared to existing print
|
|
||||||
- same diameter, very slightly smaller
|
|
||||||
- larger overlap of LED ring
|
|
||||||
- minimal wall thickness for led ring top and side
|
|
||||||
- check total height - compare to existing
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from enum import Enum
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from led_cmds import *
|
|
||||||
|
|
||||||
MAGIC_TOKEN_HOST_TO_FW = 0x1d6379e3
|
|
||||||
MAGIC_TOKEN_FW_TO_HOST = 0x10c65631
|
|
||||||
|
|
||||||
|
|
||||||
class MessageFwToHost(Enum):
|
|
||||||
RFID_TOKEN_READ = 0
|
|
||||||
ROTARY_ENCODER = 1
|
|
||||||
TOUCH_BUTTON_PRESS = 2
|
|
||||||
TOUCH_BUTTON_RELEASE = 3
|
|
||||||
|
|
||||||
|
|
||||||
class TouchButton(Enum):
|
|
||||||
LEFT_FOOT = 0
|
|
||||||
RIGHT_FOOT = 1
|
|
||||||
LEFT_EAR = 2
|
|
||||||
RIGHT_EAR = 3
|
|
||||||
|
|
||||||
|
|
||||||
led_ring_effect_to_message_id = {
|
|
||||||
EffectStaticConfig: 0,
|
|
||||||
EffectAlexaSwipeConfig: 1,
|
|
||||||
EffectCircularConfig: 2,
|
|
||||||
EffectRandomTwoColorInterpolationConfig: 3,
|
|
||||||
EffectSwipeAndChange: 4,
|
|
||||||
EffectReverseSwipe: 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
mouse_led_effect_to_message_id = {
|
|
||||||
EffectStaticConfig: 6,
|
|
||||||
EffectCircularConfig: 7,
|
|
||||||
EffectRandomTwoColorInterpolationConfig: 8,
|
|
||||||
EffectSwipeAndChange: 9,
|
|
||||||
EffectReverseSwipe: 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
mouse_leds_index_ranges = {
|
|
||||||
TouchButton.RIGHT_FOOT: (0, 6),
|
|
||||||
TouchButton.LEFT_FOOT: (6, 6 + 6),
|
|
||||||
TouchButton.LEFT_EAR: (6 + 6, 6 + 6 + 16),
|
|
||||||
TouchButton.RIGHT_EAR: (6 + 6 + 16, 6 + 6 + 16 + 17),
|
|
||||||
}
|
|
||||||
|
|
||||||
PREV_BUTTON_LED_MSG = 20
|
|
||||||
NEXT_BUTTON_LED_MSG = 21
|
|
||||||
|
|
||||||
|
|
||||||
class RfidTokenRead:
|
|
||||||
def __init__(self, id: bytes):
|
|
||||||
self.id = id
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "RFID Token (" + " ".join(f"{v:02x}" for v in self.id) + ")"
|
|
||||||
|
|
||||||
|
|
||||||
class RotaryEncoderEvent:
|
|
||||||
def __init__(self, msg_content: bytes):
|
|
||||||
self.position, self.increment, self.direction = struct.unpack("<iiB", msg_content)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"Rotary event: pos {self.position}, incr {self.increment}, dir {self.direction}"
|
|
||||||
|
|
||||||
|
|
||||||
class TouchButtonPress:
|
|
||||||
def __init__(self, msg_content: bytes):
|
|
||||||
val = int(msg_content[0])
|
|
||||||
self.touch_button = TouchButton(val)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return "Pressed " + repr(self.touch_button)
|
|
||||||
|
|
||||||
|
|
||||||
class TouchButtonRelease:
|
|
||||||
def __init__(self, msg_content: bytes):
|
|
||||||
val = int(msg_content[0])
|
|
||||||
self.touch_button = TouchButton(val)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return "Released " + repr(self.touch_button)
|
|
||||||
|
|
||||||
|
|
||||||
class ButtonEvent:
|
|
||||||
button_name = {1: 'left', 2: 'right', 3: 'rotary'}
|
|
||||||
event_name = {
|
|
||||||
0: 'pressed',
|
|
||||||
1: 'released',
|
|
||||||
2: 'clicked',
|
|
||||||
3: 'double_clicked',
|
|
||||||
4: 'long_pressed',
|
|
||||||
5: 'repeat_pressed',
|
|
||||||
6: 'long_released'
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, msg_content: bytes):
|
|
||||||
button_nr, event_nr = struct.unpack("<BB", msg_content)
|
|
||||||
self.button = self.button_name[button_nr]
|
|
||||||
self.event = self.event_name[event_nr]
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"Button {self.button} {self.event}"
|
|
||||||
|
|
||||||
|
|
||||||
incomingMsgMap = {
|
|
||||||
0: RfidTokenRead,
|
|
||||||
1: RotaryEncoderEvent,
|
|
||||||
2: TouchButtonPress,
|
|
||||||
3: TouchButtonRelease,
|
|
||||||
4: ButtonEvent,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MusicMouseProtocol(asyncio.Protocol):
|
|
||||||
def __init__(self):
|
|
||||||
super()
|
|
||||||
self._msg_callback = None
|
|
||||||
|
|
||||||
def register_message_callback(self, cb):
|
|
||||||
self._msg_callback = cb
|
|
||||||
|
|
||||||
def connection_made(self, transport):
|
|
||||||
self.transport = transport
|
|
||||||
self.in_buff = bytes()
|
|
||||||
|
|
||||||
def led_ring_effect(self, effect_cfg):
|
|
||||||
msg_content = effect_cfg.as_bytes()
|
|
||||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW,
|
|
||||||
led_ring_effect_to_message_id[type(effect_cfg)], len(msg_content))
|
|
||||||
self.transport.write(header + msg_content)
|
|
||||||
|
|
||||||
def mouse_led_effect(self, effect_cfg):
|
|
||||||
msg_content = effect_cfg.as_bytes()
|
|
||||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW,
|
|
||||||
mouse_led_effect_to_message_id[type(effect_cfg)], len(msg_content))
|
|
||||||
self.transport.write(header + msg_content)
|
|
||||||
|
|
||||||
def button_background_led_prev(self, val):
|
|
||||||
msg_content = struct.pack("<f", val)
|
|
||||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, PREV_BUTTON_LED_MSG, len(msg_content))
|
|
||||||
self.transport.write(header + msg_content)
|
|
||||||
|
|
||||||
def button_background_led_next(self, val):
|
|
||||||
msg_content = struct.pack("<f", val)
|
|
||||||
header = struct.pack("<IBH", MAGIC_TOKEN_HOST_TO_FW, NEXT_BUTTON_LED_MSG, len(msg_content))
|
|
||||||
self.transport.write(header + msg_content)
|
|
||||||
|
|
||||||
def data_received(self, data):
|
|
||||||
self.in_buff += data
|
|
||||||
self._parse_message()
|
|
||||||
|
|
||||||
def connection_lost(self, exc):
|
|
||||||
print('port closed')
|
|
||||||
self.transport.loop.stop()
|
|
||||||
|
|
||||||
def pause_writing(self):
|
|
||||||
print('pause writing')
|
|
||||||
print(self.transport.get_write_buffer_size())
|
|
||||||
|
|
||||||
def resume_writing(self):
|
|
||||||
print(self.transport.get_write_buffer_size())
|
|
||||||
print('resume writing')
|
|
||||||
|
|
||||||
def _parse_message(self):
|
|
||||||
HEADER_SIZE = 4 + 1 + 2
|
|
||||||
if len(self.in_buff) == 0:
|
|
||||||
return
|
|
||||||
if len(self.in_buff) >= HEADER_SIZE:
|
|
||||||
token, msg_type, msg_size = struct.unpack("<IBH", self.in_buff[:HEADER_SIZE])
|
|
||||||
if token == MAGIC_TOKEN_FW_TO_HOST and len(self.in_buff) >= HEADER_SIZE + msg_size:
|
|
||||||
self._on_msg_receive(msg_type, self.in_buff[HEADER_SIZE:HEADER_SIZE + msg_size])
|
|
||||||
self.in_buff = self.in_buff[HEADER_SIZE + msg_size:]
|
|
||||||
else:
|
|
||||||
idx = self.in_buff.find("\n".encode())
|
|
||||||
if idx >= 0:
|
|
||||||
text_msg = self.in_buff[:idx]
|
|
||||||
print("LOG:", text_msg.decode())
|
|
||||||
self.in_buff = self.in_buff[idx + 1:]
|
|
||||||
|
|
||||||
def _on_msg_receive(self, msg_type, msg_payload):
|
|
||||||
parsed_msg = incomingMsgMap[msg_type](msg_payload)
|
|
||||||
if self._msg_callback is not None:
|
|
||||||
self._msg_callback(self, parsed_msg)
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
import struct
|
|
||||||
import colorsys
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ColorRGBW:
|
|
||||||
r: float
|
|
||||||
g: float
|
|
||||||
b: float
|
|
||||||
w: float
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
assert self.is_valid(), "Trying to send invalid " + repr(self)
|
|
||||||
return struct.pack("<BBBB", int(self.r * 255), int(self.g * 255), int(self.b * 255),
|
|
||||||
int(self.w * 255))
|
|
||||||
|
|
||||||
def is_valid(self):
|
|
||||||
vals = (self.r, self.g, self.b, self.w)
|
|
||||||
return all(0 <= v <= 1 for v in vals)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ColorHSV:
|
|
||||||
h: float
|
|
||||||
s: float
|
|
||||||
v: float
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def fromRGB(rgb):
|
|
||||||
conv = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
|
|
||||||
return ColorHSV(conv[0] * 360, conv[1], conv[2])
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"ColorHSV({self.h}, {self.s}, {self.v})"
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return struct.pack("<fff", self.h, self.s, self.v)
|
|
||||||
|
|
||||||
def is_valid(self):
|
|
||||||
if not 0 <= self.h <= 360:
|
|
||||||
return False
|
|
||||||
if not 0 <= self.s <= 1:
|
|
||||||
return False
|
|
||||||
if not 0 <= self.v <= 2:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectStaticConfig:
|
|
||||||
color: ColorRGBW
|
|
||||||
begin: int = 0
|
|
||||||
end: int = 0
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"EffectStaticConfig {str(self.color)}, beg: {self.begin}, end {self.end}"
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return self.color.as_bytes() + struct.pack("<HH", self.begin, self.end)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectAlexaSwipeConfig:
|
|
||||||
primary_color_width: float = 20 # in degrees
|
|
||||||
transition_width: float = 30 # in degrees
|
|
||||||
swipe_speed: float = 2 * 360 # in degrees per second
|
|
||||||
bell_curve_width_in_leds: float = 3
|
|
||||||
start_position: float = 180 # in degrees
|
|
||||||
forward: bool = True
|
|
||||||
primary_color: ColorRGBW = ColorRGBW(0, 0, 1, 0)
|
|
||||||
secondary_color: ColorRGBW = ColorRGBW(0, 200 / 255, 1, 0)
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return struct.pack(
|
|
||||||
"<fffff?", self.primary_color_width, self.transition_width, self.swipe_speed,
|
|
||||||
self.bell_curve_width_in_leds, self.start_position,
|
|
||||||
self.forward) + self.primary_color.as_bytes() + self.secondary_color.as_bytes()
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"EffectAlexaSwipe primary {str(self.primary_color)}, {str(self.secondary_color)}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectRandomTwoColorInterpolationConfig:
|
|
||||||
cycle_durations_ms: int = 6000
|
|
||||||
start_with_existing: bool = True
|
|
||||||
num_segments: int = 3
|
|
||||||
hue1_random: bool = False
|
|
||||||
hue2_random: bool = False
|
|
||||||
color1: ColorHSV = ColorHSV(240, 1, 1)
|
|
||||||
color2: ColorHSV = ColorHSV(192, 1, 1)
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
c1 = ColorHSV.fromRGB(self.color1) if isinstance(self.color1, ColorRGBW) else self.color1
|
|
||||||
c2 = ColorHSV.fromRGB(self.color2) if isinstance(self.color2, ColorRGBW) else self.color2
|
|
||||||
return struct.pack("<i?i??", self.cycle_durations_ms, self.start_with_existing,
|
|
||||||
self.num_segments, self.hue1_random,
|
|
||||||
self.hue2_random) + c1.as_bytes() + c2.as_bytes()
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"RandTwoColor {str(self.color1)}, {str(self.color2)}, segments {self.num_segments}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectCircularConfig:
|
|
||||||
speed: float = 360 # in degrees per second
|
|
||||||
width: float = 180 # in degrees
|
|
||||||
color: ColorRGBW = ColorRGBW(0, 0, 1, 0)
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return struct.pack("<ff", self.speed, self.width) + self.color.as_bytes()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectSwipeAndChange:
|
|
||||||
swipe: EffectAlexaSwipeConfig = EffectAlexaSwipeConfig()
|
|
||||||
change: EffectRandomTwoColorInterpolationConfig = EffectRandomTwoColorInterpolationConfig()
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return self.swipe.as_bytes() + self.change.as_bytes()
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"Swipe and Change: \n {str(self.swipe)}\n {str(self.change)}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EffectReverseSwipe:
|
|
||||||
swipeSpeed: float = 2 * 360
|
|
||||||
bellCurveWidthInLeds: float = 3
|
|
||||||
startPosition: float = 180
|
|
||||||
|
|
||||||
def as_bytes(self) -> bytes:
|
|
||||||
return struct.pack("<fff", self.swipeSpeed, self.bellCurveWidthInLeds, self.startPosition)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"Reverse swipe, speed {self.swipeSpeed}, width in leds {self.bellCurveWidthInLeds}, start position {self.startPosition}"
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
import serial_asyncio
|
|
||||||
from led_cmds import (ColorRGBW, ColorHSV, EffectStaticConfig,
|
|
||||||
EffectRandomTwoColorInterpolationConfig, EffectAlexaSwipeConfig,
|
|
||||||
EffectSwipeAndChange, EffectReverseSwipe)
|
|
||||||
from host_driver import MusicMouseProtocol, RfidTokenRead, RotaryEncoderEvent, ButtonEvent, TouchButton, TouchButtonPress, TouchButtonRelease, mouse_leds_index_ranges
|
|
||||||
from player import AudioPlayer
|
|
||||||
from glob import glob
|
|
||||||
from copy import deepcopy
|
|
||||||
import os
|
|
||||||
from hass_client import HomeAssistantClient
|
|
||||||
import argparse
|
|
||||||
from ruamel.yaml import YAML
|
|
||||||
import warnings
|
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
yaml = YAML(typ='safe')
|
|
||||||
|
|
||||||
OFF_COLOR = ColorRGBW(0, 0, 0, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_color(color_str: str):
|
|
||||||
if isinstance(color_str, ColorRGBW):
|
|
||||||
return color_str
|
|
||||||
elif color_str.startswith("#"):
|
|
||||||
color_str = color_str.lstrip('#')
|
|
||||||
t = tuple(int(color_str[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
|
||||||
return ColorRGBW(*t, 0)
|
|
||||||
elif color_str.startswith("w"):
|
|
||||||
color_str = color_str.lstrip("w")
|
|
||||||
return ColorRGBW(0, 0, 0, int(color_str, 16) / 255)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path):
|
|
||||||
with open(os.path.join(config_path, "config.yml")) as cfg_file:
|
|
||||||
cfg = yaml.load(cfg_file)
|
|
||||||
for figure_name, figure_cfg in cfg["figures"].items():
|
|
||||||
figure_cfg["colors"] = [parse_color(c) for c in figure_cfg["colors"]]
|
|
||||||
if 'media_files' not in figure_cfg:
|
|
||||||
figure_cfg['media_files'] = sorted(glob(os.path.join(config_path, figure_name)))
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
def hass_service(hass, domain, service, **kwargs):
|
|
||||||
asyncio.create_task(hass.call_service(domain, service, kwargs))
|
|
||||||
|
|
||||||
|
|
||||||
class MusicMouseState:
|
|
||||||
def __init__(self, protocol: MusicMouseProtocol):
|
|
||||||
self.current_figure: str = None
|
|
||||||
self.last_figure: str = None
|
|
||||||
self.current_mouse_led_effect = None
|
|
||||||
self.current_led_ring_effect = None
|
|
||||||
self.protocol: MusicMouseProtocol = protocol
|
|
||||||
self.button_led_brightness = None
|
|
||||||
|
|
||||||
def mouse_led_effect(self, effect_cfg):
|
|
||||||
self.current_mouse_led_effect = effect_cfg
|
|
||||||
self.protocol.mouse_led_effect(effect_cfg)
|
|
||||||
|
|
||||||
def led_ring_effect(self, effect_cfg):
|
|
||||||
self.current_led_ring_effect = effect_cfg
|
|
||||||
self.protocol.led_ring_effect(effect_cfg)
|
|
||||||
|
|
||||||
def button_leds(self, brightness):
|
|
||||||
assert 0 <= brightness <= 1
|
|
||||||
self.protocol.button_background_led_prev(brightness)
|
|
||||||
self.protocol.button_background_led_next(brightness)
|
|
||||||
self.button_led_brightness = brightness
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
self.mouse_led_effect(EffectStaticConfig(OFF_COLOR))
|
|
||||||
self.led_ring_effect(EffectStaticConfig(OFF_COLOR))
|
|
||||||
|
|
||||||
def figure_placed(self, figure_state):
|
|
||||||
self.last_figure = self.current_figure
|
|
||||||
self.current_figure = figure_state
|
|
||||||
|
|
||||||
def figure_removed(self):
|
|
||||||
self.last_figure = self.current_figure
|
|
||||||
|
|
||||||
|
|
||||||
class Controller:
|
|
||||||
def __init__(self, protocol, hass, cfg):
|
|
||||||
self.cfg = cfg
|
|
||||||
self.audio_player = AudioPlayer(cfg["general"]["alsa_device"])
|
|
||||||
self.audio_player.set_volume(50)
|
|
||||||
self.mmstate = MusicMouseState(protocol)
|
|
||||||
self.protocol = protocol
|
|
||||||
self.hass = hass
|
|
||||||
|
|
||||||
vol_min = self.cfg["general"].get("min_volume", None)
|
|
||||||
vol_max = self.cfg["general"].get("max_volume", None)
|
|
||||||
self.audio_player.set_volume_limits(vol_min, vol_max)
|
|
||||||
protocol.register_message_callback(self.on_firmware_msg)
|
|
||||||
|
|
||||||
self.audio_player.on_playlist_end_callback = self._run_off_animation
|
|
||||||
self.playlists = {
|
|
||||||
fig: self.audio_player.create_playlist(fig_cfg['media_files'])
|
|
||||||
for fig, fig_cfg in cfg['figures'].items()
|
|
||||||
}
|
|
||||||
self._rfid_to_figure_name = {
|
|
||||||
bytes.fromhex(figure_cfg["id"]): figure_name
|
|
||||||
for figure_name, figure_cfg in cfg["figures"].items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def handle_rfid_event(self, tagid):
|
|
||||||
if tagid == bytes.fromhex("0000000000"):
|
|
||||||
if self.audio_player.is_playing():
|
|
||||||
print("Got 000 rfid -> playing off animation")
|
|
||||||
self._run_off_animation()
|
|
||||||
self.audio_player.pause()
|
|
||||||
self.mmstate.figure_removed()
|
|
||||||
elif tagid in self._rfid_to_figure_name:
|
|
||||||
figure = self._rfid_to_figure_name[tagid]
|
|
||||||
primary_color, secondary_color, *rest = self.cfg["figures"][figure]["colors"]
|
|
||||||
self._start_animation(primary_color, secondary_color)
|
|
||||||
self.mmstate.button_leds(self.cfg["general"].get("button_leds_brightness", 0.5))
|
|
||||||
|
|
||||||
if figure in self.playlists:
|
|
||||||
self.audio_player.set_playlist(self.playlists[figure])
|
|
||||||
if self.mmstate.last_figure == figure:
|
|
||||||
self.audio_player.play()
|
|
||||||
else:
|
|
||||||
self.audio_player.play_from_start()
|
|
||||||
|
|
||||||
self.mmstate.figure_placed(figure)
|
|
||||||
else:
|
|
||||||
warnings.warn(f"Unknown figure/tag with id {tagid}")
|
|
||||||
|
|
||||||
def on_firmware_msg(self, _, message):
|
|
||||||
print("FW msg:", message)
|
|
||||||
if isinstance(message, RfidTokenRead):
|
|
||||||
self.handle_rfid_event(message.id)
|
|
||||||
elif isinstance(message, RotaryEncoderEvent):
|
|
||||||
volume_increment = self.cfg["general"].get("volume_increment", 2) * abs(
|
|
||||||
message.increment)
|
|
||||||
if message.direction == 2:
|
|
||||||
self.audio_player.change_volume(volume_increment)
|
|
||||||
elif message.direction == 1:
|
|
||||||
self.audio_player.change_volume(-volume_increment)
|
|
||||||
elif isinstance(message, ButtonEvent):
|
|
||||||
btn = message.button
|
|
||||||
if btn == "left" and message.event == "pressed" and self.audio_player.is_playing():
|
|
||||||
res = self.audio_player.previous()
|
|
||||||
print(f"Prev {res}")
|
|
||||||
elif btn == "right" and message.event == "pressed" and self.audio_player.is_playing():
|
|
||||||
res = self.audio_player.nex()
|
|
||||||
print(f"Next {res}")
|
|
||||||
elif message.button == "rotary" and message.event == "pressed":
|
|
||||||
hass_service(self.hass, "light", "toggle", entity_id="light.kinderzimmer_fluter")
|
|
||||||
elif isinstance(message, TouchButtonPress):
|
|
||||||
figure = self.mmstate.current_figure
|
|
||||||
if figure and self.audio_player.is_playing():
|
|
||||||
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
|
|
||||||
self.protocol.mouse_led_effect(
|
|
||||||
EffectStaticConfig(accent, *mouse_leds_index_ranges[message.touch_button]))
|
|
||||||
|
|
||||||
colors = {
|
|
||||||
TouchButton.RIGHT_FOOT: {
|
|
||||||
'rgb_color': [235, 255, 67]
|
|
||||||
},
|
|
||||||
TouchButton.LEFT_FOOT: {
|
|
||||||
'color_temp': 469
|
|
||||||
},
|
|
||||||
TouchButton.RIGHT_EAR: {
|
|
||||||
'rgb_color': [101, 49, 255]
|
|
||||||
},
|
|
||||||
TouchButton.LEFT_EAR: {
|
|
||||||
'rgb_color': [255, 74, 254]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
hass_service(self.hass,
|
|
||||||
"light",
|
|
||||||
"turn_on",
|
|
||||||
entity_id="light.kinderzimmer_fluter",
|
|
||||||
**colors[message.touch_button])
|
|
||||||
|
|
||||||
elif isinstance(message, TouchButtonRelease):
|
|
||||||
figure = self.mmstate.current_figure
|
|
||||||
eff_change = EffectRandomTwoColorInterpolationConfig()
|
|
||||||
eff_static = EffectStaticConfig(ColorRGBW(0, 0, 0, 0),
|
|
||||||
*mouse_leds_index_ranges[message.touch_button])
|
|
||||||
if self.audio_player.is_playing():
|
|
||||||
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
|
|
||||||
eff_static.color = primary_color
|
|
||||||
self.protocol.mouse_led_effect(eff_static)
|
|
||||||
|
|
||||||
if self.audio_player.is_playing():
|
|
||||||
primary_color, secondary_color, bg, accent = self.cfg["figures"][figure]["colors"]
|
|
||||||
eff_change.color1 = primary_color
|
|
||||||
eff_change.color2 = secondary_color
|
|
||||||
eff_change.start_with_existing = True
|
|
||||||
self.protocol.mouse_led_effect(eff_change)
|
|
||||||
|
|
||||||
def _start_animation(self, primary_color, secondary_color):
|
|
||||||
ring_eff = EffectSwipeAndChange()
|
|
||||||
ring_eff.swipe.primary_color = primary_color
|
|
||||||
ring_eff.swipe.secondary_color = secondary_color
|
|
||||||
ring_eff.swipe.swipe_speed = 180
|
|
||||||
ring_eff.change.color1 = primary_color
|
|
||||||
ring_eff.change.color2 = secondary_color
|
|
||||||
self.mmstate.led_ring_effect(ring_eff)
|
|
||||||
|
|
||||||
mouse_eff = deepcopy(ring_eff)
|
|
||||||
mouse_eff.swipe.start_position = 6 / 45 * 360
|
|
||||||
mouse_eff.swipe.bell_curve_width_in_leds = 16
|
|
||||||
mouse_eff.swipe.swipe_speed = 180
|
|
||||||
self.mmstate.mouse_led_effect(mouse_eff)
|
|
||||||
|
|
||||||
def _run_off_animation(self):
|
|
||||||
print("Running off animation")
|
|
||||||
ring_eff = EffectReverseSwipe()
|
|
||||||
self.mmstate.led_ring_effect(ring_eff)
|
|
||||||
|
|
||||||
mouse_eff = EffectReverseSwipe()
|
|
||||||
mouse_eff.startPosition = 6 / 45 * 360
|
|
||||||
self.mmstate.mouse_led_effect(mouse_eff)
|
|
||||||
|
|
||||||
self.mmstate.button_leds(0)
|
|
||||||
|
|
||||||
|
|
||||||
def main(config_path):
|
|
||||||
cfg = load_config(config_path)
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
hass = HomeAssistantClient(cfg["general"]["hass_url"], cfg["general"]["hass_token"], loop)
|
|
||||||
|
|
||||||
coro = serial_asyncio.create_serial_connection(loop,
|
|
||||||
MusicMouseProtocol,
|
|
||||||
cfg["general"]["serial_port"],
|
|
||||||
baudrate=115200)
|
|
||||||
transport, protocol = loop.run_until_complete(coro)
|
|
||||||
controller = Controller(protocol, hass, cfg)
|
|
||||||
loop.create_task(hass.connect())
|
|
||||||
return controller, loop
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) == 2:
|
|
||||||
controller, loop = main(config_path=sys.argv[1])
|
|
||||||
loop.run_forever()
|
|
||||||
loop.close()
|
|
||||||
else:
|
|
||||||
print("Error: run with config file path as first argument")
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import vlc
|
|
||||||
|
|
||||||
all_events = (
|
|
||||||
vlc.EventType.MediaDiscovererEnded,
|
|
||||||
vlc.EventType.MediaDiscovererStarted,
|
|
||||||
vlc.EventType.MediaDurationChanged,
|
|
||||||
vlc.EventType.MediaFreed,
|
|
||||||
vlc.EventType.MediaListEndReached,
|
|
||||||
vlc.EventType.MediaListItemAdded,
|
|
||||||
vlc.EventType.MediaListItemDeleted,
|
|
||||||
vlc.EventType.MediaListPlayerNextItemSet,
|
|
||||||
vlc.EventType.MediaListPlayerPlayed,
|
|
||||||
vlc.EventType.MediaListPlayerStopped,
|
|
||||||
vlc.EventType.MediaListViewItemAdded,
|
|
||||||
vlc.EventType.MediaListViewItemDeleted,
|
|
||||||
vlc.EventType.MediaListViewWillAddItem,
|
|
||||||
vlc.EventType.MediaListViewWillDeleteItem,
|
|
||||||
vlc.EventType.MediaListWillAddItem,
|
|
||||||
vlc.EventType.MediaListWillDeleteItem,
|
|
||||||
vlc.EventType.MediaMetaChanged,
|
|
||||||
vlc.EventType.MediaParsedChanged,
|
|
||||||
vlc.EventType.MediaPlayerAudioDevice,
|
|
||||||
vlc.EventType.MediaPlayerAudioVolume,
|
|
||||||
vlc.EventType.MediaPlayerBackward,
|
|
||||||
vlc.EventType.MediaPlayerBuffering,
|
|
||||||
vlc.EventType.MediaPlayerChapterChanged,
|
|
||||||
vlc.EventType.MediaPlayerCorked,
|
|
||||||
vlc.EventType.MediaPlayerESAdded,
|
|
||||||
vlc.EventType.MediaPlayerESDeleted,
|
|
||||||
vlc.EventType.MediaPlayerESSelected,
|
|
||||||
vlc.EventType.MediaPlayerEncounteredError,
|
|
||||||
vlc.EventType.MediaPlayerEndReached,
|
|
||||||
vlc.EventType.MediaPlayerForward,
|
|
||||||
#vlc.EventType.MediaPlayerLengthChanged,
|
|
||||||
vlc.EventType.MediaPlayerMediaChanged,
|
|
||||||
vlc.EventType.MediaPlayerMuted,
|
|
||||||
vlc.EventType.MediaPlayerNothingSpecial,
|
|
||||||
vlc.EventType.MediaPlayerOpening,
|
|
||||||
vlc.EventType.MediaPlayerPausableChanged,
|
|
||||||
vlc.EventType.MediaPlayerPaused,
|
|
||||||
vlc.EventType.MediaPlayerPlaying,
|
|
||||||
#vlc.EventType.MediaPlayerPositionChanged,
|
|
||||||
vlc.EventType.MediaPlayerScrambledChanged,
|
|
||||||
vlc.EventType.MediaPlayerSeekableChanged,
|
|
||||||
vlc.EventType.MediaPlayerSnapshotTaken,
|
|
||||||
vlc.EventType.MediaPlayerStopped,
|
|
||||||
#vlc.EventType.MediaPlayerTimeChanged,
|
|
||||||
vlc.EventType.MediaPlayerTitleChanged,
|
|
||||||
vlc.EventType.MediaPlayerUncorked,
|
|
||||||
vlc.EventType.MediaPlayerUnmuted,
|
|
||||||
vlc.EventType.MediaPlayerVout,
|
|
||||||
vlc.EventType.MediaStateChanged,
|
|
||||||
vlc.EventType.MediaSubItemAdded,
|
|
||||||
vlc.EventType.MediaSubItemTreeAdded,
|
|
||||||
vlc.EventType.RendererDiscovererItemAdded,
|
|
||||||
vlc.EventType.RendererDiscovererItemDeleted,
|
|
||||||
vlc.EventType.VlmMediaAdded,
|
|
||||||
vlc.EventType.VlmMediaChanged,
|
|
||||||
vlc.EventType.VlmMediaInstanceStarted,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusEnd,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusError,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusInit,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusOpening,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusPause,
|
|
||||||
vlc.EventType.VlmMediaInstanceStatusPlaying,
|
|
||||||
vlc.EventType.VlmMediaInstanceStopped,
|
|
||||||
vlc.EventType.VlmMediaRemoved,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AudioPlayer:
|
|
||||||
def __init__(self, alsa_device=None):
|
|
||||||
params = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else []
|
|
||||||
self.instance = vlc.Instance(*params)
|
|
||||||
self.media_list_player = self.instance.media_list_player_new()
|
|
||||||
self.media_player = self.media_list_player.get_media_player()
|
|
||||||
|
|
||||||
evm = self.media_player.event_manager()
|
|
||||||
evm.event_attach(vlc.EventType.MediaPlayerStopped, self._callback)
|
|
||||||
|
|
||||||
evm2 = self.media_list_player.event_manager()
|
|
||||||
evm2.event_attach(vlc.EventType.MediaListPlayerPlayed, self._callback)
|
|
||||||
evm2.event_attach(vlc.EventType.MediaListPlayerStopped, self._callback)
|
|
||||||
|
|
||||||
self.on_playlist_end_callback = None
|
|
||||||
|
|
||||||
self.volume_min = None
|
|
||||||
self.volume_max = None
|
|
||||||
|
|
||||||
def create_playlist(self, files):
|
|
||||||
result = vlc.MediaList()
|
|
||||||
for e in files:
|
|
||||||
result.add_media(self.instance.media_new(e))
|
|
||||||
|
|
||||||
evm = result.event_manager()
|
|
||||||
evm.event_attach(vlc.EventType.MediaListEndReached,
|
|
||||||
lambda e: print("Ml CB", str(vlc.EventType(e.type))))
|
|
||||||
evm.event_attach(vlc.EventType.MediaListItemAdded,
|
|
||||||
lambda e: print("Ml ia CB", str(vlc.EventType(e.type))))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def set_playlist(self, media_list):
|
|
||||||
self.media_list_player.set_media_list(media_list)
|
|
||||||
print("Setting media list of length ", media_list.count())
|
|
||||||
self.media_list_player.set_playback_mode(vlc.PlaybackMode.default)
|
|
||||||
|
|
||||||
def next(self):
|
|
||||||
return self.media_list_player.next()
|
|
||||||
|
|
||||||
def previous(self):
|
|
||||||
return self.media_list_player.previous()
|
|
||||||
|
|
||||||
def play(self):
|
|
||||||
self.media_list_player.play()
|
|
||||||
|
|
||||||
def play_from_start(self):
|
|
||||||
self.media_list_player.play_item_at_index(0)
|
|
||||||
|
|
||||||
def is_playing(self):
|
|
||||||
return self.media_list_player.is_playing()
|
|
||||||
|
|
||||||
def pause(self):
|
|
||||||
self.media_list_player.pause()
|
|
||||||
|
|
||||||
def _callback(self, event, *args, **kwargs):
|
|
||||||
eventStr = str(vlc.EventType(event.type))
|
|
||||||
print(f"Got vlc event type {event.type} {eventStr} , event {event}")
|
|
||||||
if event.type == vlc.EventType.MediaPlayerStopped:
|
|
||||||
if self.on_playlist_end_callback:
|
|
||||||
print("Calling playlist end cb")
|
|
||||||
self.on_playlist_end_callback()
|
|
||||||
#print("Callback from VLC", event, args, kwargs)
|
|
||||||
#print(event.meta_type, event.obj, event.type)
|
|
||||||
|
|
||||||
def set_volume(self, volume):
|
|
||||||
if self.volume_min and volume < self.volume_min:
|
|
||||||
volume = self.volume_min
|
|
||||||
if self.volume_max and volume > self.volume_max:
|
|
||||||
volume = self.volume_max
|
|
||||||
self.media_player.audio_set_volume(volume)
|
|
||||||
|
|
||||||
def set_volume_limits(self, vmin, vmax):
|
|
||||||
self.volume_min = vmin
|
|
||||||
self.volume_max = vmax
|
|
||||||
|
|
||||||
def change_volume(self, amount=1):
|
|
||||||
vol = self.media_player.audio_get_volume() + amount
|
|
||||||
self.set_volume(vol)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
struct ColorRGBW
|
|
||||||
{
|
|
||||||
uint8_t r, g, b, w;
|
|
||||||
|
|
||||||
ColorRGBW operator*(float s) const
|
|
||||||
{
|
|
||||||
return {uint8_t(s * r),
|
|
||||||
uint8_t(s * g),
|
|
||||||
uint8_t(s * b),
|
|
||||||
uint8_t(s * w)};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
BIN
hardware/3dprints/back-figure/music_mouse_back_figure.FCStd
Normal file
BIN
hardware/3dprints/back-figure/music_mouse_back_figure.FCStd
Normal file
Binary file not shown.
251
hardware/3dprints/back-figure/rueckseite_converted.svg
Normal file
251
hardware/3dprints/back-figure/rueckseite_converted.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.3 MiB |
BIN
hardware/3dprints/figures/croco.blend
Normal file
BIN
hardware/3dprints/figures/croco.blend
Normal file
Binary file not shown.
512068
hardware/3dprints/figures/croco.obj
Normal file
512068
hardware/3dprints/figures/croco.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/croco.stl
Normal file
BIN
hardware/3dprints/figures/croco.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/elephant.blend
Normal file
BIN
hardware/3dprints/figures/elephant.blend
Normal file
Binary file not shown.
133640
hardware/3dprints/figures/elephant.obj
Normal file
133640
hardware/3dprints/figures/elephant.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/omnom.blend
Normal file
BIN
hardware/3dprints/figures/omnom.blend
Normal file
Binary file not shown.
694529
hardware/3dprints/figures/omnom.obj
Normal file
694529
hardware/3dprints/figures/omnom.obj
Normal file
File diff suppressed because it is too large
Load Diff
696208
hardware/3dprints/figures/omnom2.obj
Normal file
696208
hardware/3dprints/figures/omnom2.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/puppy.blend
Normal file
BIN
hardware/3dprints/figures/puppy.blend
Normal file
Binary file not shown.
61345
hardware/3dprints/figures/puppy.obj
Normal file
61345
hardware/3dprints/figures/puppy.obj
Normal file
File diff suppressed because it is too large
Load Diff
101023
hardware/3dprints/figures/puppy2.obj
Normal file
101023
hardware/3dprints/figures/puppy2.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/rabbit_ducky.blend
Normal file
BIN
hardware/3dprints/figures/rabbit_ducky.blend
Normal file
Binary file not shown.
110816
hardware/3dprints/figures/rabbit_ducky.obj
Normal file
110816
hardware/3dprints/figures/rabbit_ducky.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/raw/Ducky.stl
Normal file
BIN
hardware/3dprints/figures/raw/Ducky.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/Elephant.stl
Normal file
BIN
hardware/3dprints/figures/raw/Elephant.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/Fox.stl
Normal file
BIN
hardware/3dprints/figures/raw/Fox.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/Meeple_-_Spielfigur.stl
Normal file
BIN
hardware/3dprints/figures/raw/Meeple_-_Spielfigur.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/croco.stl
Normal file
BIN
hardware/3dprints/figures/raw/croco.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/omnom_60mm.stl
Normal file
BIN
hardware/3dprints/figures/raw/omnom_60mm.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/owl-print.stl
Normal file
BIN
hardware/3dprints/figures/raw/owl-print.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/puppy.stl
Normal file
BIN
hardware/3dprints/figures/raw/puppy.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/snowman.stl
Normal file
BIN
hardware/3dprints/figures/raw/snowman.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/raw/squirrel.stl
Normal file
BIN
hardware/3dprints/figures/raw/squirrel.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/figures/snowman.blend
Normal file
BIN
hardware/3dprints/figures/snowman.blend
Normal file
Binary file not shown.
291311
hardware/3dprints/figures/snowman.obj
Normal file
291311
hardware/3dprints/figures/snowman.obj
Normal file
File diff suppressed because it is too large
Load Diff
1548458
hardware/3dprints/figures/squirrel.obj
Normal file
1548458
hardware/3dprints/figures/squirrel.obj
Normal file
File diff suppressed because it is too large
Load Diff
BIN
hardware/3dprints/figures/squirrel.stl
Normal file
BIN
hardware/3dprints/figures/squirrel.stl
Normal file
Binary file not shown.
BIN
hardware/3dprints/musicmouse.FCStd
Normal file
BIN
hardware/3dprints/musicmouse.FCStd
Normal file
Binary file not shown.
BIN
hardware/datasheets/nfc-reader-MFRC522.pdf
Normal file
BIN
hardware/datasheets/nfc-reader-MFRC522.pdf
Normal file
Binary file not shown.
BIN
hardware/led_driver_clock.ods
Normal file
BIN
hardware/led_driver_clock.ods
Normal file
Binary file not shown.
28
hardware/pinout.md
Normal file
28
hardware/pinout.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
Reader
|
||||||
|
----------
|
||||||
|
|
||||||
|
- GND black
|
||||||
|
- RST blue 3.3V
|
||||||
|
- 3.3V red
|
||||||
|
- MISO brown 21
|
||||||
|
- SDA green 19
|
||||||
|
- SCK yellow 18
|
||||||
|
- MOSI orange 5
|
||||||
|
- IRQ green single cable not connected
|
||||||
|
|
||||||
|
|
||||||
|
Button Board:
|
||||||
|
-------------
|
||||||
|
|
||||||
|
- rot in | white 13
|
||||||
|
- btn2 led | grey 12
|
||||||
|
- btn2 in | purple 14
|
||||||
|
- rotB | blue 27
|
||||||
|
- rotA | green 26
|
||||||
|
- btn1 in | yellow 25
|
||||||
|
- btn1 led | orange 33
|
||||||
|
|
||||||
|
rot="rotary encoder"
|
||||||
|
in=button sense in
|
||||||
|
led = 5V pwm
|
||||||
BIN
hardware/power_consumption_leds.ods
Normal file
BIN
hardware/power_consumption_leds.ods
Normal file
Binary file not shown.
BIN
hardware/sketch.fzz
Normal file
BIN
hardware/sketch.fzz
Normal file
Binary file not shown.
BIN
musicmouse.FCStd
BIN
musicmouse.FCStd
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
|
||||||
|
|
||||||
project("pyaudioplayeralsa")
|
|
||||||
|
|
||||||
add_executable(play main.cpp src/WavFile.cpp)
|
|
||||||
target_link_libraries(play -lasound -pthread)
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
/*
|
|
||||||
* Simple sound playback using ALSA API and libasound.
|
|
||||||
*
|
|
||||||
* Compile:
|
|
||||||
* $ cc -o play sound_playback.c -lasound
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* $ ./play <sample_rate> <channels> <seconds> < <file>
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* $ ./play 44100 2 5 < /dev/urandom
|
|
||||||
* $ ./play 22050 1 8 < /path/to/file.wav
|
|
||||||
*
|
|
||||||
* Copyright (C) 2009 Alessandro Ghedini <al3xbio@gmail.com>
|
|
||||||
* --------------------------------------------------------------
|
|
||||||
* "THE BEER-WARE LICENSE" (Revision 42):
|
|
||||||
* Alessandro Ghedini wrote this file. As long as you retain this
|
|
||||||
* notice you can do whatever you want with this stuff. If we
|
|
||||||
* meet some day, and you think this stuff is worth it, you can
|
|
||||||
* buy me a beer in return.
|
|
||||||
* --------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <alsa/asoundlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
#include <fstream>
|
|
||||||
|
|
||||||
#include "src/WavFile.h"
|
|
||||||
|
|
||||||
#include <future>
|
|
||||||
#include <thread>
|
|
||||||
#include <chrono>
|
|
||||||
#include <queue>
|
|
||||||
|
|
||||||
#define PCM_DEVICE "default"
|
|
||||||
|
|
||||||
std::deque<std::string> queue;
|
|
||||||
std::mutex queueMutex;
|
|
||||||
|
|
||||||
static std::string getInput()
|
|
||||||
{
|
|
||||||
|
|
||||||
std::cout << "starting input thread" << std::endl;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
std::string result;
|
|
||||||
std::getline(std::cin, result);
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> guard(queueMutex);
|
|
||||||
std::cout << "adding to queue" << std::endl;
|
|
||||||
queue.push_back(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char **argv)
|
|
||||||
{
|
|
||||||
std::ifstream wavFileStream("test.wav", std::ios::binary);
|
|
||||||
WavFile wav(wavFileStream);
|
|
||||||
wavFileStream.close();
|
|
||||||
std::cout << "Wav samples " << wav.size() << std::endl;
|
|
||||||
|
|
||||||
unsigned int pcm, tmp, dir;
|
|
||||||
snd_pcm_t *pcm_handle;
|
|
||||||
snd_pcm_hw_params_t *params;
|
|
||||||
snd_pcm_uframes_t frames;
|
|
||||||
char *buff;
|
|
||||||
int buff_size, loops;
|
|
||||||
|
|
||||||
/* Open the PCM device in playback mode */
|
|
||||||
if (pcm = snd_pcm_open(&pcm_handle, PCM_DEVICE,
|
|
||||||
SND_PCM_STREAM_PLAYBACK, 0) < 0)
|
|
||||||
printf("ERROR: Can't open \"%s\" PCM device. %s\n",
|
|
||||||
PCM_DEVICE, snd_strerror(pcm));
|
|
||||||
|
|
||||||
/* Allocate parameters object and fill it with default values*/
|
|
||||||
snd_pcm_hw_params_alloca(¶ms);
|
|
||||||
|
|
||||||
snd_pcm_hw_params_any(pcm_handle, params);
|
|
||||||
|
|
||||||
/* Set parameters */
|
|
||||||
if (pcm = snd_pcm_hw_params_set_access(pcm_handle, params,
|
|
||||||
SND_PCM_ACCESS_RW_INTERLEAVED) < 0)
|
|
||||||
printf("ERROR: Can't set interleaved mode. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
if (pcm = snd_pcm_hw_params_set_format(pcm_handle, params,
|
|
||||||
SND_PCM_FORMAT_S16_LE) < 0)
|
|
||||||
printf("ERROR: Can't set format. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
if (pcm = snd_pcm_hw_params_set_channels(pcm_handle, params, wav.channels()) < 0)
|
|
||||||
printf("ERROR: Can't set channels number. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
snd_pcm_hw_params_set_buffer_size(pcm_handle, params, 2 * 2048);
|
|
||||||
|
|
||||||
unsigned int rate = wav.sampleRate();
|
|
||||||
if (pcm = snd_pcm_hw_params_set_rate_near(pcm_handle, params, &rate, 0) < 0)
|
|
||||||
printf("ERROR: Can't set rate. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
/* Write parameters */
|
|
||||||
if (pcm = snd_pcm_hw_params(pcm_handle, params) < 0)
|
|
||||||
printf("ERROR: Can't set harware parameters. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
/* Resume information */
|
|
||||||
printf("PCM name: '%s'\n", snd_pcm_name(pcm_handle));
|
|
||||||
|
|
||||||
printf("PCM state: %s\n", snd_pcm_state_name(snd_pcm_state(pcm_handle)));
|
|
||||||
|
|
||||||
snd_pcm_hw_params_get_channels(params, &tmp);
|
|
||||||
|
|
||||||
printf("channels: %i ", tmp);
|
|
||||||
|
|
||||||
if (tmp == 1)
|
|
||||||
printf("(mono)\n");
|
|
||||||
else if (tmp == 2)
|
|
||||||
printf("(stereo)\n");
|
|
||||||
|
|
||||||
snd_pcm_hw_params_get_rate(params, &tmp, 0);
|
|
||||||
printf("rate: %d bps\n", tmp);
|
|
||||||
|
|
||||||
/* Allocate buffer to hold single period */
|
|
||||||
snd_pcm_hw_params_get_period_size(params, &frames, 0);
|
|
||||||
|
|
||||||
buff_size = frames * wav.channels() * 2 /* 2 -> sample size */;
|
|
||||||
buff = (char *)malloc(buff_size);
|
|
||||||
std::cout << "Buffer size " << buff_size << " frames " << frames << std::endl;
|
|
||||||
|
|
||||||
snd_pcm_hw_params_get_period_time(params, &tmp, NULL);
|
|
||||||
|
|
||||||
std::thread inputThread(getInput);
|
|
||||||
bool playing = true;
|
|
||||||
|
|
||||||
size_t wavFilePosition = 0;
|
|
||||||
while (wavFilePosition < wav.size())
|
|
||||||
{
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> guard(queueMutex);
|
|
||||||
if (queue.size() > 0)
|
|
||||||
{
|
|
||||||
queue.pop_back();
|
|
||||||
std::cout << "Toggling" << std::endl;
|
|
||||||
//snd_pcm_drain(pcm_handle);
|
|
||||||
std::cout << "Done" << std::endl;
|
|
||||||
playing = !playing;
|
|
||||||
if (playing)
|
|
||||||
{
|
|
||||||
std::cout << "continuing at " << wavFilePosition << std::endl;
|
|
||||||
snd_pcm_pause(pcm_handle, 0);
|
|
||||||
//snd_pcm_prepare(pcm_handle);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
snd_pcm_pause(pcm_handle, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!playing)
|
|
||||||
continue;
|
|
||||||
size_t dataToWrite = std::min(size_t(buff_size), wav.size() - wavFilePosition);
|
|
||||||
int framesToWrite = dataToWrite / wav.channels() / 2;
|
|
||||||
if (pcm = snd_pcm_writei(pcm_handle, wav[wavFilePosition], framesToWrite) == -EPIPE)
|
|
||||||
{
|
|
||||||
std::cout << "XRUN at wav position " << wavFilePosition << std::endl;
|
|
||||||
snd_pcm_prepare(pcm_handle);
|
|
||||||
}
|
|
||||||
else if (pcm < 0)
|
|
||||||
printf("ERROR. Can't write to PCM device. %s\n", snd_strerror(pcm));
|
|
||||||
|
|
||||||
wavFilePosition += dataToWrite;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::cout << "done filling buffer" << std::endl;
|
|
||||||
snd_pcm_drain(pcm_handle);
|
|
||||||
snd_pcm_close(pcm_handle);
|
|
||||||
free(buff);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
- play wav file [ok]
|
|
||||||
- read & parse wav file [ok]
|
|
||||||
- stop wave file in the middle, wait 2 secs and continue
|
|
||||||
- fade in/out
|
|
||||||
- mix second wave file on top (some effect)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <iostream>
|
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
template <size_t len>
|
|
||||||
std::string readStr(std::istream &str)
|
|
||||||
{
|
|
||||||
char buff[len];
|
|
||||||
str.read(buff, len);
|
|
||||||
return std::string(buff, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
template <typename T>
|
|
||||||
T read(std::istream &str)
|
|
||||||
{
|
|
||||||
T res;
|
|
||||||
str.read((char *)&res, sizeof(res));
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
class WavFile
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
WavFile(std::istream &str)
|
|
||||||
{
|
|
||||||
auto chunkId = readStr<4>(str);
|
|
||||||
auto chunkSize = read<uint32_t>(str);
|
|
||||||
auto format = readStr<4>(str);
|
|
||||||
|
|
||||||
auto subchunk1Id = readStr<4>(str);
|
|
||||||
auto subchunk1Size = read<uint32_t>(str);
|
|
||||||
|
|
||||||
auto audioFormat = read<uint16_t>(str);
|
|
||||||
numChannels_ = read<uint16_t>(str);
|
|
||||||
sampleRate_ = read<uint32_t>(str);
|
|
||||||
auto byteRate = read<uint32_t>(str);
|
|
||||||
|
|
||||||
auto blockAlign = read<uint16_t>(str);
|
|
||||||
bitsPerSample_ = read<uint16_t>(str);
|
|
||||||
|
|
||||||
auto subchunk2Id = readStr<4>(str);
|
|
||||||
dataSize_ = read<uint32_t>(str);
|
|
||||||
|
|
||||||
data_ = std::unique_ptr<char>(new char[dataSize_]);
|
|
||||||
str.read(data_.get(), dataSize_);
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t sampleRate() const { return sampleRate_; }
|
|
||||||
uint16_t channels() const { return numChannels_; }
|
|
||||||
uint32_t size() const { return dataSize_; }
|
|
||||||
|
|
||||||
const char *operator[](int offset) const { return &data_.get()[offset]; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::unique_ptr<char> data_;
|
|
||||||
uint32_t sampleRate_;
|
|
||||||
uint16_t bitsPerSample_;
|
|
||||||
uint32_t dataSize_;
|
|
||||||
uint16_t numChannels_;
|
|
||||||
};
|
|
||||||
4
python-backend/.gitignore
vendored
Normal file
4
python-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
config.yml
|
||||||
|
tippen-curriculum.yml
|
||||||
|
tippen-progress.json
|
||||||
|
/.musicmouse-cache
|
||||||
241
python-backend/README.md
Normal file
241
python-backend/README.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# MusicMouse backend
|
||||||
|
|
||||||
|
The host application: it reads RFID tags, buttons and touch areas from the ESP32
|
||||||
|
firmware over serial, plays music through VLC, drives three LED strips, indexes the
|
||||||
|
music collection, and exposes everything to Home Assistant over MQTT and to a browser
|
||||||
|
over HTTP.
|
||||||
|
|
||||||
|
```
|
||||||
|
ESP32 ⇄ MusicMouseDevice ─┐ ┌─► MqttService (state out, intents in)
|
||||||
|
VLC ⇄ VlcPlayer ────────┼──► EventBus ──────────►┤
|
||||||
|
broker ⇄ MqttService ──────┤ ▲ └─► WebService (state out, intents in)
|
||||||
|
browser ⇄ WebService ───────┘ │
|
||||||
|
reactions/*.py ── call actions on ──► device / player
|
||||||
|
```
|
||||||
|
|
||||||
|
Three objects own the outside world, one bus carries everything, and the *reactions*
|
||||||
|
are the only place that decides what should happen. Adding a new way to control the
|
||||||
|
mouse means adding a service that emits the same intents - no device or reaction
|
||||||
|
changes. The web front-end was added exactly that way.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install -e '.[dev]'
|
||||||
|
python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
See `config.yml.example` for the schema and `musicmouse.service` for the systemd unit.
|
||||||
|
Config problems are reported all at once with the path to each one; unknown keys are
|
||||||
|
errors, not silent no-ops.
|
||||||
|
|
||||||
|
### On a host with no mouse attached
|
||||||
|
|
||||||
|
The web front-end is a complete way to drive the player, so the backend is useful on a
|
||||||
|
machine with no serial port and no sound card worth grabbing. Two config keys say so,
|
||||||
|
each by taking the literal value `simulate`:
|
||||||
|
|
||||||
|
| Setting | `simulate` gives you | Warned about as |
|
||||||
|
|---|---|---|
|
||||||
|
| `serial_port` | no serial link; RFID, buttons and LEDs are inert | "running without the mouse" |
|
||||||
|
| `alsa_device` | the simulator's player: everything works, nothing is audible | "nothing will be audible" |
|
||||||
|
|
||||||
|
Both keys are **required**. Leaving one out is a config error, not a shortcut to
|
||||||
|
simulation - running blind or silent has to be asked for, so a config that lost a line
|
||||||
|
fails loudly instead of booting into something that looks like it is working. The
|
||||||
|
warnings are repeated on every boot for the same reason.
|
||||||
|
|
||||||
|
`--no-hardware` forces the serial half regardless of the config, for a one-off run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --no-hardware
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that `alsa_device` has no "just use whatever" value. VLC's own default would seize
|
||||||
|
whatever the desktop is playing through, which is the wrong thing to do silently - name
|
||||||
|
a device (`"default"` is the system one) when the machine is meant to make noise.
|
||||||
|
|
||||||
|
### Without hardware
|
||||||
|
|
||||||
|
The simulator runs the entire app - real bus, real device, real reactions, real MQTT
|
||||||
|
if configured - against a fake serial link and a fake player.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --simulate
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
musicmouse> place fuchs
|
||||||
|
in RfidTokenRead(04a1b2c3d4, figure='fuchs')
|
||||||
|
rfid fuchs
|
||||||
|
led ring: SwipeAndChange(AlexaSwipe(#(1.0, 0.4, 0.0, 0) -> ...))
|
||||||
|
play playing
|
||||||
|
musicmouse> press right
|
||||||
|
play track 1: 01 - Song 1
|
||||||
|
```
|
||||||
|
|
||||||
|
`help` lists the verbs. The same verbs go in a scenario file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Scenario files run on a virtual clock under pytest, so `wait 1s` costs microseconds and
|
||||||
|
every file in `scenarios/` is part of the test suite. A session reproduced by hand at
|
||||||
|
the prompt becomes a regression test by pasting it into a `.txt` file.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `musicmouse/bus.py` | One FIFO queue on one loop. Thread-safe `emit()` - which is how libVLC's callback thread stops reaching the serial transport. |
|
||||||
|
| `musicmouse/events.py` | The vocabulary: **input** (something happened), **intent** (something was requested), **state** (something changed). |
|
||||||
|
| `musicmouse/devices/` | `mouse.py` (firmware), `player.py` (VLC), `wire.py` (pure codec), `serial_link.py` (transport + reconnect). |
|
||||||
|
| `musicmouse/reactions/` | The policy. `@on(SomeEvent)` functions that get the app and act. |
|
||||||
|
| `musicmouse/library/` | The music collection: scanning, tags, cover art and its colours, the cache, and the seams for future track analysis. |
|
||||||
|
| `musicmouse/services/mqtt/` | Home Assistant entities: three lights, a player sensor, a volume number, transport buttons, device triggers, a tag scanner. |
|
||||||
|
| `musicmouse/services/web/` | The browser front-end's API: the library, a state websocket, command endpoints, and parent-mode settings. |
|
||||||
|
| `musicmouse/simulator/` | Fake transport and player, the driver vocabulary, the REPL and the script runner. |
|
||||||
|
| `musicmouse/config.py` | Pydantic schema, validation, and human-readable error formatting. |
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pytest # unit + scenario tests
|
||||||
|
ruff check .
|
||||||
|
mypy # --strict, configured in pyproject.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/test_wire.py` parses `../esp-firmware/src/Messages.h` and fails if the Python
|
||||||
|
message ids drift from the firmware's - the contract is hand-duplicated in two
|
||||||
|
languages, and it had already drifted once (`BUTTON_EVENT` was missing on the Python
|
||||||
|
side). `tests/test_effects.py` pins the exact bytes of every effect payload.
|
||||||
|
|
||||||
|
## LED arbitration
|
||||||
|
|
||||||
|
`MusicMouseDevice` is the single writer to each zone, and the most recent effect wins -
|
||||||
|
whether it came from a figure animation or from Home Assistant. There is no priority
|
||||||
|
scheme. Every write emits `LedEffectChanged`, and the MQTT light entities publish their
|
||||||
|
state from that rather than echoing their own commands, so HA keeps showing the strip's
|
||||||
|
real state when a figure animation overrides a colour it set.
|
||||||
|
|
||||||
|
## Home Assistant
|
||||||
|
|
||||||
|
The backend no longer calls Home Assistant directly (`hass-client` is gone). It
|
||||||
|
publishes what happened; the automations live in HA.
|
||||||
|
|
||||||
|
MQTT device triggers are published for every button (`pressed`, `double_clicked`,
|
||||||
|
`long_pressed`), every touch area (touched/released), and the RFID reader appears as a
|
||||||
|
tag scanner. Topics are under `musicmouse/trigger/…` and `musicmouse/tag`.
|
||||||
|
|
||||||
|
### Recreating the old room-light behaviour
|
||||||
|
|
||||||
|
Two behaviours used to be hard-coded in `main.py` and now need automations:
|
||||||
|
|
||||||
|
**Rotary press toggled the room light.** Trigger on the `rotary_pressed` device
|
||||||
|
trigger, action `light.toggle` on `light.kinderzimmer_fluter`.
|
||||||
|
|
||||||
|
**Touching a body part set a colour** on `light.kinderzimmer_fluter` and
|
||||||
|
`light.music_mouse_regal_licht`:
|
||||||
|
|
||||||
|
| Touch area | Old service data |
|
||||||
|
|---|---|
|
||||||
|
| `right_foot` | `rgb_color: [235, 255, 67]` |
|
||||||
|
| `left_foot` | `color_temp: 469` |
|
||||||
|
| `right_ear` | `rgb_color: [101, 49, 255]` |
|
||||||
|
| `left_ear` | `rgb_color: [255, 74, 254]` |
|
||||||
|
|
||||||
|
Trigger on the corresponding `*_touched` device trigger and call `light.turn_on` with
|
||||||
|
that data.
|
||||||
|
|
||||||
|
## The library
|
||||||
|
|
||||||
|
`general.library.root` is the one path to the music. The shelves under it are fixed
|
||||||
|
names rather than settings (`musicmouse/library/sections.py`), because each has quirks
|
||||||
|
the scanner has to know about:
|
||||||
|
|
||||||
|
| Folder | Shown as | Why it is special |
|
||||||
|
|---|---|---|
|
||||||
|
| `Figuren/<figure>/` | an album per figurine | the folder name *is* the figure name from the config |
|
||||||
|
| `Musik/<Artist> - <Album>/` | music, grouped by artist | plain ID3 |
|
||||||
|
| `Hörbücher/<Artist> - <Album>/` | audiobooks, grouped by character | `album_artist` is a credit list; only the name before the first comma groups usefully |
|
||||||
|
| `Kinderpodcasts/<Show>/` | audiobooks, newest episode first | the tags are useless here - `artist` is the presenter list and `album` is the feed name, so the *folder* is the show |
|
||||||
|
|
||||||
|
Only files whose suffix is in `audio_extensions` are read, and dotfiles are skipped, so
|
||||||
|
a podcast downloader's `archive.json` and its half-finished `.download.tmp` never reach
|
||||||
|
a playlist.
|
||||||
|
|
||||||
|
Drop a `feed.txt` into a show folder (its first line the show's RSS feed URL) and the
|
||||||
|
backend becomes that podcast downloader itself: every six hours it checks the feed and
|
||||||
|
saves any episode not already on disk, named `YYYYMMDD - Title.ext` like a hand-placed
|
||||||
|
one so it sorts and scans identically. A show with no `feed.txt` is untouched, exactly
|
||||||
|
as before - the file is the opt-in, there is no separate setting for it.
|
||||||
|
|
||||||
|
Each album carries three colours, pulled out of its cover art with Pillow (or
|
||||||
|
synthesised from a hash of its id when it has none). The frontend paints cards with
|
||||||
|
them and the LED strips run the first of them, so shelf and screen agree.
|
||||||
|
|
||||||
|
### The cache
|
||||||
|
|
||||||
|
`general.library.cache` is a directory, not a file, because its contents cost wildly
|
||||||
|
different amounts to produce:
|
||||||
|
|
||||||
|
```
|
||||||
|
index.json cheap: tags and structure. Rebuilt freely.
|
||||||
|
covers/<album_id>.jpg medium: art extracted from an ID3 APIC frame
|
||||||
|
analysis/<track_key>.json expensive: reserved for offline audio analysis
|
||||||
|
```
|
||||||
|
|
||||||
|
Deleting the whole directory is safe; deleting it throws away analysis that is minutes
|
||||||
|
of DSP per track, which is why anything expensive is keyed by *file content* rather than
|
||||||
|
by album id - renaming a folder or re-sorting a section then costs nothing.
|
||||||
|
|
||||||
|
An entry is reused whenever its files' sizes and mtimes are unchanged. That means a
|
||||||
|
change to how the scanner derives a title, artist or series is invisible until the cache
|
||||||
|
is invalidated: bump `_INDEX_VERSION` in `musicmouse/library/cache.py` when you touch
|
||||||
|
that logic.
|
||||||
|
|
||||||
|
**Track analysis is not implemented.** `musicmouse/library/analysis.py` fixes the shape
|
||||||
|
of the results - scalars (`tempo`, `energy`, `valence`, `brightness`) travel inline with
|
||||||
|
the index, and a beat grid lives in its own file and is fetched per track - so an
|
||||||
|
analyzer can be added later without touching the scanner, the API or the frontend. It
|
||||||
|
will go behind an optional dependency group, and because results are content-keyed
|
||||||
|
files, they can equally well be computed on a workstation and the `analysis/` folder
|
||||||
|
copied to the device.
|
||||||
|
|
||||||
|
## The web front-end
|
||||||
|
|
||||||
|
`web/` in the repo root, served by this backend when `general.web.static_dir` is set.
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/library` | Every album with its tracks and colours. ~90 kB, sent once. |
|
||||||
|
| `GET /api/albums/{id}/cover` | The cover, or 404 - the client paints the album's colours instead. |
|
||||||
|
| `POST /api/library/refresh` | Rescan in the background; connected clients are told when it lands. |
|
||||||
|
| `GET /api/state` | Snapshot: what is playing, where, how loud. |
|
||||||
|
| `WS /api/ws` | Push only. A snapshot on connect, then a frame per change, plus the position at 2 Hz while playing. |
|
||||||
|
| `POST /api/play` | `{album_id, track_index?}` |
|
||||||
|
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
|
||||||
|
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
|
||||||
|
| `GET` `PUT /api/settings` | parent mode |
|
||||||
|
|
||||||
|
Two decisions worth knowing:
|
||||||
|
|
||||||
|
**Search is not an endpoint.** The whole index goes to the browser and filtering happens
|
||||||
|
there, which is what makes the design's type-to-search feel instant.
|
||||||
|
|
||||||
|
**Volume is a percentage at this boundary.** `max_volume` is a parent's business, not a
|
||||||
|
child's, so it never crosses into the browser: `100 %` means whatever ceiling is
|
||||||
|
configured, and the mapping lives in `services/web/settings.py` so MQTT, the rotary
|
||||||
|
encoder and the firmware carry on in device units.
|
||||||
|
|
||||||
|
### Parent mode
|
||||||
|
|
||||||
|
`?parentMode=1` reveals a settings panel for the volume limits, the rotary step and the
|
||||||
|
button brightness. Saving writes `config.yml` back through ruamel's round-trip loader,
|
||||||
|
so the file keeps its comments, and a new ceiling applies to the running player rather
|
||||||
|
than waiting for a restart.
|
||||||
|
|
||||||
|
This **hides** the settings; it does not protect them. There is no authentication on any
|
||||||
|
endpoint, which matches a device on a home network - put it behind a reverse proxy if
|
||||||
|
that is not good enough.
|
||||||
184
python-backend/config.yml.example
Normal file
184
python-backend/config.yml.example
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Example config for the MusicMouse backend.
|
||||||
|
#
|
||||||
|
# python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
|
#
|
||||||
|
# Unknown keys are rejected rather than ignored, and every problem in the file is
|
||||||
|
# reported at once, so a typo fails at startup with the path to the offending line.
|
||||||
|
# Keep the real config (with credentials) off the repo - on the device only.
|
||||||
|
|
||||||
|
general:
|
||||||
|
# The music collection. One path; the shelves underneath it are fixed names, not
|
||||||
|
# settings, because each one has its own quirks the code already knows about:
|
||||||
|
#
|
||||||
|
# <root>/Figuren/<figure name>/ one folder per figurine
|
||||||
|
# <root>/Musik/<Artist> - <Album>/ albums, grouped by artist
|
||||||
|
# <root>/Hörbücher/<Artist> - <Album>/ audiobooks, grouped by character
|
||||||
|
# <root>/Kinderpodcasts/<Show>/ shows, newest episode first
|
||||||
|
#
|
||||||
|
# A cover.jpg next to the audio is used if present, otherwise the art is pulled out
|
||||||
|
# of the files' tags. Relative paths resolve against this file's directory.
|
||||||
|
library:
|
||||||
|
root: /home/martin/Music
|
||||||
|
# Scan results, extracted cover art and track analysis. Safe to delete: the index
|
||||||
|
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
||||||
|
# is expensive to recompute.
|
||||||
|
cache: .musicmouse-cache
|
||||||
|
# How many tracks the background analyzer may work on at once, each in its own
|
||||||
|
# worker process. Omitted means one per core bar one (capped at 8), which is what
|
||||||
|
# turns a first-time pass over a whole library from an overnight job into a coffee
|
||||||
|
# break on a desktop. Set it to 1 on a machine that has better things to do, or to
|
||||||
|
# a specific number to cap how much of it analysis may take.
|
||||||
|
# analysis_workers: 4
|
||||||
|
|
||||||
|
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
||||||
|
# Required - use "simulate" to run without the mouse attached, which is a complete
|
||||||
|
# setup on its own because the web front-end can drive the player by itself. RFID,
|
||||||
|
# buttons and LEDs then do nothing, and startup says so every boot.
|
||||||
|
serial_port: "/dev/ttyUSB0"
|
||||||
|
baudrate: 115200
|
||||||
|
reconnect_interval: 5.0
|
||||||
|
|
||||||
|
# ALSA output device passed to VLC, e.g. "hw:0,0", or "default" for the system
|
||||||
|
# default output. Required - use "simulate" for a player that makes no sound, which
|
||||||
|
# is handy when working on the web UI on a machine whose audio you would rather not
|
||||||
|
# commandeer. Startup says so every boot.
|
||||||
|
#
|
||||||
|
# Both of these are required rather than optional on purpose: running blind or silent
|
||||||
|
# has to be asked for, so a config that lost a line fails loudly instead of booting
|
||||||
|
# into something that looks like it is working.
|
||||||
|
alsa_device: "softvol_effects"
|
||||||
|
|
||||||
|
# Volume, 0..100. min/max clamp everything, including the rotary encoder.
|
||||||
|
min_volume: 0
|
||||||
|
max_volume: 60
|
||||||
|
initial_volume: 40
|
||||||
|
volume_increment: 5 # per rotary-encoder click
|
||||||
|
|
||||||
|
# Backlight of the prev/next buttons while a figure is playing, 0..1.
|
||||||
|
button_leds_brightness: 0.5
|
||||||
|
|
||||||
|
# Which files count as music. Anything else - a podcast downloader's archive.json,
|
||||||
|
# a half-finished .tmp - is ignored.
|
||||||
|
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
|
||||||
|
|
||||||
|
# How many episodes of each podcast show to keep, newest first. A show that has
|
||||||
|
# published for years grows without bound: GEOlino Spezial alone is 358 episodes and
|
||||||
|
# 5.8 GB, which will not sit next to the rest of a library on a Pi's SD card.
|
||||||
|
#
|
||||||
|
# The same number caps what gets downloaded, which is what makes the folder settle.
|
||||||
|
# Prune to the newest N but fetch everything the feed offers, and every poll would
|
||||||
|
# re-download the episodes the last one deleted.
|
||||||
|
#
|
||||||
|
# Lowering this DELETES the episodes that fall outside the window on the next poll,
|
||||||
|
# and an episode that has aged out of its feed cannot be fetched again. Use `null` to
|
||||||
|
# keep every episode and mind the free space yourself.
|
||||||
|
podcast_episode_limit: 50
|
||||||
|
|
||||||
|
# The web front-end. Omit the whole section to run without it.
|
||||||
|
#
|
||||||
|
# There is no authentication: this is a device on a home network. The settings panel
|
||||||
|
# at ?parentMode=1 is hidden from the child, not protected from them - it writes back
|
||||||
|
# to this file. Put it behind a reverse proxy if that is not good enough.
|
||||||
|
web:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8080
|
||||||
|
# Built frontend to serve at /. Omit to expose only the JSON API.
|
||||||
|
static_dir: ../web/dist
|
||||||
|
|
||||||
|
# IR remote control, over lircd's TCP socket (see ansible/roles/pi_lirc for how
|
||||||
|
# lircd itself is set up on the Pi). Omit the whole section to run without a remote.
|
||||||
|
# Play/pause/stop/previous/forward/rewind/volume/mute map to normal music control;
|
||||||
|
# number keys 0-9 play whatever the "remote:" section below assigns them.
|
||||||
|
lirc:
|
||||||
|
host: "musicmouse-pi.local"
|
||||||
|
port: 2222 # this deployment's lircd listens on 2222, not its own
|
||||||
|
# default of 8765 - see the ansible role
|
||||||
|
remote_name: "Hauppauge" # other remotes registered with the same lircd (an LED
|
||||||
|
# remote, say) are ignored
|
||||||
|
reconnect_interval: 5.0
|
||||||
|
|
||||||
|
# Home Assistant integration. Omit the whole section to run without MQTT.
|
||||||
|
# The backend exposes three lights, a player sensor, a volume slider, transport
|
||||||
|
# buttons, device triggers for every button/touch area, and a tag scanner.
|
||||||
|
mqtt:
|
||||||
|
server: "homeassistant.local"
|
||||||
|
port: 1883
|
||||||
|
user: "musicmouse"
|
||||||
|
password: "REPLACE_WITH_MQTT_PASSWORD"
|
||||||
|
base_topic: "musicmouse"
|
||||||
|
discovery_prefix: "homeassistant"
|
||||||
|
device_id: "musicmouse"
|
||||||
|
device_name: "Music Mouse"
|
||||||
|
reconnect_interval: 10.0
|
||||||
|
|
||||||
|
# Room control page ("Mein Zimmer"). Omit the whole section to hide the page. This is
|
||||||
|
# the opposite direction from mqtt above: it's musicmouse controlling Home Assistant
|
||||||
|
# entities, not the other way round. The backend proxies every call to Home
|
||||||
|
# Assistant's REST API with this token attached; the browser never sees it, only
|
||||||
|
# entity ids and display names. Home Assistant's own CORS settings do not need to
|
||||||
|
# allow musicmouse's origin for this - the browser only ever talks to musicmouse.
|
||||||
|
ha:
|
||||||
|
url: "http://homeassistant.local:8123"
|
||||||
|
# A long-lived access token, created under the HA user's own profile page.
|
||||||
|
token: "REPLACE_WITH_HA_LONG_LIVED_TOKEN"
|
||||||
|
# Cards on the room page, in this order. "name" is optional; falls back to the
|
||||||
|
# entity id if omitted.
|
||||||
|
devices:
|
||||||
|
- entity_id: cover.kinderzimmer_rollo
|
||||||
|
name: "Rollo"
|
||||||
|
- entity_id: light.kinderzimmer_hue_beyond_links
|
||||||
|
name: "Hue Beyond links"
|
||||||
|
- entity_id: light.kinderzimmer_deckenlampe
|
||||||
|
name: "Deckenlampe"
|
||||||
|
# Scene pill row above the cards, in this order.
|
||||||
|
scenes:
|
||||||
|
- entity_id: scene.kinderzimmer_lesen
|
||||||
|
name: "Lesen"
|
||||||
|
- entity_id: scene.kinderzimmer_gute_nacht
|
||||||
|
name: "Gute Nacht"
|
||||||
|
|
||||||
|
# The typing game ("Tippen"). Omit the whole section to hide its tab in the web
|
||||||
|
# front-end. The lesson plan is content, not device config, so it lives in its own
|
||||||
|
# file - see tippen-curriculum.yml.example for the format, including the optional
|
||||||
|
# `unlocks:` key that turns passing a lesson into unlocking part of the library.
|
||||||
|
tippen:
|
||||||
|
curriculum_file: tippen-curriculum.yml
|
||||||
|
# Where progress (stars, unlocked lessons, streak, ...) is saved. Written by the
|
||||||
|
# app itself - never hand-edited. Relative to this file, like curriculum_file.
|
||||||
|
progress_file: tippen-progress.json
|
||||||
|
|
||||||
|
# One entry per figurine. The key is the figure name and the subfolder name.
|
||||||
|
figures:
|
||||||
|
fuchs:
|
||||||
|
# RFID tag id, 5 bytes as hex. Must be unique across figures.
|
||||||
|
id: "04a1b2c3d4"
|
||||||
|
# Exactly four colours: primary, secondary, background, accent.
|
||||||
|
# Either "#rrggbb" (RGB) or "wNN" (white channel only, hex).
|
||||||
|
colors: ["#ff6600", "#ffcc00", "#331100", "wff"]
|
||||||
|
# "music" (default) or "book". Every other shelf is named after what is on it, so
|
||||||
|
# its type is obvious; a figure folder is named after the figurine, so this is the
|
||||||
|
# one thing that has to be said out loud. The web UI draws albums square and
|
||||||
|
# audiobooks taller than wide, so getting it wrong is visible at a glance.
|
||||||
|
kind: music
|
||||||
|
|
||||||
|
eule:
|
||||||
|
id: "04b2c3d4e5"
|
||||||
|
colors: ["#3355ff", "#66aaff", "#001133", "#ffffff"]
|
||||||
|
kind: book
|
||||||
|
|
||||||
|
# Number keys 0-9 on the IR remote, mapped to what they play. Omit the whole section,
|
||||||
|
# or any digit within it, for "unassigned" - a fresh install boots with none of this
|
||||||
|
# and that is not an error. Editable from the web front-end, which writes back here.
|
||||||
|
#
|
||||||
|
# target_kind: album -> always starts from the first track (music, audiobooks).
|
||||||
|
# target is an album id, as shown at GET /api/library.
|
||||||
|
# target_kind: series -> always plays the newest episode of a podcast show, resolved
|
||||||
|
# fresh on every press - never a fixed episode. target is the
|
||||||
|
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
|
||||||
|
remote:
|
||||||
|
"1":
|
||||||
|
target_kind: album
|
||||||
|
target: "3f9a0c12ab44"
|
||||||
|
"2":
|
||||||
|
target_kind: series
|
||||||
|
target: "Wissen macht Ah"
|
||||||
1
python-backend/music/.gitignore
vendored
Normal file
1
python-backend/music/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
**/*.mp3
|
||||||
24
python-backend/musicmouse.service
Normal file
24
python-backend/musicmouse.service
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Put this into /etc/systemd/system/musicmouse.service
|
||||||
|
#
|
||||||
|
# Assumes the repo is checked out at /opt/musicmouse with a venv at /opt/musicmouse/.venv:
|
||||||
|
# /opt/musicmouse/.venv/bin/pip install -e /opt/musicmouse/python-backend
|
||||||
|
#
|
||||||
|
# A dropped serial link is now handled in-process (SerialLink reconnects), so
|
||||||
|
# Restart=always is only for genuine crashes.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Music Mouse RFID Music Player
|
||||||
|
After=multi-user.target sound.target network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
WorkingDirectory=/opt/musicmouse/python-backend
|
||||||
|
ExecStart=/opt/musicmouse/.venv/bin/python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
5
python-backend/musicmouse/__init__.py
Normal file
5
python-backend/musicmouse/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""MusicMouse backend: an RFID music player for kids."""
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
|
__version__ = "2.0.0"
|
||||||
403
python-backend/musicmouse/__main__.py
Normal file
403
python-backend/musicmouse/__main__.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
"""Entry point and composition root.
|
||||||
|
|
||||||
|
python -m musicmouse --config /media/musicmouse/config.yml
|
||||||
|
python -m musicmouse --config ./config.yml --no-hardware
|
||||||
|
python -m musicmouse --config ./config.yml --simulate
|
||||||
|
python -m musicmouse --config ./config.yml --simulate --script scenarios/smoke.txt
|
||||||
|
|
||||||
|
This is the only module that knows which concrete implementations are in play; the
|
||||||
|
difference between "real mouse", "no mouse attached" and "simulated mouse" is which
|
||||||
|
transport and which player get built here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from collections.abc import Awaitable, Callable, Coroutine
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
|
||||||
|
from musicmouse import __version__
|
||||||
|
from musicmouse.app import App
|
||||||
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import RealClock
|
||||||
|
from musicmouse.config import SIMULATE, Config, ConfigError, GeneralConfig, load_config
|
||||||
|
from musicmouse.devices.mouse import MusicMouseDevice
|
||||||
|
from musicmouse.devices.null_transport import NullTransport
|
||||||
|
from musicmouse.devices.player import Player, VlcPlayer
|
||||||
|
from musicmouse.devices.serial_link import SerialLink
|
||||||
|
from musicmouse.library import MusicLibrary, default_worker_count
|
||||||
|
from musicmouse.library.analysis import build_analyzer
|
||||||
|
from musicmouse.reactions import register_all
|
||||||
|
from musicmouse.services.base import Service
|
||||||
|
from musicmouse.services.lirc import LircService
|
||||||
|
from musicmouse.services.mqtt import MqttService, build_entities
|
||||||
|
from musicmouse.services.podcasts import PodcastFeedService
|
||||||
|
from musicmouse.services.web import WebService
|
||||||
|
from musicmouse.tippen.curriculum import CurriculumError
|
||||||
|
from musicmouse.tippen.runtime import TippenRuntime, build_tippen_runtime
|
||||||
|
|
||||||
|
_log = logging.getLogger("musicmouse")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="musicmouse", description="Host backend for the MusicMouse RFID music player."
|
||||||
|
)
|
||||||
|
parser.add_argument("-c", "--config", type=Path, required=True, help="path to config.yml")
|
||||||
|
parser.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--simulate",
|
||||||
|
action="store_true",
|
||||||
|
help="run against fake hardware and a fake player (no serial port, no audio)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-hardware",
|
||||||
|
action="store_true",
|
||||||
|
help="real audio and a real web front-end, but no serial port: for a host with "
|
||||||
|
"no mouse attached",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--script",
|
||||||
|
type=Path,
|
||||||
|
help="with --simulate: run a scenario file instead of the interactive prompt",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-level",
|
||||||
|
default="INFO",
|
||||||
|
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||||
|
help="default: INFO",
|
||||||
|
)
|
||||||
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if args.script and not args.simulate:
|
||||||
|
parser.error("--script only makes sense together with --simulate")
|
||||||
|
if args.simulate and args.no_hardware:
|
||||||
|
parser.error("--simulate already runs without hardware; drop --no-hardware")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: str) -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, level),
|
||||||
|
format="%(asctime)s %(levelname)-7s %(name)-28s %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
setup_logging(args.log_level)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_config(args.config, check_paths=True)
|
||||||
|
except ConfigError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
tippen: TippenRuntime | None = None
|
||||||
|
if config.general.tippen is not None:
|
||||||
|
try:
|
||||||
|
tippen = build_tippen_runtime(config.general.tippen)
|
||||||
|
except CurriculumError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
runner = (
|
||||||
|
run_simulated(config, args.config, args.script, tippen=tippen)
|
||||||
|
if args.simulate
|
||||||
|
else run_real(
|
||||||
|
config, args.config, hardware=wants_hardware(config, args.no_hardware), tippen=tippen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
asyncio.run(runner)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
_log.info("Interrupted")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- real
|
||||||
|
|
||||||
|
|
||||||
|
async def run_real(
|
||||||
|
config: Config,
|
||||||
|
config_path: Path,
|
||||||
|
*,
|
||||||
|
hardware: bool = True,
|
||||||
|
tippen: TippenRuntime | None = None,
|
||||||
|
) -> None:
|
||||||
|
bus = EventBus()
|
||||||
|
await bus.start()
|
||||||
|
clock = RealClock()
|
||||||
|
general = config.general
|
||||||
|
|
||||||
|
link: SerialLink | None = None
|
||||||
|
if hardware:
|
||||||
|
link = SerialLink(
|
||||||
|
general.serial_port,
|
||||||
|
general.baudrate,
|
||||||
|
reconnect_interval=general.reconnect_interval,
|
||||||
|
clock=clock,
|
||||||
|
)
|
||||||
|
mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port)
|
||||||
|
link.attach(mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected)
|
||||||
|
else:
|
||||||
|
mouse = MusicMouseDevice(bus, NullTransport(), config.tag_map, port="none")
|
||||||
|
|
||||||
|
player = _build_player(bus, general, clock=clock)
|
||||||
|
|
||||||
|
library = await build_library(config)
|
||||||
|
app = _build_app(config, bus, mouse, player, library, clock=clock, tippen=tippen)
|
||||||
|
services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
|
||||||
|
|
||||||
|
_log.info(
|
||||||
|
"MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s",
|
||||||
|
__version__,
|
||||||
|
len(config.figures),
|
||||||
|
len(library.albums),
|
||||||
|
# Report what the config says, with a marker when --no-hardware overrode it,
|
||||||
|
# so the line never disagrees with the file it was started from.
|
||||||
|
general.serial_port + ("" if link or general.serial_simulated else " (no link)"),
|
||||||
|
general.alsa_device,
|
||||||
|
general.mqtt.server if general.mqtt else "disabled",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await _run_forever(
|
||||||
|
[
|
||||||
|
*([link.run()] if link else []),
|
||||||
|
player.run(),
|
||||||
|
# `is_busy` reads `player.is_playing` directly rather than the library
|
||||||
|
# knowing about playback at all: analysis must never compete with audio
|
||||||
|
# decoding for CPU, and this device is otherwise idle most of the day, so
|
||||||
|
# the pass simply resumes once it is. `on_batch` is unset unless the web
|
||||||
|
# front-end is on - nothing else has a use for the notification.
|
||||||
|
library.run_analysis(
|
||||||
|
is_busy=lambda: player.is_playing,
|
||||||
|
on_batch=_analysis_batch_hook(services),
|
||||||
|
),
|
||||||
|
*(service.run() for service in services),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
player.close()
|
||||||
|
await bus.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- simulated
|
||||||
|
|
||||||
|
|
||||||
|
async def run_simulated(
|
||||||
|
config: Config, config_path: Path, script: Path | None, *, tippen: TippenRuntime | None = None
|
||||||
|
) -> None:
|
||||||
|
# Imported here so the production path never touches the simulator.
|
||||||
|
from musicmouse.simulator.harness import build_simulation
|
||||||
|
from musicmouse.simulator.repl import run_repl
|
||||||
|
from musicmouse.simulator.script import run_script_file
|
||||||
|
|
||||||
|
# A script runs on virtual time, so `wait 1s` is instant. The prompt runs on the
|
||||||
|
# real clock, so playback ticks along while you watch it.
|
||||||
|
sim = await build_simulation(
|
||||||
|
config, clock=RealClock() if script is None else None, track_duration=5.0, tippen=tippen
|
||||||
|
)
|
||||||
|
|
||||||
|
services = _build_services(
|
||||||
|
sim.app, sim.app.mouse, sim.player, clock=RealClock(), config_path=config_path
|
||||||
|
)
|
||||||
|
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
|
||||||
|
tasks.append(
|
||||||
|
asyncio.create_task(
|
||||||
|
sim.app.library.run_analysis(
|
||||||
|
is_busy=lambda: sim.player.is_playing,
|
||||||
|
on_batch=_analysis_batch_hook(services),
|
||||||
|
),
|
||||||
|
name="library-analysis",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if script is not None:
|
||||||
|
await run_script_file(sim, script)
|
||||||
|
else:
|
||||||
|
await run_repl(sim)
|
||||||
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await sim.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- wiring
|
||||||
|
|
||||||
|
|
||||||
|
def wants_hardware(config: Config, no_hardware_flag: bool) -> bool:
|
||||||
|
"""Whether to open a serial port at all.
|
||||||
|
|
||||||
|
``serial_port: simulate`` says the same thing ``--no-hardware`` does. It is asked
|
||||||
|
for rather than inferred from a missing key, but it is still worth saying out loud
|
||||||
|
every boot - a mouse whose RFID reader does nothing should say why.
|
||||||
|
"""
|
||||||
|
if no_hardware_flag:
|
||||||
|
return False
|
||||||
|
if config.general.serial_simulated:
|
||||||
|
_log.warning(
|
||||||
|
'general.serial_port is "%s": running without the mouse. '
|
||||||
|
"The web front-end still works; RFID, buttons and LEDs do not.",
|
||||||
|
SIMULATE,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) -> Player:
|
||||||
|
"""A real player, or a silent one when the config asked for that.
|
||||||
|
|
||||||
|
``alsa_device: simulate`` gets the simulator's player: everything above it behaves
|
||||||
|
identically, it just makes no sound. Useful for working on the web UI without
|
||||||
|
commandeering the machine's audio.
|
||||||
|
"""
|
||||||
|
if not general.audio_simulated:
|
||||||
|
return VlcPlayer(
|
||||||
|
bus,
|
||||||
|
alsa_device=general.alsa_device,
|
||||||
|
clock=clock,
|
||||||
|
**VlcPlayer.volume_kwargs(general),
|
||||||
|
)
|
||||||
|
|
||||||
|
_log.warning(
|
||||||
|
'general.alsa_device is "%s": using a simulated player, so nothing will be '
|
||||||
|
'audible. Set it to "default" for the system default output.',
|
||||||
|
SIMULATE,
|
||||||
|
)
|
||||||
|
# Imported here rather than at module scope so the real audio path never loads the
|
||||||
|
# simulator - and so this still runs on a machine without libVLC at all.
|
||||||
|
from musicmouse.simulator.fake_player import FakePlayer
|
||||||
|
|
||||||
|
return FakePlayer(bus, clock=clock, **FakePlayer.volume_kwargs(general))
|
||||||
|
|
||||||
|
|
||||||
|
async def build_library(config: Config) -> MusicLibrary:
|
||||||
|
library_config = config.general.library
|
||||||
|
workers = library_config.analysis_workers
|
||||||
|
return await MusicLibrary.build(
|
||||||
|
library_config.root,
|
||||||
|
library_config.cache,
|
||||||
|
frozenset(config.general.audio_extensions),
|
||||||
|
analyzer=build_analyzer(),
|
||||||
|
# Unset in the config means "use the machine": a first pass over an unanalyzed
|
||||||
|
# library is hours of DSP, and there is no reason for a desktop to do it one
|
||||||
|
# core at a time. `MusicLibrary` itself defaults to 1 - see its docstring.
|
||||||
|
analysis_workers=default_worker_count() if workers is None else workers,
|
||||||
|
figure_kinds=config.figure_kinds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app(
|
||||||
|
config: Config,
|
||||||
|
bus: EventBus,
|
||||||
|
mouse: MusicMouseDevice,
|
||||||
|
player: Player,
|
||||||
|
library: MusicLibrary,
|
||||||
|
*,
|
||||||
|
clock: RealClock,
|
||||||
|
tippen: TippenRuntime | None = None,
|
||||||
|
) -> App:
|
||||||
|
app = App(
|
||||||
|
config=config,
|
||||||
|
bus=bus,
|
||||||
|
mouse=mouse,
|
||||||
|
player=player,
|
||||||
|
library=library,
|
||||||
|
# One source of truth: the figure path and the web path must hand the player the
|
||||||
|
# same Playlist object for the same folder, because `play_figure` resumes on an
|
||||||
|
# identity check.
|
||||||
|
playlists=library.figure_playlists(),
|
||||||
|
clock=clock,
|
||||||
|
tippen=tippen,
|
||||||
|
)
|
||||||
|
register_all(bus, app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=Service)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_of_type(services: list[Service], kind: type[T]) -> T | None:
|
||||||
|
return next((s for s in services if isinstance(s, kind)), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
|
||||||
|
"""The web front-end's own hub, if it is running - so open tabs refetch the library
|
||||||
|
as background analysis lands, instead of only after a manual reload. `None` when
|
||||||
|
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
|
||||||
|
tell".
|
||||||
|
"""
|
||||||
|
web_service = _service_of_type(services, WebService)
|
||||||
|
return web_service.hub.broadcast_library if web_service else None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_services(
|
||||||
|
app: App,
|
||||||
|
mouse: MusicMouseDevice,
|
||||||
|
player: Player,
|
||||||
|
*,
|
||||||
|
clock: RealClock,
|
||||||
|
config_path: Path,
|
||||||
|
) -> list[Service]:
|
||||||
|
"""Every front-end. Each one only speaks intents, so they cannot conflict."""
|
||||||
|
services: list[Service] = []
|
||||||
|
|
||||||
|
mqtt_config = app.config.general.mqtt
|
||||||
|
if mqtt_config is None:
|
||||||
|
_log.info("No mqtt section in the config: Home Assistant integration is off")
|
||||||
|
else:
|
||||||
|
entities = build_entities(app.bus, mqtt_config, mouse, player)
|
||||||
|
services.append(MqttService(app.bus, mqtt_config, entities, clock=clock))
|
||||||
|
|
||||||
|
web_config = app.config.general.web
|
||||||
|
if web_config is None:
|
||||||
|
_log.info("No web section in the config: the web front-end is off")
|
||||||
|
else:
|
||||||
|
services.append(WebService(app, web_config, config_path))
|
||||||
|
|
||||||
|
lirc_config = app.config.general.lirc
|
||||||
|
if lirc_config is None:
|
||||||
|
_log.info("No lirc section in the config: the IR remote is off")
|
||||||
|
else:
|
||||||
|
services.append(LircService(app, lirc_config, clock=clock))
|
||||||
|
|
||||||
|
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
||||||
|
# into its folder, so there is nothing to gate here with its own config section.
|
||||||
|
web_service = _service_of_type(services, WebService)
|
||||||
|
services.append(
|
||||||
|
PodcastFeedService(
|
||||||
|
app.library,
|
||||||
|
client=httpx2.AsyncClient(timeout=30.0),
|
||||||
|
on_change=lambda: app.rescan_library(
|
||||||
|
broadcast=web_service.hub.broadcast_library if web_service else None
|
||||||
|
),
|
||||||
|
episode_limit=app.config.general.podcast_episode_limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return services
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_forever(coroutines: list[Coroutine[Any, Any, None]]) -> None:
|
||||||
|
tasks = [asyncio.create_task(coro) for coro in coroutines]
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
78
python-backend/musicmouse/app.py
Normal file
78
python-backend/musicmouse/app.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""What the reactions get handed: the three objects, the bus, and a little shared state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import Clock, RealClock
|
||||||
|
from musicmouse.config import Config, FigureColors
|
||||||
|
from musicmouse.devices.mouse import MusicMouseDevice
|
||||||
|
from musicmouse.devices.player import Player
|
||||||
|
from musicmouse.library import MusicLibrary
|
||||||
|
from musicmouse.library.models import Album
|
||||||
|
from musicmouse.media import Playlist
|
||||||
|
from musicmouse.tippen.runtime import TippenRuntime
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["App", "AppState"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppState:
|
||||||
|
"""State that belongs to no single device but is shared between reactions."""
|
||||||
|
|
||||||
|
#: Figure that was taken off the reader mid-playlist, so putting it back resumes
|
||||||
|
#: instead of starting over. Cleared once its playlist runs out.
|
||||||
|
last_partially_played_figure: str | None = None
|
||||||
|
|
||||||
|
#: Whether the MQTT broker is currently reachable. The firmware's equivalent is
|
||||||
|
#: readable off the transport; a broker's is not, so it is remembered here.
|
||||||
|
mqtt_connected: bool = False
|
||||||
|
|
||||||
|
#: Whether the lircd TCP link for the IR remote is currently reachable.
|
||||||
|
lirc_connected: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class App:
|
||||||
|
config: Config
|
||||||
|
bus: EventBus
|
||||||
|
mouse: MusicMouseDevice
|
||||||
|
player: Player
|
||||||
|
library: MusicLibrary
|
||||||
|
playlists: dict[str, Playlist]
|
||||||
|
clock: Clock = field(default_factory=RealClock)
|
||||||
|
state: AppState = field(default_factory=AppState)
|
||||||
|
#: `None` when `general.tippen` is absent - the typing game is off.
|
||||||
|
tippen: TippenRuntime | None = None
|
||||||
|
|
||||||
|
def colors(self, figure: str) -> FigureColors:
|
||||||
|
return self.config.figures[figure].colors
|
||||||
|
|
||||||
|
def playlist(self, figure: str) -> Playlist | None:
|
||||||
|
playlist = self.playlists.get(figure)
|
||||||
|
if playlist is None:
|
||||||
|
_log.warning("No playlist for figure %r", figure)
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
def album_for(self, playlist: Playlist | None) -> Album | None:
|
||||||
|
"""The library album a playlist came from, if any."""
|
||||||
|
return self.library.get(playlist.album_id if playlist else None)
|
||||||
|
|
||||||
|
async def rescan_library(
|
||||||
|
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Rescan from disk, rebuild figure playlists, and tell whoever's listening.
|
||||||
|
|
||||||
|
Shared by the manual "Bibliothek neu einlesen" endpoint and anything else that
|
||||||
|
can change what's on disk on its own, such as the podcast feed poller.
|
||||||
|
"""
|
||||||
|
await self.library.refresh()
|
||||||
|
self.playlists.clear()
|
||||||
|
self.playlists.update(self.library.figure_playlists())
|
||||||
|
if broadcast is not None:
|
||||||
|
await broadcast()
|
||||||
157
python-backend/musicmouse/bus.py
Normal file
157
python-backend/musicmouse/bus.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""The event bus.
|
||||||
|
|
||||||
|
Everything in the process is serialised through one FIFO queue on one loop, which is
|
||||||
|
what makes "last event wins" a well-defined rule for LED zone arbitration and what
|
||||||
|
makes scenario tests deterministic.
|
||||||
|
|
||||||
|
Handlers may be sync or async; async handlers are awaited, so one event is fully
|
||||||
|
handled before the next is dispatched. A handler that raises is logged and does not
|
||||||
|
stop the others or the bus.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Any, TypeAlias, TypeVar
|
||||||
|
|
||||||
|
from musicmouse.events import Event
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["EventBus", "Handler", "Unsubscribe"]
|
||||||
|
|
||||||
|
#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
|
||||||
|
#: parameterises it the way the PEP 695 form did.
|
||||||
|
E = TypeVar("E", bound=Event)
|
||||||
|
|
||||||
|
Handler: TypeAlias = Callable[[E], Coroutine[Any, Any, None] | None]
|
||||||
|
Unsubscribe: TypeAlias = Callable[[], None]
|
||||||
|
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._handlers: dict[type[Event], list[Handler[Any]]] = {}
|
||||||
|
self._wildcard: list[Handler[Any]] = []
|
||||||
|
self._resolved: dict[type[Event], tuple[Handler[Any], ...]] = {}
|
||||||
|
self._queue: asyncio.Queue[Event] = asyncio.Queue()
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._dispatcher: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ lifecycle
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._dispatcher is not None:
|
||||||
|
return
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self._dispatcher = asyncio.create_task(self._run(), name="event-bus")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._dispatcher is None:
|
||||||
|
return
|
||||||
|
self._dispatcher.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._dispatcher
|
||||||
|
self._dispatcher = None
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> EventBus:
|
||||||
|
await self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc_info: object) -> None:
|
||||||
|
await self.stop()
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- subscription
|
||||||
|
|
||||||
|
def subscribe(self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
|
||||||
|
"""Register ``handler`` for ``event_type`` and any subclass of it."""
|
||||||
|
self._handlers.setdefault(event_type, []).append(handler)
|
||||||
|
self._resolved.clear()
|
||||||
|
|
||||||
|
def unsubscribe() -> None:
|
||||||
|
handlers = self._handlers.get(event_type)
|
||||||
|
if handlers and handler in handlers:
|
||||||
|
handlers.remove(handler)
|
||||||
|
self._resolved.clear()
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
|
|
||||||
|
def subscribe_all(self, handler: Handler[Event]) -> Unsubscribe:
|
||||||
|
"""Register ``handler`` for every event. Useful for logging and broadcasting."""
|
||||||
|
self._wildcard.append(handler)
|
||||||
|
|
||||||
|
def unsubscribe() -> None:
|
||||||
|
if handler in self._wildcard:
|
||||||
|
self._wildcard.remove(handler)
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- publication
|
||||||
|
|
||||||
|
def emit(self, event: Event) -> None:
|
||||||
|
"""Queue ``event`` for dispatch. Safe to call from any thread.
|
||||||
|
|
||||||
|
libVLC fires its callbacks on its own thread; this is where that crossing is
|
||||||
|
made safe instead of reaching the serial transport off-loop.
|
||||||
|
"""
|
||||||
|
loop = self._loop
|
||||||
|
if loop is None:
|
||||||
|
raise RuntimeError("EventBus.emit() before start()")
|
||||||
|
try:
|
||||||
|
running = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
running = None
|
||||||
|
if running is loop:
|
||||||
|
self._queue.put_nowait(event)
|
||||||
|
else:
|
||||||
|
loop.call_soon_threadsafe(self._queue.put_nowait, event)
|
||||||
|
|
||||||
|
async def drain(self) -> None:
|
||||||
|
"""Wait until every queued event, and everything they emitted, is handled."""
|
||||||
|
await self._queue.join()
|
||||||
|
|
||||||
|
async def emit_and_wait(self, event: Event) -> None:
|
||||||
|
self.emit(event)
|
||||||
|
await self.drain()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- internals
|
||||||
|
|
||||||
|
async def _run(self) -> None:
|
||||||
|
while True:
|
||||||
|
event = await self._queue.get()
|
||||||
|
try:
|
||||||
|
await self._dispatch(event)
|
||||||
|
finally:
|
||||||
|
self._queue.task_done()
|
||||||
|
|
||||||
|
async def _dispatch(self, event: Event) -> None:
|
||||||
|
for handler in self._handlers_for(type(event)):
|
||||||
|
try:
|
||||||
|
result = handler(event)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
_log.exception("Handler %s failed on %r", _name(handler), event)
|
||||||
|
|
||||||
|
def _handlers_for(self, event_type: type[Event]) -> tuple[Handler[Any], ...]:
|
||||||
|
cached = self._resolved.get(event_type)
|
||||||
|
if cached is None:
|
||||||
|
matched: list[Handler[Any]] = []
|
||||||
|
for klass in event_type.__mro__:
|
||||||
|
if klass is object:
|
||||||
|
continue
|
||||||
|
matched.extend(self._handlers.get(klass, ()))
|
||||||
|
cached = tuple(matched)
|
||||||
|
self._resolved[event_type] = cached
|
||||||
|
# Wildcards are not cached: they are appended last and change rarely.
|
||||||
|
return cached + tuple(self._wildcard)
|
||||||
|
|
||||||
|
|
||||||
|
def _name(handler: Handler[Any]) -> str:
|
||||||
|
return getattr(handler, "__qualname__", repr(handler))
|
||||||
94
python-backend/musicmouse/clock.py
Normal file
94
python-backend/musicmouse/clock.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Time, behind a protocol.
|
||||||
|
|
||||||
|
Anything that waits takes a :class:`Clock` instead of calling :func:`asyncio.sleep`
|
||||||
|
directly. Under :class:`RealClock` a scenario runs in real time; under
|
||||||
|
:class:`FakeClock` the identical scenario runs in microseconds, which is what makes
|
||||||
|
``wait 1s`` affordable inside the test suite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import heapq
|
||||||
|
import itertools
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
__all__ = ["Clock", "FakeClock", "RealClock"]
|
||||||
|
|
||||||
|
|
||||||
|
class Clock(Protocol):
|
||||||
|
def now(self) -> float:
|
||||||
|
"""Monotonic seconds. Only differences are meaningful."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def sleep(self, seconds: float) -> None:
|
||||||
|
"""Suspend the calling task for ``seconds``."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def advance(self, seconds: float) -> None:
|
||||||
|
"""Let ``seconds`` pass, from the driver's point of view."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RealClock:
|
||||||
|
def now(self) -> float:
|
||||||
|
return time.monotonic()
|
||||||
|
|
||||||
|
async def sleep(self, seconds: float) -> None:
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
|
||||||
|
async def advance(self, seconds: float) -> None:
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
"""Virtual time.
|
||||||
|
|
||||||
|
``sleep()`` parks the caller until ``advance()`` moves time past its deadline.
|
||||||
|
``idle`` is awaited after each wake-up so that whatever the woken task emitted has
|
||||||
|
been fully handled before virtual time moves on - pass ``EventBus.drain``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, start: float = 0.0, idle: Callable[[], Awaitable[None]] | None = None):
|
||||||
|
self._now = start
|
||||||
|
self._idle = idle
|
||||||
|
self._counter = itertools.count()
|
||||||
|
self._sleepers: list[tuple[float, int, asyncio.Future[None]]] = []
|
||||||
|
|
||||||
|
def now(self) -> float:
|
||||||
|
return self._now
|
||||||
|
|
||||||
|
async def sleep(self, seconds: float) -> None:
|
||||||
|
if seconds <= 0:
|
||||||
|
await self._settle()
|
||||||
|
return
|
||||||
|
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
||||||
|
heapq.heappush(self._sleepers, (self._now + seconds, next(self._counter), future))
|
||||||
|
await future
|
||||||
|
|
||||||
|
async def advance(self, seconds: float) -> None:
|
||||||
|
# Settle first: a task created but not yet started has not registered its
|
||||||
|
# sleep, and would otherwise have its deadline computed from the new time.
|
||||||
|
await self._settle()
|
||||||
|
target = self._now + max(0.0, seconds)
|
||||||
|
while self._sleepers and self._sleepers[0][0] <= target:
|
||||||
|
deadline, _, future = heapq.heappop(self._sleepers)
|
||||||
|
self._now = max(self._now, deadline)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(None)
|
||||||
|
await self._settle()
|
||||||
|
self._now = target
|
||||||
|
await self._settle()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_timers(self) -> int:
|
||||||
|
return len(self._sleepers)
|
||||||
|
|
||||||
|
async def _settle(self) -> None:
|
||||||
|
# Give woken tasks a chance to run, then let their events be handled.
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if self._idle is not None:
|
||||||
|
await self._idle()
|
||||||
|
await asyncio.sleep(0)
|
||||||
128
python-backend/musicmouse/color.py
Normal file
128
python-backend/musicmouse/color.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Colour types shared by the LED wire format and the config schema.
|
||||||
|
|
||||||
|
Kept separate from :mod:`musicmouse.devices.effects` so that :mod:`musicmouse.config`
|
||||||
|
can validate colours without importing anything device-related.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import colorsys
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
__all__ = ["ColorHSV", "ColorRGBW", "parse_color"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ColorRGBW:
|
||||||
|
"""An RGBW colour with all channels normalised to ``0.0 .. 1.0``."""
|
||||||
|
|
||||||
|
r: float
|
||||||
|
g: float
|
||||||
|
b: float
|
||||||
|
w: float
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
return all(0 <= v <= 1 for v in (self.r, self.g, self.b, self.w))
|
||||||
|
|
||||||
|
def as_bytes(self) -> bytes:
|
||||||
|
if not self.is_valid:
|
||||||
|
raise ValueError(f"Channel values must be within 0..1, got {self!r}")
|
||||||
|
return struct.pack(
|
||||||
|
"<BBBB",
|
||||||
|
int(self.r * 255),
|
||||||
|
int(self.g * 255),
|
||||||
|
int(self.b * 255),
|
||||||
|
int(self.w * 255),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> ColorRGBW:
|
||||||
|
r, g, b, w = struct.unpack("<BBBB", data)
|
||||||
|
return cls(r / 255, g / 255, b / 255, w / 255)
|
||||||
|
|
||||||
|
def __mul__(self, scale: float) -> ColorRGBW:
|
||||||
|
if not 0 <= scale <= 1:
|
||||||
|
raise ValueError(f"Scale must be within 0..1, got {scale}")
|
||||||
|
return ColorRGBW(self.r * scale, self.g * scale, self.b * scale, self.w * scale)
|
||||||
|
|
||||||
|
def without_white_channel(self) -> ColorRGBW:
|
||||||
|
"""Fold the white channel into RGB, for strips driven without a W channel."""
|
||||||
|
r, g, b = (min(1.0, c + self.w) for c in (self.r, self.g, self.b))
|
||||||
|
return ColorRGBW(r, g, b, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ColorHSV:
|
||||||
|
"""Hue in degrees (``0..360``), saturation ``0..1``, value ``0..2``."""
|
||||||
|
|
||||||
|
h: float
|
||||||
|
s: float
|
||||||
|
v: float
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"ColorHSV({self.h}, {self.s}, {self.v})"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_rgb(rgb: ColorRGBW) -> ColorHSV:
|
||||||
|
h, s, v = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
|
||||||
|
return ColorHSV(h * 360, s, v)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
return 0 <= self.h <= 360 and 0 <= self.s <= 1 and 0 <= self.v <= 2
|
||||||
|
|
||||||
|
def as_bytes(self) -> bytes:
|
||||||
|
if not self.is_valid:
|
||||||
|
raise ValueError(f"Out-of-range HSV colour {self!r}")
|
||||||
|
return struct.pack("<fff", self.h, self.s, self.v)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> ColorHSV:
|
||||||
|
return cls(*struct.unpack("<fff", data))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_color(value: str | ColorRGBW) -> ColorRGBW:
|
||||||
|
"""Parse ``"#rrggbb"`` (RGB) or ``"wNN"`` (white channel only) into a colour.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: with a message naming the accepted formats.
|
||||||
|
"""
|
||||||
|
if isinstance(value, ColorRGBW):
|
||||||
|
return value
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"expected a colour string, got {type(value).__name__}")
|
||||||
|
|
||||||
|
text = value.strip()
|
||||||
|
if text.startswith("#"):
|
||||||
|
digits = text[1:]
|
||||||
|
if len(digits) != 6:
|
||||||
|
raise ValueError(
|
||||||
|
f"unrecognized color format {value!r} "
|
||||||
|
f"(expected '#rrggbb' with 6 hex digits, got {len(digits)})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r, g, b = (int(digits[i : i + 2], 16) / 255 for i in (0, 2, 4))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"unrecognized color format {value!r} (expected '#rrggbb' with hex digits)"
|
||||||
|
) from None
|
||||||
|
return ColorRGBW(r, g, b, 0)
|
||||||
|
|
||||||
|
if text.startswith("w"):
|
||||||
|
digits = text[1:]
|
||||||
|
try:
|
||||||
|
white = int(digits, 16)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"unrecognized color format {value!r} (expected 'wNN' with hex digits)"
|
||||||
|
) from None
|
||||||
|
if not 0 <= white <= 255:
|
||||||
|
raise ValueError(f"white value in {value!r} must be within 00..ff")
|
||||||
|
return ColorRGBW(0, 0, 0, white / 255)
|
||||||
|
|
||||||
|
raise ValueError(f"unrecognized color format {value!r} (expected '#rrggbb' or 'wNN')")
|
||||||
436
python-backend/musicmouse/config.py
Normal file
436
python-backend/musicmouse/config.py
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
"""Config schema and loading.
|
||||||
|
|
||||||
|
Validation is strict on purpose: unknown keys are rejected (a typo'd setting that is
|
||||||
|
silently ignored is worse than a startup failure), and every problem in the file is
|
||||||
|
reported at once rather than one per run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Any, Final, Literal, Self, TypeAlias
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
Field,
|
||||||
|
PlainValidator,
|
||||||
|
ValidationError,
|
||||||
|
ValidationInfo,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
from ruamel.yaml.error import YAMLError
|
||||||
|
|
||||||
|
from musicmouse.color import ColorRGBW, parse_color
|
||||||
|
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
||||||
|
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SIMULATE",
|
||||||
|
"Config",
|
||||||
|
"ConfigError",
|
||||||
|
"Digit",
|
||||||
|
"FigureColors",
|
||||||
|
"FigureConfig",
|
||||||
|
"GeneralConfig",
|
||||||
|
"HaConfig",
|
||||||
|
"HaDeviceConfig",
|
||||||
|
"LibraryConfig",
|
||||||
|
"LircConfig",
|
||||||
|
"MqttConfig",
|
||||||
|
"RemoteSlotConfig",
|
||||||
|
"TippenConfig",
|
||||||
|
"WebConfig",
|
||||||
|
"format_validation_error",
|
||||||
|
"load_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
|
||||||
|
Digit: TypeAlias = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
||||||
|
|
||||||
|
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||||
|
|
||||||
|
#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or
|
||||||
|
#: without sound is a supported setup, but it has to be *asked for*: a missing key is an
|
||||||
|
#: error, so a config that lost a line fails loudly instead of booting into a silent
|
||||||
|
#: mouse that looks like it is working.
|
||||||
|
SIMULATE: Final = "simulate"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"""Raised with an already human-readable, multi-line message."""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tag_id(value: Any) -> bytes:
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
raw = value
|
||||||
|
else:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"expected a hex string, got {type(value).__name__}")
|
||||||
|
text = value.strip().replace(":", "").replace(" ", "")
|
||||||
|
try:
|
||||||
|
raw = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"{value!r} is not a valid hex string") from None
|
||||||
|
if len(raw) != RFID_TAG_LENGTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected {RFID_TAG_LENGTH} bytes ({RFID_TAG_LENGTH * 2} hex digits), "
|
||||||
|
f"got {len(raw)} ({raw.hex()!r})"
|
||||||
|
)
|
||||||
|
if raw == NO_FIGURE_TAG:
|
||||||
|
raise ValueError("the all-zero tag id is reserved for 'no figure on the reader'")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
Color = Annotated[ColorRGBW, PlainValidator(parse_color)]
|
||||||
|
TagId = Annotated[bytes, PlainValidator(_parse_tag_id)]
|
||||||
|
|
||||||
|
_COLOR_ROLES = ("primary", "secondary", "bg", "accent")
|
||||||
|
|
||||||
|
|
||||||
|
class _Strict(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FigureColors(_Strict):
|
||||||
|
"""The four colours of a figure, given in config as a list of colour strings."""
|
||||||
|
|
||||||
|
primary: Color
|
||||||
|
secondary: Color
|
||||||
|
bg: Color
|
||||||
|
accent: Color
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _accept_sequence(cls, data: Any) -> Any:
|
||||||
|
if isinstance(data, (list, tuple)):
|
||||||
|
if len(data) != len(_COLOR_ROLES):
|
||||||
|
raise ValueError(
|
||||||
|
f"expected exactly {len(_COLOR_ROLES)} colors "
|
||||||
|
f"({', '.join(_COLOR_ROLES)}), got {len(data)}"
|
||||||
|
)
|
||||||
|
return dict(zip(_COLOR_ROLES, data, strict=True))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_folder(
|
||||||
|
folder: Path, info: ValidationInfo, *, must_exist: bool, kind: Literal["dir", "file"] = "dir"
|
||||||
|
) -> Path:
|
||||||
|
"""Make a configured path absolute against the config file, and optionally check it."""
|
||||||
|
context = info.context or {}
|
||||||
|
base = context.get("config_dir")
|
||||||
|
if base is not None and not folder.is_absolute():
|
||||||
|
folder = (Path(base) / folder).resolve()
|
||||||
|
if must_exist and context.get("check_paths", True):
|
||||||
|
exists = folder.is_file() if kind == "file" else folder.is_dir()
|
||||||
|
if not exists:
|
||||||
|
noun = "file" if kind == "file" else "directory"
|
||||||
|
raise ValueError(f"no such {noun}: {folder}")
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
class MqttConfig(_Strict):
|
||||||
|
server: str
|
||||||
|
port: int = Field(default=1883, ge=1, le=65535)
|
||||||
|
user: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
base_topic: str = "musicmouse"
|
||||||
|
discovery_prefix: str = "homeassistant"
|
||||||
|
device_id: str = "musicmouse"
|
||||||
|
device_name: str = "Music Mouse"
|
||||||
|
reconnect_interval: float = Field(default=10.0, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class LircConfig(_Strict):
|
||||||
|
"""TCP client for lircd's classic network protocol - see ``ansible/roles/pi_lirc``.
|
||||||
|
|
||||||
|
Omit the whole section to run without an IR remote.
|
||||||
|
"""
|
||||||
|
|
||||||
|
host: str
|
||||||
|
#: This deployment's lircd listens on 2222 (see the ansible role); lircd's own
|
||||||
|
#: default is 8765, so this is worth overriding rather than assuming.
|
||||||
|
port: int = Field(default=2222, ge=1, le=65535)
|
||||||
|
#: Only button events from this remote are acted on - other remotes registered
|
||||||
|
#: with the same lircd (an LED remote, say) are ignored.
|
||||||
|
remote_name: str = "Hauppauge"
|
||||||
|
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryConfig(_Strict):
|
||||||
|
"""Where the music lives.
|
||||||
|
|
||||||
|
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hoerbuecher``,
|
||||||
|
``Kinderpodcasts`` - are fixed names, not settings; see
|
||||||
|
:mod:`musicmouse.library.sections`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
root: Path
|
||||||
|
#: Scan results, extracted cover art and track analysis. Relative to this file.
|
||||||
|
cache: Path = Path(".musicmouse-cache")
|
||||||
|
#: How many tracks background analysis may work on at once, each in its own worker
|
||||||
|
#: process. Omit for one per core bar one (see
|
||||||
|
#: :func:`musicmouse.library.workers.default_worker_count`); set it to 1 to keep
|
||||||
|
#: analysis to a single process on a machine that has other work to do.
|
||||||
|
analysis_workers: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
@field_validator("root")
|
||||||
|
@classmethod
|
||||||
|
def _resolve_root(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||||
|
return _resolve_folder(folder, info, must_exist=True)
|
||||||
|
|
||||||
|
@field_validator("cache")
|
||||||
|
@classmethod
|
||||||
|
def _resolve_cache(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||||
|
return _resolve_folder(folder, info, must_exist=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def figure_folder(self) -> Path:
|
||||||
|
return self.root / "Figuren"
|
||||||
|
|
||||||
|
|
||||||
|
class WebConfig(_Strict):
|
||||||
|
"""The web front-end. Omit the whole section to run without it."""
|
||||||
|
|
||||||
|
#: A LAN appliance with no auth; binding to all interfaces is the point.
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = Field(default=8080, ge=1, le=65535)
|
||||||
|
#: Built frontend to serve at ``/``. Omit to expose only the JSON API.
|
||||||
|
static_dir: Path | None = None
|
||||||
|
|
||||||
|
@field_validator("static_dir")
|
||||||
|
@classmethod
|
||||||
|
def _resolve_static(cls, folder: Path | None, info: ValidationInfo) -> Path | None:
|
||||||
|
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
|
||||||
|
|
||||||
|
|
||||||
|
class HaDeviceConfig(_Strict):
|
||||||
|
"""One Home Assistant entity to expose to the room-control page ("Mein Zimmer")."""
|
||||||
|
|
||||||
|
entity_id: str
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HaConfig(_Strict):
|
||||||
|
"""Home Assistant integration for the room-control page ("Mein Zimmer").
|
||||||
|
|
||||||
|
The backend never calls Home Assistant itself - it only hands the browser the
|
||||||
|
server URL, the token, and these two ordered lists. Control happens directly from
|
||||||
|
the browser to Home Assistant's own REST API, so this token grants full HA control
|
||||||
|
to anything on the LAN that can reach musicmouse. See config.yml.example.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
token: str
|
||||||
|
#: Order is preserved and drives the device card grid on the room page.
|
||||||
|
devices: list[HaDeviceConfig] = Field(default_factory=list)
|
||||||
|
#: Order is preserved and drives the scene pill row on the room page.
|
||||||
|
scenes: list[HaDeviceConfig] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_something_configured(self) -> Self:
|
||||||
|
if not self.devices and not self.scenes:
|
||||||
|
raise ValueError("configure at least one device or scene, or omit the ha section")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TippenConfig(_Strict):
|
||||||
|
"""The typing game. Omit the whole section to run without it.
|
||||||
|
|
||||||
|
The curriculum is content, not device settings, so it lives in its own file
|
||||||
|
(``curriculum_file``) rather than inline here - see ``tippen-curriculum.yml.example``.
|
||||||
|
``progress_file`` is written by the app itself, not hand-edited, and defaults to a
|
||||||
|
name next to ``config.yml`` if not given a folder of its own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
curriculum_file: Path
|
||||||
|
progress_file: Path = Path("tippen-progress.json")
|
||||||
|
|
||||||
|
@field_validator("curriculum_file")
|
||||||
|
@classmethod
|
||||||
|
def _resolve_curriculum_file(cls, path: Path, info: ValidationInfo) -> Path:
|
||||||
|
return _resolve_folder(path, info, must_exist=True, kind="file")
|
||||||
|
|
||||||
|
@field_validator("progress_file")
|
||||||
|
@classmethod
|
||||||
|
def _resolve_progress_file(cls, path: Path, info: ValidationInfo) -> Path:
|
||||||
|
return _resolve_folder(path, info, must_exist=False, kind="file")
|
||||||
|
|
||||||
|
|
||||||
|
class GeneralConfig(_Strict):
|
||||||
|
library: LibraryConfig
|
||||||
|
|
||||||
|
#: Serial port the ESP32 is on, or ``"simulate"`` to run without the mouse: the
|
||||||
|
#: web front-end is a complete way to drive the player on its own. Required.
|
||||||
|
serial_port: str
|
||||||
|
baudrate: int = Field(default=115200, gt=0)
|
||||||
|
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||||
|
|
||||||
|
#: ALSA output device passed to VLC, e.g. ``"hw:0,0"`` or ``"default"``, or
|
||||||
|
#: ``"simulate"`` for a player that makes no sound. Required.
|
||||||
|
alsa_device: str
|
||||||
|
|
||||||
|
mqtt: MqttConfig | None = None
|
||||||
|
web: WebConfig | None = None
|
||||||
|
ha: HaConfig | None = None
|
||||||
|
lirc: LircConfig | None = None
|
||||||
|
tippen: TippenConfig | None = None
|
||||||
|
|
||||||
|
min_volume: int = Field(default=0, ge=0, le=200)
|
||||||
|
max_volume: int = Field(default=100, ge=0, le=200)
|
||||||
|
initial_volume: int = Field(default=50, ge=0, le=200)
|
||||||
|
volume_increment: int = Field(default=5, ge=1, le=100)
|
||||||
|
button_leds_brightness: float = Field(default=0.5, ge=0, le=1)
|
||||||
|
|
||||||
|
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||||
|
|
||||||
|
#: How many episodes of each podcast show to keep, newest first. A show that has
|
||||||
|
#: published for years grows without bound and will eventually fill the device's SD
|
||||||
|
#: card. ``null`` keeps every episode, and minding the free space is then on you.
|
||||||
|
#:
|
||||||
|
#: Lowering this *deletes* the episodes that fall outside the window on the next
|
||||||
|
#: poll, and an episode that has aged out of its feed cannot be fetched again.
|
||||||
|
podcast_episode_limit: int | None = Field(default=DEFAULT_EPISODE_LIMIT, ge=1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serial_simulated(self) -> bool:
|
||||||
|
return self.serial_port == SIMULATE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio_simulated(self) -> bool:
|
||||||
|
return self.alsa_device == SIMULATE
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_volumes(self) -> Self:
|
||||||
|
if self.min_volume > self.max_volume:
|
||||||
|
raise ValueError(
|
||||||
|
f"min_volume ({self.min_volume}) must not exceed max_volume ({self.max_volume})"
|
||||||
|
)
|
||||||
|
if not self.min_volume <= self.initial_volume <= self.max_volume:
|
||||||
|
raise ValueError(
|
||||||
|
f"initial_volume ({self.initial_volume}) must lie between "
|
||||||
|
f"min_volume ({self.min_volume}) and max_volume ({self.max_volume})"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class FigureConfig(_Strict):
|
||||||
|
#: RFID tag id as hex, e.g. "04a1b2c3d4".
|
||||||
|
id: TagId
|
||||||
|
colors: FigureColors
|
||||||
|
#: What this figure holds. Unlike the other shelves a figure folder is named after
|
||||||
|
#: the figurine rather than its contents, so nothing on disk says whether it is an
|
||||||
|
#: album or an audiobook - and the browse view draws the two differently.
|
||||||
|
kind: Literal["music", "book"] = "music"
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteSlotConfig(_Strict):
|
||||||
|
"""What a number key on the IR remote plays.
|
||||||
|
|
||||||
|
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
|
||||||
|
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
|
||||||
|
``Album.series``); resolved to that show's newest episode fresh on every press,
|
||||||
|
since a podcast show is not itself one playable thing in this library - each
|
||||||
|
episode is its own album.
|
||||||
|
"""
|
||||||
|
|
||||||
|
target_kind: Literal["album", "series"]
|
||||||
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
class Config(_Strict):
|
||||||
|
general: GeneralConfig
|
||||||
|
figures: dict[str, FigureConfig] = Field(min_length=1)
|
||||||
|
#: Number key (0-9) -> what it plays. Empty by default: a fresh install has no
|
||||||
|
#: assignments, and that is not an error.
|
||||||
|
remote: dict[Digit, RemoteSlotConfig] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_unique_tag_ids(self) -> Self:
|
||||||
|
seen: dict[bytes, str] = {}
|
||||||
|
for name, figure in self.figures.items():
|
||||||
|
if (other := seen.get(figure.id)) is not None:
|
||||||
|
raise ValueError(
|
||||||
|
f"figures {other!r} and {name!r} both use tag id {figure.id.hex()}"
|
||||||
|
)
|
||||||
|
seen[figure.id] = name
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tag_map(self) -> dict[bytes, str]:
|
||||||
|
"""Tag id -> figure name, as handed to the device."""
|
||||||
|
return {figure.id: name for name, figure in self.figures.items()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def figure_kinds(self) -> dict[str, Literal["music", "book"]]:
|
||||||
|
"""Figure name -> what it holds, as handed to the library scanner."""
|
||||||
|
return {name: figure.kind for name, figure in self.figures.items()}
|
||||||
|
|
||||||
|
def folder_for(self, figure: str) -> Path:
|
||||||
|
return self.general.library.figure_folder / figure
|
||||||
|
|
||||||
|
|
||||||
|
def format_validation_error(error: ValidationError) -> str:
|
||||||
|
"""Render a pydantic error as one short ``path: message`` line per problem."""
|
||||||
|
lines: list[str] = []
|
||||||
|
for entry in error.errors():
|
||||||
|
location = ".".join(
|
||||||
|
f"[{part}]" if isinstance(part, int) else str(part) for part in entry["loc"]
|
||||||
|
).replace(".[", "[")
|
||||||
|
message = entry["msg"]
|
||||||
|
for prefix in ("Value error, ", "Assertion failed, "):
|
||||||
|
message = message.removeprefix(prefix)
|
||||||
|
if entry["type"] == "extra_forbidden":
|
||||||
|
message = "unknown option (check the spelling against config.yml.example)"
|
||||||
|
elif entry["type"] == "missing":
|
||||||
|
message = "required (see config.yml.example)"
|
||||||
|
lines.append(f" {location or '<root>'}: {message}")
|
||||||
|
plural = "s" if len(lines) != 1 else ""
|
||||||
|
return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path, *, check_paths: bool = True) -> Config:
|
||||||
|
"""Load and validate a config file.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigError: with a message that can be printed straight to the terminal.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
if path.is_dir():
|
||||||
|
raise ConfigError(
|
||||||
|
f"{path} is a directory. Pass the config file itself, e.g. {path / 'config.yml'}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigError(f"Cannot read config file {path}: {exc.strerror}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = YAML(typ="safe").load(text)
|
||||||
|
except YAMLError as exc:
|
||||||
|
raise ConfigError(f"{path} is not valid YAML:\n {exc}") from exc
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
raise ConfigError(f"{path} is empty")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ConfigError(
|
||||||
|
f"{path} must contain a mapping at the top level, got {type(data).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {"config_dir": path.parent, "check_paths": check_paths}
|
||||||
|
try:
|
||||||
|
config = Config.model_validate(data, context=context)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ConfigError(f"{path}\n{format_validation_error(exc)}") from exc
|
||||||
|
|
||||||
|
if check_paths:
|
||||||
|
for name in config.figures:
|
||||||
|
folder = config.folder_for(name)
|
||||||
|
if not folder.is_dir():
|
||||||
|
_log.warning("Figure %r has no media folder at %s", name, folder)
|
||||||
|
return config
|
||||||
1
python-backend/musicmouse/devices/__init__.py
Normal file
1
python-backend/musicmouse/devices/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Objects that own a piece of hardware: the mouse itself and the audio player."""
|
||||||
177
python-backend/musicmouse/devices/mouse.py
Normal file
177
python-backend/musicmouse/devices/mouse.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""The mouse itself: the object that talks to the firmware.
|
||||||
|
|
||||||
|
It owns the physical state - which figure is on the reader, how bright the button
|
||||||
|
backlights are, which effect each LED zone is showing - and it is the single writer to
|
||||||
|
all three LED zones. "Last write wins" is therefore a well-defined rule: whoever sets a
|
||||||
|
zone last, whether a figure animation or an MQTT command, is what the strip shows.
|
||||||
|
|
||||||
|
Every write emits :class:`~musicmouse.events.LedEffectChanged`, so front-ends can
|
||||||
|
publish the strip's real state instead of echoing their own commands back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.devices.transport import Transport
|
||||||
|
from musicmouse.devices.wire import (
|
||||||
|
FirmwareLog,
|
||||||
|
FrameDecoder,
|
||||||
|
ProtocolError,
|
||||||
|
UnsupportedEffectError,
|
||||||
|
encode_button_brightness,
|
||||||
|
encode_effect,
|
||||||
|
)
|
||||||
|
from musicmouse.effects import OFF, LedEffect
|
||||||
|
from musicmouse.events import (
|
||||||
|
ActiveFigureChanged,
|
||||||
|
ConnectionChanged,
|
||||||
|
DeviceConnected,
|
||||||
|
DeviceDisconnected,
|
||||||
|
EventSource,
|
||||||
|
InputEvent,
|
||||||
|
LedEffectChanged,
|
||||||
|
RfidTokenRead,
|
||||||
|
)
|
||||||
|
from musicmouse.hardware import NO_FIGURE_TAG, Button, LedZone
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["MusicMouseDevice"]
|
||||||
|
|
||||||
|
_BACKLIT_BUTTONS = (Button.LEFT, Button.RIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
class MusicMouseDevice:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bus: EventBus,
|
||||||
|
transport: Transport,
|
||||||
|
tag_map: dict[bytes, str],
|
||||||
|
*,
|
||||||
|
port: str = "",
|
||||||
|
) -> None:
|
||||||
|
self._bus = bus
|
||||||
|
self._transport = transport
|
||||||
|
self._tag_map = dict(tag_map)
|
||||||
|
self._decoder = FrameDecoder()
|
||||||
|
self.port = port
|
||||||
|
|
||||||
|
self._active_figure: str | None = None
|
||||||
|
self._button_brightness: float = 0.0
|
||||||
|
self._effects: dict[LedZone, LedEffect] = {}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_figure(self) -> str | None:
|
||||||
|
"""The figure currently on the reader, or ``None`` if there is none."""
|
||||||
|
return self._active_figure
|
||||||
|
|
||||||
|
@property
|
||||||
|
def button_led_brightness(self) -> float:
|
||||||
|
return self._button_brightness
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._transport.connected
|
||||||
|
|
||||||
|
def effect(self, zone: LedZone) -> LedEffect | None:
|
||||||
|
return self._effects.get(zone)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
|
def set_effect(
|
||||||
|
self, zone: LedZone, effect: LedEffect, *, origin: EventSource = "system"
|
||||||
|
) -> None:
|
||||||
|
"""Show ``effect`` on ``zone``. The most recent call wins."""
|
||||||
|
try:
|
||||||
|
frame = encode_effect(zone, effect)
|
||||||
|
except UnsupportedEffectError as exc:
|
||||||
|
_log.error("%s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._effects[zone] = effect
|
||||||
|
self._transport.write(frame)
|
||||||
|
self._bus.emit(
|
||||||
|
LedEffectChanged(zone=zone, effect=effect, origin=origin, source="device")
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_button_brightness(self, brightness: float, *, origin: EventSource = "system") -> None:
|
||||||
|
"""Set both prev/next button backlights (``0..1``)."""
|
||||||
|
brightness = min(1.0, max(0.0, brightness))
|
||||||
|
self._button_brightness = brightness
|
||||||
|
for button in _BACKLIT_BUTTONS:
|
||||||
|
self._transport.write(encode_button_brightness(button, brightness))
|
||||||
|
_log.debug("Button backlights -> %.2f (%s)", brightness, origin)
|
||||||
|
|
||||||
|
def all_leds_off(self, *, origin: EventSource = "system") -> None:
|
||||||
|
for zone in LedZone:
|
||||||
|
self.set_effect(zone, OFF(), origin=origin)
|
||||||
|
self.set_button_brightness(0.0, origin=origin)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- link callbacks
|
||||||
|
|
||||||
|
def on_connected(self) -> None:
|
||||||
|
"""Re-apply memorized state, so a reconnect is invisible from the outside."""
|
||||||
|
self._bus.emit(DeviceConnected(port=self.port, source="device"))
|
||||||
|
self._bus.emit(ConnectionChanged(target="firmware", connected=True, source="device"))
|
||||||
|
|
||||||
|
for zone, effect in self._effects.items():
|
||||||
|
self._transport.write(encode_effect(zone, effect))
|
||||||
|
for button in _BACKLIT_BUTTONS:
|
||||||
|
self._transport.write(encode_button_brightness(button, self._button_brightness))
|
||||||
|
if self._effects:
|
||||||
|
_log.info("Restored %d LED zone(s) after reconnect", len(self._effects))
|
||||||
|
|
||||||
|
def on_disconnected(self, reason: str = "") -> None:
|
||||||
|
self._bus.emit(DeviceDisconnected(port=self.port, reason=reason or None, source="device"))
|
||||||
|
self._bus.emit(ConnectionChanged(target="firmware", connected=False, source="device"))
|
||||||
|
|
||||||
|
def feed(self, data: bytes) -> None:
|
||||||
|
"""Hand bytes from the link to the decoder and publish what comes out."""
|
||||||
|
self._decoder.push(data)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
item = self._decoder.take()
|
||||||
|
except ProtocolError as exc:
|
||||||
|
_log.warning("Discarding bad frame from firmware: %s", exc)
|
||||||
|
continue
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(item, FirmwareLog):
|
||||||
|
if item.text:
|
||||||
|
_log.info("[firmware] %s", item.text)
|
||||||
|
else:
|
||||||
|
self._publish(item)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- internals
|
||||||
|
|
||||||
|
def _publish(self, event: InputEvent) -> None:
|
||||||
|
if isinstance(event, RfidTokenRead):
|
||||||
|
self._publish_tag_read(event)
|
||||||
|
else:
|
||||||
|
self._bus.emit(event)
|
||||||
|
|
||||||
|
def _publish_tag_read(self, event: RfidTokenRead) -> None:
|
||||||
|
if event.tag_id == NO_FIGURE_TAG:
|
||||||
|
figure, known = None, True
|
||||||
|
elif (name := self._tag_map.get(event.tag_id)) is not None:
|
||||||
|
figure, known = name, True
|
||||||
|
else:
|
||||||
|
figure, known = None, False
|
||||||
|
_log.warning("Unknown RFID tag %s - not configured as a figure", event.tag_id.hex())
|
||||||
|
|
||||||
|
self._bus.emit(replace(event, figure=figure, known=known))
|
||||||
|
if not known:
|
||||||
|
# Leave the active figure alone: an unreadable tag is not a removal.
|
||||||
|
return
|
||||||
|
|
||||||
|
previous, self._active_figure = self._active_figure, figure
|
||||||
|
if previous != figure:
|
||||||
|
self._bus.emit(
|
||||||
|
ActiveFigureChanged(figure=figure, previous=previous, source="device")
|
||||||
|
)
|
||||||
22
python-backend/musicmouse/devices/null_transport.py
Normal file
22
python-backend/musicmouse/devices/null_transport.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""A transport with nothing on the other end.
|
||||||
|
|
||||||
|
The web front-end is a complete way to drive the mouse, so the backend has to be
|
||||||
|
useful on a machine that has no mouse attached - a spare Pi, a laptop, a container.
|
||||||
|
Rather than making :class:`~musicmouse.devices.mouse.MusicMouseDevice` optional and
|
||||||
|
teaching every reaction to cope with its absence, the device is built as usual against
|
||||||
|
a transport that drops what it is handed. Lighting reactions still run; their bytes go
|
||||||
|
nowhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = ["NullTransport"]
|
||||||
|
|
||||||
|
|
||||||
|
class NullTransport:
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return False
|
||||||
365
python-backend/musicmouse/devices/player.py
Normal file
365
python-backend/musicmouse/devices/player.py
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
"""Audio playback, behind a protocol.
|
||||||
|
|
||||||
|
:class:`VlcPlayer` is the only real implementation; the simulator supplies another.
|
||||||
|
libVLC fires its callbacks on its own thread, so every one of them goes through
|
||||||
|
``bus.emit()``, which hops back onto the event loop. The old code called straight into
|
||||||
|
the serial transport from that thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any, Protocol
|
||||||
|
|
||||||
|
from musicmouse.bus import EventBus
|
||||||
|
from musicmouse.clock import Clock, RealClock
|
||||||
|
from musicmouse.events import (
|
||||||
|
EventSource,
|
||||||
|
PlaybackChanged,
|
||||||
|
PlaylistFinished,
|
||||||
|
TrackChanged,
|
||||||
|
VolumeChanged,
|
||||||
|
)
|
||||||
|
from musicmouse.media import Playlist, Track
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from musicmouse.config import GeneralConfig
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["Player", "PlayerBase", "VlcPlayer"]
|
||||||
|
|
||||||
|
|
||||||
|
class Player(Protocol):
|
||||||
|
"""What reactions and front-ends are allowed to do with the audio player."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_playing(self) -> bool: ...
|
||||||
|
@property
|
||||||
|
def volume(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def playlist(self) -> Playlist | None: ...
|
||||||
|
@property
|
||||||
|
def track_index(self) -> int: ...
|
||||||
|
@property
|
||||||
|
def current_track(self) -> Track | None: ...
|
||||||
|
@property
|
||||||
|
def position(self) -> float:
|
||||||
|
"""Seconds into the current track. ``0.0`` when nothing is loaded.
|
||||||
|
|
||||||
|
Read on demand rather than announced: a progress bar wants this twice a second,
|
||||||
|
and an event at that rate would flood the bus, the MQTT service and the log for
|
||||||
|
the benefit of one front-end.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self) -> float:
|
||||||
|
"""Length of the current track in seconds, or ``0.0`` when unknown."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_playlist(self, playlist: Playlist) -> None: ...
|
||||||
|
def play(self) -> None: ...
|
||||||
|
def play_from_start(self) -> None: ...
|
||||||
|
def play_track(self, index: int) -> None: ...
|
||||||
|
def pause(self) -> None: ...
|
||||||
|
def stop(self) -> None: ...
|
||||||
|
def next_track(self) -> None: ...
|
||||||
|
def previous_track(self) -> None: ...
|
||||||
|
def seek(self, position: float) -> None: ...
|
||||||
|
def set_volume(self, volume: int, *, source: EventSource = "system") -> None: ...
|
||||||
|
def change_volume(self, delta: int, *, source: EventSource = "system") -> None: ...
|
||||||
|
def set_volume_limits(self, minimum: int, maximum: int) -> None: ...
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Long-running task, if the implementation needs one."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def close(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerBase:
|
||||||
|
"""Volume clamping, playlist bookkeeping and state events, shared by the
|
||||||
|
real and the simulated player."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bus: EventBus,
|
||||||
|
*,
|
||||||
|
min_volume: int = 0,
|
||||||
|
max_volume: int = 100,
|
||||||
|
initial_volume: int = 50,
|
||||||
|
) -> None:
|
||||||
|
self._bus = bus
|
||||||
|
self._min_volume = min_volume
|
||||||
|
self._max_volume = max_volume
|
||||||
|
self._volume = self._clamp(initial_volume)
|
||||||
|
self._playlist: Playlist | None = None
|
||||||
|
self._index = 0
|
||||||
|
self._playing = False
|
||||||
|
self._playlist_changed = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def volume_kwargs(cls, config: GeneralConfig) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"min_volume": config.min_volume,
|
||||||
|
"max_volume": config.max_volume,
|
||||||
|
"initial_volume": config.initial_volume,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
return self._playing
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume(self) -> int:
|
||||||
|
return self._volume
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playlist(self) -> Playlist | None:
|
||||||
|
return self._playlist
|
||||||
|
|
||||||
|
@property
|
||||||
|
def track_index(self) -> int:
|
||||||
|
return self._index
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_track(self) -> Track | None:
|
||||||
|
if self._playlist is None or not 0 <= self._index < len(self._playlist):
|
||||||
|
return None
|
||||||
|
return self._playlist[self._index]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
|
def set_volume(self, volume: int, *, source: EventSource = "system") -> None:
|
||||||
|
clamped = self._clamp(volume)
|
||||||
|
if clamped == self._volume:
|
||||||
|
return
|
||||||
|
self._volume = clamped
|
||||||
|
self._apply_volume(clamped)
|
||||||
|
self._announce_volume(source)
|
||||||
|
|
||||||
|
def change_volume(self, delta: int, *, source: EventSource = "system") -> None:
|
||||||
|
self.set_volume(self._volume + delta, source=source)
|
||||||
|
|
||||||
|
def set_volume_limits(self, minimum: int, maximum: int) -> None:
|
||||||
|
"""Re-clamp to a new allowed range, and pull the current volume into it.
|
||||||
|
|
||||||
|
Parent mode edits these while the mouse is playing, so they cannot only be
|
||||||
|
constructor arguments.
|
||||||
|
"""
|
||||||
|
self._min_volume = minimum
|
||||||
|
self._max_volume = maximum
|
||||||
|
self.set_volume(self._volume)
|
||||||
|
|
||||||
|
def _apply_volume(self, volume: int) -> None:
|
||||||
|
"""Push the new volume at whatever actually makes sound. No-op by default."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- internals
|
||||||
|
|
||||||
|
def _clamp(self, volume: int) -> int:
|
||||||
|
# `if self._min_volume and ...` in the old code silently ignored min_volume: 0,
|
||||||
|
# which is what config.yml.example shipped with.
|
||||||
|
return max(self._min_volume, min(self._max_volume, volume))
|
||||||
|
|
||||||
|
def _set_playing(self, playing: bool, *, figure: str | None = None) -> None:
|
||||||
|
if playing == self._playing:
|
||||||
|
return
|
||||||
|
self._playing = playing
|
||||||
|
self._playlist_changed = False
|
||||||
|
self._bus.emit(
|
||||||
|
PlaybackChanged(
|
||||||
|
playing=playing, figure=figure, playlist=self._playlist, source="player"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_index(self, index: int) -> None:
|
||||||
|
# A playlist swap while already playing resets the index to 0 without going
|
||||||
|
# through here (see `_load_playlist`), so the following `play_track(0)` looks
|
||||||
|
# like a no-op index change. Force it through in that case - `_set_playing`
|
||||||
|
# will not itself announce anything, since it was already playing before and
|
||||||
|
# after. Starting from idle needs no such push: `_set_playing`'s own True
|
||||||
|
# transition already covers the broadcast.
|
||||||
|
force = self._playlist_changed and self._playing
|
||||||
|
if index == self._index and not force:
|
||||||
|
return
|
||||||
|
self._playlist_changed = False
|
||||||
|
self._index = index
|
||||||
|
self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player"))
|
||||||
|
|
||||||
|
def _load_playlist(self, playlist: Playlist) -> None:
|
||||||
|
"""Bookkeeping shared by every player's ``set_playlist``."""
|
||||||
|
self._playlist = playlist
|
||||||
|
self._index = 0
|
||||||
|
self._playlist_changed = True
|
||||||
|
|
||||||
|
def _announce_volume(self, source: EventSource = "player") -> None:
|
||||||
|
self._bus.emit(VolumeChanged(volume=self._volume, source=source))
|
||||||
|
|
||||||
|
def _announce_playlist_finished(self) -> None:
|
||||||
|
self._bus.emit(PlaylistFinished(source="player"))
|
||||||
|
|
||||||
|
async def run(self) -> None: # pragma: no cover - overridden where needed
|
||||||
|
return
|
||||||
|
|
||||||
|
def close(self) -> None: # pragma: no cover - overridden where needed
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class VlcPlayer(PlayerBase):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bus: EventBus,
|
||||||
|
*,
|
||||||
|
alsa_device: str | None = None,
|
||||||
|
min_volume: int = 0,
|
||||||
|
max_volume: int = 100,
|
||||||
|
initial_volume: int = 50,
|
||||||
|
poll_interval: float = 1.0,
|
||||||
|
clock: Clock | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
bus, min_volume=min_volume, max_volume=max_volume, initial_volume=initial_volume
|
||||||
|
)
|
||||||
|
# Imported here rather than at module scope: python-vlc loads libvlc eagerly,
|
||||||
|
# and the simulator must run on machines without it.
|
||||||
|
import vlc
|
||||||
|
|
||||||
|
self._vlc = vlc
|
||||||
|
self._poll_interval = poll_interval
|
||||||
|
self._clock = clock or RealClock()
|
||||||
|
|
||||||
|
args = ["-A", "alsa", "--alsa-audio-device", alsa_device] if alsa_device else []
|
||||||
|
self._instance = vlc.Instance(*args)
|
||||||
|
self._list_player = self._instance.media_list_player_new()
|
||||||
|
self._media_player = self._list_player.get_media_player()
|
||||||
|
self._mrl_to_index: dict[str, int] = {}
|
||||||
|
|
||||||
|
self._attach_events()
|
||||||
|
self._media_player.audio_set_volume(self._volume)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def position(self) -> float:
|
||||||
|
# libVLC reports -1 for both of these until a media is actually opened.
|
||||||
|
return max(0.0, float(self._media_player.get_time()) / 1000)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self) -> float:
|
||||||
|
return max(0.0, float(self._media_player.get_length()) / 1000)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ actions
|
||||||
|
|
||||||
|
def set_playlist(self, playlist: Playlist) -> None:
|
||||||
|
media_list = self._vlc.MediaList()
|
||||||
|
self._mrl_to_index.clear()
|
||||||
|
for index, track in enumerate(playlist.tracks):
|
||||||
|
media = self._instance.media_new(str(track.path))
|
||||||
|
media_list.add_media(media)
|
||||||
|
self._mrl_to_index[media.get_mrl()] = index
|
||||||
|
|
||||||
|
self._list_player.set_media_list(media_list)
|
||||||
|
self._list_player.set_playback_mode(self._vlc.PlaybackMode.default)
|
||||||
|
self._load_playlist(playlist)
|
||||||
|
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
|
||||||
|
|
||||||
|
def play(self) -> None:
|
||||||
|
if self._playing:
|
||||||
|
return
|
||||||
|
self._list_player.play()
|
||||||
|
|
||||||
|
def play_from_start(self) -> None:
|
||||||
|
self.play_track(0)
|
||||||
|
|
||||||
|
def play_track(self, index: int) -> None:
|
||||||
|
if self._playlist is None or not self._playlist:
|
||||||
|
_log.warning("Nothing to play: the playlist is empty")
|
||||||
|
return
|
||||||
|
self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1)))
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
if not self._playing:
|
||||||
|
return
|
||||||
|
self._media_player.set_pause(1)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._list_player.stop()
|
||||||
|
|
||||||
|
def next_track(self) -> None:
|
||||||
|
self._list_player.next()
|
||||||
|
|
||||||
|
def previous_track(self) -> None:
|
||||||
|
self._list_player.previous()
|
||||||
|
|
||||||
|
def seek(self, position: float) -> None:
|
||||||
|
self._media_player.set_time(int(max(0.0, position) * 1000))
|
||||||
|
|
||||||
|
def _apply_volume(self, volume: int) -> None:
|
||||||
|
self._media_player.audio_set_volume(volume)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Poll for state libVLC does not reliably report by event."""
|
||||||
|
while True:
|
||||||
|
await self._clock.sleep(self._poll_interval)
|
||||||
|
try:
|
||||||
|
self._poll()
|
||||||
|
except Exception: # pragma: no cover - defensive around a C library
|
||||||
|
_log.exception("VLC poll failed")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._list_player.stop()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- internals
|
||||||
|
|
||||||
|
def _poll(self) -> None:
|
||||||
|
volume = self._media_player.audio_get_volume()
|
||||||
|
if volume >= 0 and volume != self._volume:
|
||||||
|
self._volume = volume
|
||||||
|
self._announce_volume()
|
||||||
|
self._set_playing(bool(self._list_player.is_playing()))
|
||||||
|
self._sync_index()
|
||||||
|
|
||||||
|
def _sync_index(self) -> None:
|
||||||
|
"""Read which track is playing back off libVLC.
|
||||||
|
|
||||||
|
Asking the media player which media it holds works on every build; the
|
||||||
|
``MediaListPlayerNextItemSet`` payload does not - on some it arrives as a bare
|
||||||
|
int rather than a Media, and the index would then never move off zero.
|
||||||
|
"""
|
||||||
|
media = self._media_player.get_media()
|
||||||
|
if media is None:
|
||||||
|
return
|
||||||
|
index = self._mrl_to_index.get(media.get_mrl())
|
||||||
|
if index is not None:
|
||||||
|
self._set_index(index)
|
||||||
|
|
||||||
|
def _attach_events(self) -> None:
|
||||||
|
vlc = self._vlc
|
||||||
|
player_events = self._media_player.event_manager()
|
||||||
|
player_events.event_attach(vlc.EventType.MediaPlayerPlaying, self._on_playing)
|
||||||
|
player_events.event_attach(vlc.EventType.MediaPlayerPaused, self._on_stopped)
|
||||||
|
player_events.event_attach(vlc.EventType.MediaPlayerStopped, self._on_stopped)
|
||||||
|
|
||||||
|
list_events = self._list_player.event_manager()
|
||||||
|
list_events.event_attach(vlc.EventType.MediaListPlayerPlayed, self._on_playlist_end)
|
||||||
|
list_events.event_attach(vlc.EventType.MediaListPlayerNextItemSet, self._on_next_item)
|
||||||
|
|
||||||
|
# These four run on a libVLC thread. bus.emit() is the thread hop; nothing else
|
||||||
|
# here may touch the loop.
|
||||||
|
|
||||||
|
def _on_playing(self, _event: Any) -> None:
|
||||||
|
self._set_playing(True)
|
||||||
|
|
||||||
|
def _on_stopped(self, _event: Any) -> None:
|
||||||
|
self._set_playing(False)
|
||||||
|
|
||||||
|
def _on_playlist_end(self, _event: Any) -> None:
|
||||||
|
self._set_playing(False)
|
||||||
|
self._announce_playlist_finished()
|
||||||
|
|
||||||
|
def _on_next_item(self, _event: Any) -> None:
|
||||||
|
# The event says *when* to look; what it carries is not portable, so ignore it.
|
||||||
|
self._sync_index()
|
||||||
122
python-backend/musicmouse/devices/serial_link.py
Normal file
122
python-backend/musicmouse/devices/serial_link.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Serial transport with reconnect.
|
||||||
|
|
||||||
|
The old ``host_driver.py`` stopped the event loop when the USB cable was pulled and
|
||||||
|
relied on systemd to restart the whole process. Here a dropped link is just a
|
||||||
|
reconnect loop, and the device re-applies its LED state when it comes back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import serial_asyncio
|
||||||
|
|
||||||
|
from musicmouse.clock import Clock, RealClock
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["SerialLink"]
|
||||||
|
|
||||||
|
_READ_CHUNK = 1024
|
||||||
|
|
||||||
|
|
||||||
|
class SerialLink:
|
||||||
|
"""Owns the serial port and keeps trying to hold it open."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
port: str,
|
||||||
|
baudrate: int = 115200,
|
||||||
|
*,
|
||||||
|
reconnect_interval: float = 5.0,
|
||||||
|
clock: Clock | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self._reconnect_interval = reconnect_interval
|
||||||
|
self._clock = clock or RealClock()
|
||||||
|
self._writer: asyncio.StreamWriter | None = None
|
||||||
|
|
||||||
|
self._on_data: Callable[[bytes], None] = lambda _data: None
|
||||||
|
self._on_connect: Callable[[], None] | None = None
|
||||||
|
self._on_disconnect: Callable[[str], None] | None = None
|
||||||
|
|
||||||
|
def attach(
|
||||||
|
self,
|
||||||
|
on_data: Callable[[bytes], None],
|
||||||
|
*,
|
||||||
|
on_connect: Callable[[], None] | None = None,
|
||||||
|
on_disconnect: Callable[[str], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Wire up the device. Separate from ``__init__`` because the device needs the
|
||||||
|
link as its transport, so one of the two has to be built first."""
|
||||||
|
self._on_data = on_data
|
||||||
|
self._on_connect = on_connect
|
||||||
|
self._on_disconnect = on_disconnect
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._writer is not None
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
writer = self._writer
|
||||||
|
if writer is None:
|
||||||
|
_log.debug("Dropping %d bytes: %s is not connected", len(data), self.port)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
writer.write(data)
|
||||||
|
except OSError as exc: # pragma: no cover - needs a real port dying mid-write
|
||||||
|
_log.warning("Write to %s failed: %s", self.port, exc)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Connect, read until the link drops, wait, repeat. Runs until cancelled."""
|
||||||
|
while True:
|
||||||
|
reader = await self._connect()
|
||||||
|
if reader is None:
|
||||||
|
await self._clock.sleep(self._reconnect_interval)
|
||||||
|
continue
|
||||||
|
|
||||||
|
reason = await self._pump(reader)
|
||||||
|
|
||||||
|
self._writer = None
|
||||||
|
_log.warning("Lost connection to %s: %s", self.port, reason)
|
||||||
|
if self._on_disconnect is not None:
|
||||||
|
self._on_disconnect(reason)
|
||||||
|
await self._clock.sleep(self._reconnect_interval)
|
||||||
|
|
||||||
|
async def _connect(self) -> asyncio.StreamReader | None:
|
||||||
|
reader: asyncio.StreamReader
|
||||||
|
writer: asyncio.StreamWriter
|
||||||
|
try:
|
||||||
|
reader, writer = await serial_asyncio.open_serial_connection(
|
||||||
|
url=self.port, baudrate=self.baudrate
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
_log.warning(
|
||||||
|
"Cannot open %s (%s); retrying in %gs",
|
||||||
|
self.port,
|
||||||
|
exc,
|
||||||
|
self._reconnect_interval,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._writer = writer
|
||||||
|
_log.info("Connected to firmware on %s at %d baud", self.port, self.baudrate)
|
||||||
|
if self._on_connect is not None:
|
||||||
|
self._on_connect()
|
||||||
|
return reader
|
||||||
|
|
||||||
|
async def _pump(self, reader: asyncio.StreamReader) -> str:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await reader.read(_READ_CHUNK)
|
||||||
|
if not data:
|
||||||
|
return "port closed"
|
||||||
|
self._on_data(data)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self._writer = None
|
||||||
|
raise
|
||||||
|
except (OSError, asyncio.IncompleteReadError) as exc:
|
||||||
|
return str(exc)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user