diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index fc8877d..464afbe 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -127,6 +127,22 @@ def _dither_value(value: Any) -> DitherMode: return _str_to_int_enum(DitherMode)(value) +def _valid_melody(value: str) -> str: + """Validate a compact melody string via the py-opendisplay parser. + + Parses ``value`` with default tempo/duration settings purely to check token + syntax, converting the parser's ``ValueError`` into ``vol.Invalid`` (carrying + the offending token's position and text). Tempo-dependent duration overflow + cannot be caught here because ``tempo`` is a sibling field; the handler + re-parses with the real values and re-raises as ``invalid_melody``. + """ + try: + BuzzerActivateConfig.melody(value) + except ValueError as err: + raise vol.Invalid(str(err)) from err + return value + + def _refresh_type_value(value: Any) -> RefreshMode: """Accept names ("full"/"fast"/"partial") and legacy numeric values. @@ -249,6 +265,18 @@ def _led_step_fields(n: int, *, color_default: list[int], flash_count_default: i } ) +SCHEMA_PLAY_MELODY = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Optional("instance", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=3)), + vol.Required("notes"): vol.All(cv.string, _valid_melody), + vol.Optional("tempo", default=120): vol.All(vol.Coerce(int), vol.Range(min=40, max=400)), + vol.Optional("repeats", default=1): vol.All(vol.Coerce(int), vol.Range(min=1, max=255)), + vol.Optional("default_note_ms", default=200): vol.All(vol.Coerce(int), vol.Range(min=5, max=1275)), + vol.Optional("default_length"): vol.All(vol.Coerce(int), vol.In([1, 2, 4, 8, 16, 32])), + } +) + def _get_entry_for_device(call: ServiceCall) -> OpenDisplayConfigEntry: """Return the config entry for the device targeted by a service call.""" @@ -1108,6 +1136,38 @@ async def _nfc(device: OpenDisplayDevice) -> None: await _async_connect_and_run(call.hass, entry, _nfc) +async def _async_play_melody(call: ServiceCall) -> None: + """Handle the play_melody service call.""" + entry = _get_entry_for_device(call) + if not entry.runtime_data.device_config.buzzers: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_buzzers", + translation_placeholders={"device_id": call.data[ATTR_DEVICE_ID]}, + ) + _raise_if_sleeping(entry, call.data[ATTR_DEVICE_ID]) + try: + buzz_config = BuzzerActivateConfig.melody( + call.data["notes"], + tempo=call.data["tempo"], + repeats=call.data["repeats"], + default_ms=call.data["default_note_ms"], + default_length=call.data.get("default_length"), + ) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_melody", + translation_placeholders={"error": str(err)}, + ) from err + instance: int = call.data["instance"] + + async def _buzz(device: OpenDisplayDevice) -> None: + await device.activate_buzzer(instance, buzz_config) + + await _async_connect_and_run(call.hass, entry, _buzz) + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Register OpenDisplay services.""" @@ -1127,4 +1187,5 @@ def async_setup_services(hass: HomeAssistant) -> None: ) hass.services.async_register(DOMAIN, "activate_led", _async_activate_led, schema=SCHEMA_ACTIVATE_LED) hass.services.async_register(DOMAIN, "activate_buzzer", _async_activate_buzzer, schema=SCHEMA_ACTIVATE_BUZZER) - hass.services.async_register(DOMAIN, "write_nfc", _async_write_nfc, schema=SCHEMA_WRITE_NFC) \ No newline at end of file + hass.services.async_register(DOMAIN, "write_nfc", _async_write_nfc, schema=SCHEMA_WRITE_NFC) + hass.services.async_register(DOMAIN, "play_melody", _async_play_melody, schema=SCHEMA_PLAY_MELODY) diff --git a/custom_components/opendisplay/services.yaml b/custom_components/opendisplay/services.yaml index cbd1b50..72b36f3 100644 --- a/custom_components/opendisplay/services.yaml +++ b/custom_components/opendisplay/services.yaml @@ -277,6 +277,64 @@ write_nfc: selector: text: +play_melody: + fields: + device_id: + required: true + selector: + device: + integration: opendisplay + instance: + required: false + default: 0 + selector: + number: + min: 0 + max: 3 + mode: box + notes: + required: true + example: "C5 C5 G5 G5 A5 A5 G5/2" + selector: + text: + tempo: + required: false + default: 120 + selector: + number: + min: 40 + max: 400 + mode: box + unit_of_measurement: BPM + repeats: + required: false + default: 1 + selector: + number: + min: 1 + max: 255 + mode: box + default_note_ms: + required: false + default: 200 + selector: + number: + min: 5 + max: 1275 + mode: box + unit_of_measurement: ms + default_length: + required: false + selector: + select: + options: + - "1" + - "2" + - "4" + - "8" + - "16" + - "32" + drawcustom: name: Draw Custom Image description: Draws a custom image on one or more OpenDisplay devices diff --git a/custom_components/opendisplay/strings.json b/custom_components/opendisplay/strings.json index ecaac64..2155b9f 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -144,6 +144,9 @@ "invalid_device_id": { "message": "Device `{device_id}` is not a valid OpenDisplay device." }, + "invalid_melody": { + "message": "Invalid melody: {error}" + }, "media_download_error": { "message": "Failed to download media: {error}" }, @@ -348,7 +351,7 @@ }, "activate_buzzer": { "name": "Activate buzzer", - "description": "Triggers a buzzer tone on an OpenDisplay device.", + "description": "Triggers a buzzer tone on an OpenDisplay device. For multi-note melodies, see Play buzzer melody.", "fields": { "device_id": { "name": "Device", @@ -394,6 +397,40 @@ } } }, + "play_melody": { + "name": "Play buzzer melody", + "description": "Plays a multi-note melody on an OpenDisplay device's buzzer.", + "fields": { + "device_id": { + "name": "Device", + "description": "The OpenDisplay device." + }, + "instance": { + "name": "Buzzer instance", + "description": "Buzzer instance index (0-based)." + }, + "notes": { + "name": "Notes", + "description": "Compact melody: whitespace/comma-separated tokens, each `pitch[duration]`. Pitch is a note name (`A4`, `C#5`, flats and quarter-tones like `A4+` allowed), `R` for a rest, or a raw index 0-255. Duration is optional: `:200` for absolute milliseconds, or `/4` (quarter), `/4.` (dotted), `/8t` (triplet) for tempo-relative note fractions; omitted uses the default length or default note duration." + }, + "tempo": { + "name": "Tempo", + "description": "Beats per minute; only affects `/fraction` durations (40-400)." + }, + "repeats": { + "name": "Repeats", + "description": "Number of times to repeat the whole melody (1-255)." + }, + "default_note_ms": { + "name": "Default note duration", + "description": "Duration in milliseconds for notes with no duration marker, when no default length is set (5-1275)." + }, + "default_length": { + "name": "Default length", + "description": "Note fraction (1/2/4/8/16/32) used at the current tempo for notes with no duration marker; overrides the default note duration when set." + } + } + }, "drawcustom": { "description": "Draws a custom image on one or more OpenDisplay devices.", "fields": { diff --git a/custom_components/opendisplay/translations/en.json b/custom_components/opendisplay/translations/en.json index 75280ea..58bafcc 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -141,6 +141,9 @@ "invalid_device_id": { "message": "Device `{device_id}` is not a valid OpenDisplay device." }, + "invalid_melody": { + "message": "Invalid melody: {error}" + }, "media_download_error": { "message": "Failed to download media: {error}" }, @@ -345,7 +348,7 @@ }, "activate_buzzer": { "name": "Activate buzzer", - "description": "Triggers a buzzer tone on an OpenDisplay device.", + "description": "Triggers a buzzer tone on an OpenDisplay device. For multi-note melodies, see Play buzzer melody.", "fields": { "device_id": { "name": "Device", @@ -391,6 +394,40 @@ } } }, + "play_melody": { + "name": "Play buzzer melody", + "description": "Plays a multi-note melody on an OpenDisplay device's buzzer.", + "fields": { + "device_id": { + "name": "Device", + "description": "The OpenDisplay device." + }, + "instance": { + "name": "Buzzer instance", + "description": "Buzzer instance index (0-based)." + }, + "notes": { + "name": "Notes", + "description": "Compact melody: whitespace/comma-separated tokens, each `pitch[duration]`. Pitch is a note name (`A4`, `C#5`, flats and quarter-tones like `A4+` allowed), `R` for a rest, or a raw index 0-255. Duration is optional: `:200` for absolute milliseconds, or `/4` (quarter), `/4.` (dotted), `/8t` (triplet) for tempo-relative note fractions; omitted uses the default length or default note duration." + }, + "tempo": { + "name": "Tempo", + "description": "Beats per minute; only affects `/fraction` durations (40-400)." + }, + "repeats": { + "name": "Repeats", + "description": "Number of times to repeat the whole melody (1-255)." + }, + "default_note_ms": { + "name": "Default note duration", + "description": "Duration in milliseconds for notes with no duration marker, when no default length is set (5-1275)." + }, + "default_length": { + "name": "Default length", + "description": "Note fraction (1/2/4/8/16/32) used at the current tempo for notes with no duration marker; overrides the default note duration when set." + } + } + }, "drawcustom": { "description": "Draws a custom image on one or more OpenDisplay devices.", "fields": { diff --git a/tests/test_services.py b/tests/test_services.py index 2065e97..68600e0 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -14,6 +14,9 @@ from opendisplay import BLEConnectionError, RefreshMode, DitherMode from PIL import Image as PILImage import pytest +import voluptuous as vol + +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from custom_components.opendisplay import services as services_mod from custom_components.opendisplay.const import ( @@ -26,6 +29,8 @@ from custom_components.opendisplay.services import ( PROBE_CONNECT_TIMEOUT_S, PROBE_MAX_ATTEMPTS, + SCHEMA_PLAY_MELODY, + _async_play_melody, _async_send_image, ) from custom_components.opendisplay.sleep import SleepProfile @@ -326,5 +331,175 @@ async def test_live_send_passes_state_and_refresh_mode(): assert call.kwargs["refresh_mode"] is RefreshMode.FULL +# -------------------------------------------------------------------------- +# play_melody service +# +# Same no-HA-harness approach: the handler's touchpoints +# (``_get_entry_for_device``, ``_raise_if_sleeping``, ``_async_connect_and_run``) +# are patched in the services module namespace; the config is byte-compared via +# ``BuzzerActivateConfig.melody(...).to_bytes()`` against wire bytes derived from +# the protocol reference (§7.2/§7.4). +# -------------------------------------------------------------------------- + + +def _melody_env(buzzers=(object(),)): + """Return an (entry, device, call-factory) trio for play_melody tests.""" + entry = MagicMock() + entry.runtime_data.device_config.buzzers = list(buzzers) + device = MagicMock() + device.activate_buzzer = AsyncMock() + + def make_call(**data): + payload = { + "device_id": "dev-1", + "instance": 0, + "notes": "A4:200", + "tempo": 120, + "repeats": 1, + "default_note_ms": 200, + } + payload.update(data) + call = MagicMock() + call.data = payload # a real dict, so __getitem__/.get behave + call.hass = MagicMock() + return call + + return entry, device, make_call + + +def _play_patches(entry, device, sleeping_exc=None): + """Patch the handler's three touchpoints; drive the captured callback.""" + + async def _fake_connect(hass, e, cb): + await cb(device) + + def _fake_sleeping(e, device_id): + if sleeping_exc is not None: + raise sleeping_exc + + return ( + patch.object(services_mod, "_get_entry_for_device", return_value=entry), + patch.object(services_mod, "_raise_if_sleeping", side_effect=_fake_sleeping), + patch.object(services_mod, "_async_connect_and_run", side_effect=_fake_connect), + ) + + +@pytest.mark.asyncio +async def test_play_melody_happy_path(): + """Valid notes reach the device as one activate_buzzer call with wire-exact bytes.""" + entry, device, make_call = _melody_env() + call = make_call(instance=2, notes="A4:200 R:50 144:200") + p1, p2, p3 = _play_patches(entry, device) + with p1, p2, p3: + await _async_play_melody(call) + + device.activate_buzzer.assert_awaited_once() + (instance, config), _ = device.activate_buzzer.await_args + assert instance == 2 + # A4->78/40u, R->00/10u, 144->90/40u; outer_repeats=1, one pattern (§7.2). + assert config.to_bytes() == bytes.fromhex("0101037828000a9028") + + +@pytest.mark.asyncio +async def test_play_melody_default_length_and_tempo(): + """tempo + default_length plumb through: terse Twinkle == explicit-fraction Twinkle.""" + entry, device, make_call = _melody_env() + call = make_call(notes="C5 C5 G5 G5 A5 A5 G5/2", tempo=120, default_length=4) + p1, p2, p3 = _play_patches(entry, device) + with p1, p2, p3: + await _async_play_melody(call) + + (_, config), _ = device.activate_buzzer.await_args + # 120 BPM: quarter=500ms=0x64, half=1000ms=0xC8; C5=126, G5=140, A5=144 (§7.4). + assert config.to_bytes() == bytes.fromhex("0101077e647e648c648c64906490648cc8") + + +@pytest.mark.asyncio +async def test_play_melody_default_note_ms_and_repeats(): + """default_note_ms sets unmarked durations; repeats maps to the wire outer_repeat.""" + entry, device, make_call = _melody_env() + call = make_call(notes="A4", default_note_ms=100, repeats=2) + p1, p2, p3 = _play_patches(entry, device) + with p1, p2, p3: + await _async_play_melody(call) + + (_, config), _ = device.activate_buzzer.await_args + # A4->0x78, 100ms=20 units=0x14, outer_repeats=2. + assert config.to_bytes() == bytes.fromhex("0201017814") + + +@pytest.mark.asyncio +async def test_play_melody_no_buzzers_raises(): + """A device without a buzzer rejects the call before any BLE work.""" + entry, device, make_call = _melody_env(buzzers=()) + call = make_call() + p1, p2, p3 = _play_patches(entry, device) + with p1, p2, p3, pytest.raises(ServiceValidationError) as excinfo: + await _async_play_melody(call) + assert excinfo.value.translation_key == "no_buzzers" + device.activate_buzzer.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_play_melody_sleeping_raises(): + """A provably-asleep device fails fast at the sleep gate.""" + entry, device, make_call = _melody_env() + call = make_call() + p1, p2, p3 = _play_patches( + entry, device, sleeping_exc=HomeAssistantError("asleep") + ) + with p1, p2, p3, pytest.raises(HomeAssistantError): + await _async_play_melody(call) + device.activate_buzzer.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_play_melody_handler_time_overflow_raises(): + """Tempo-dependent overflow (uncatchable at schema time) surfaces here. + + ``C5/1`` (a whole note) at 40 BPM resolves to 6000 ms > 1275 ms; the parser + raises ValueError, which the handler re-raises as ``invalid_melody``. + """ + entry, device, make_call = _melody_env() + call = make_call(notes="C5/1", tempo=40) + p1, p2, p3 = _play_patches(entry, device) + with p1, p2, p3, pytest.raises(ServiceValidationError) as excinfo: + await _async_play_melody(call) + assert excinfo.value.translation_key == "invalid_melody" + assert "1275" in excinfo.value.translation_placeholders["error"] + device.activate_buzzer.assert_not_awaited() + + +def test_schema_play_melody_accepts_valid(): + """The schema fills defaults and accepts a well-formed melody string.""" + data = SCHEMA_PLAY_MELODY({"device_id": "dev-1", "notes": "C5 C5 G5/2"}) + assert data["instance"] == 0 + assert data["tempo"] == 120 + assert data["repeats"] == 1 + assert data["default_note_ms"] == 200 + assert "default_length" not in data + assert data["notes"] == "C5 C5 G5/2" + + +def test_schema_play_melody_accepts_default_length(): + """default_length accepts the select's string value (coerced to int).""" + data = SCHEMA_PLAY_MELODY( + {"device_id": "dev-1", "notes": "C5 C5", "default_length": "4"} + ) + assert data["default_length"] == 4 + + +def test_schema_play_melody_rejects_unknown_note(): + """A bad note letter is rejected by _valid_melody at schema time.""" + with pytest.raises(vol.Invalid): + SCHEMA_PLAY_MELODY({"device_id": "dev-1", "notes": "H4:100"}) + + +def test_schema_play_melody_rejects_pipe(): + """The reserved multi-pattern separator is rejected at schema time.""" + with pytest.raises(vol.Invalid): + SCHEMA_PLAY_MELODY({"device_id": "dev-1", "notes": "A4 | C5"}) + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-v"]))