Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion custom_components/opendisplay/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand All @@ -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)
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)
58 changes: 58 additions & 0 deletions custom_components/opendisplay/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 38 additions & 1 deletion custom_components/opendisplay/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
39 changes: 38 additions & 1 deletion custom_components/opendisplay/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
Loading
Loading