From 1807b5157bdcde40546feaeec412966ca503e2d5 Mon Sep 17 00:00:00 2001 From: Adrian Jagielak Date: Fri, 25 Jul 2025 15:40:28 +0200 Subject: [PATCH] Add support for 'media_player' service --- README.md | 4 +- futurehome/CHANGELOG.md | 4 + futurehome/README.md | 4 +- futurehome/config.yaml | 2 +- futurehome/src/ha/publish_device.ts | 4 + futurehome/src/mqtt/demo_data/device.json | 53 +++++ futurehome/src/mqtt/demo_data/state.json | 71 +++++++ futurehome/src/services/media_player.ts | 226 ++++++++++++++++++++++ 8 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 futurehome/src/services/media_player.ts diff --git a/README.md b/README.md index c527478..b612708 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,11 @@ todo: links to the .ts service implementations below | chargepoint | [Futurehome Charge](https://www.futurehome.io/en_no/shop/charge) | ✅ | | color_ctrl | | ✅ | | complex_alarm_system | | | -| door_lock | | | +| door_lock | | | | doorman | | | | fan_ctrl | | ✅ | | light | | ✅ | -| media_player | | | +| media_player | | ✅ | | meter_elec | [HAN-Sensor](https://www.futurehome.io/en/shop/han-sensor) | | | out_bin_switch | [16A Puck Relé](https://www.futurehome.io/en_no/shop/puck-relay-16a) | ✅ | | out_lvl_switch | [Smart LED Dimmer](https://www.futurehome.io/en_no/shop/smart-led-dimmer-polar-white) | ✅ | diff --git a/futurehome/CHANGELOG.md b/futurehome/CHANGELOG.md index bc33f56..1e36676 100644 --- a/futurehome/CHANGELOG.md +++ b/futurehome/CHANGELOG.md @@ -1,6 +1,10 @@ +## 0.1.4 (25.07.2025) + +- Added support for 'media_player' service. + ## 0.1.3 (25.07.2025) - Added support for 'chargepoint' service (EV chargers). diff --git a/futurehome/README.md b/futurehome/README.md index 05d6994..4279e47 100644 --- a/futurehome/README.md +++ b/futurehome/README.md @@ -66,11 +66,11 @@ todo: links to the .ts service implementations below | chargepoint | [Futurehome Charge](https://www.futurehome.io/en_no/shop/charge) | ✅ | | color_ctrl | | ✅ | | complex_alarm_system | | | -| door_lock | | | +| door_lock | | | | doorman | | | | fan_ctrl | | ✅ | | light | | ✅ | -| media_player | | | +| media_player | | ✅ | | meter_elec | [HAN-Sensor](https://www.futurehome.io/en/shop/han-sensor) | | | out_bin_switch | [16A Puck Relé](https://www.futurehome.io/en_no/shop/puck-relay-16a) | ✅ | | out_lvl_switch | [Smart LED Dimmer](https://www.futurehome.io/en_no/shop/smart-led-dimmer-polar-white) | ✅ | diff --git a/futurehome/config.yaml b/futurehome/config.yaml index 11c2ff5..4992f62 100644 --- a/futurehome/config.yaml +++ b/futurehome/config.yaml @@ -1,6 +1,6 @@ # https://developers.home-assistant.io/docs/add-ons/configuration#add-on-config name: Futurehome -version: "0.1.3" +version: "0.1.4" slug: futurehome description: Local Futurehome Smarthub integration url: "https://github.com/adrianjagielak/home-assistant-futurehome" diff --git a/futurehome/src/ha/publish_device.ts b/futurehome/src/ha/publish_device.ts index 6b5a2f5..bfe11f2 100644 --- a/futurehome/src/ha/publish_device.ts +++ b/futurehome/src/ha/publish_device.ts @@ -11,6 +11,7 @@ import { chargepoint__components } from '../services/chargepoint'; import { color_ctrl__components } from '../services/color_ctrl'; import { fan_ctrl__components } from '../services/fan_ctrl'; import { indicator_ctrl__components } from '../services/indicator_ctrl'; +import { media_player__components } from '../services/media_player'; import { out_bin_switch__components } from '../services/out_bin_switch'; import { out_lvl_switch__components } from '../services/out_lvl_switch'; import { scene_ctrl__components } from '../services/scene_ctrl'; @@ -54,6 +55,7 @@ import { sensor_watflow__components } from '../services/sensor_watflow'; import { sensor_watpressure__components } from '../services/sensor_watpressure'; import { sensor_wattemp__components } from '../services/sensor_wattemp'; import { sensor_weight__components } from '../services/sensor_weight'; +import { sensor_wind__components } from '../services/sensor_wind'; import { thermostat__components } from '../services/thermostat'; import { water_heater__components } from '../services/water_heater'; import { abbreviateHaMqttKeys } from './abbreviate_ha_mqtt_keys'; @@ -168,6 +170,7 @@ const serviceHandlers: { color_ctrl: color_ctrl__components, fan_ctrl: fan_ctrl__components, indicator_ctrl: indicator_ctrl__components, + media_player: media_player__components, out_bin_switch: out_bin_switch__components, out_lvl_switch: out_lvl_switch__components, scene_ctrl: scene_ctrl__components, @@ -211,6 +214,7 @@ const serviceHandlers: { sensor_watpressure: sensor_watpressure__components, sensor_wattemp: sensor_wattemp__components, sensor_weight: sensor_weight__components, + sensor_wind: sensor_wind__components, thermostat: thermostat__components, water_heater: water_heater__components, }; diff --git a/futurehome/src/mqtt/demo_data/device.json b/futurehome/src/mqtt/demo_data/device.json index 123f686..038b075 100644 --- a/futurehome/src/mqtt/demo_data/device.json +++ b/futurehome/src/mqtt/demo_data/device.json @@ -1942,5 +1942,58 @@ } }, "metadata": null + }, + { + "client": { + "name": "Smart Speaker" + }, + "id": 1001, + "model": "zigbee - Futurehome - Smart Speaker", + "modelAlias": "Smart Speaker", + "type": { + "subtype": null, + "supported": { + "boiler": [] + }, + "type": null + }, + "locationRef": { + "id": "11", + "type": "Room" + }, + "thingRef": { + "id": "166" + }, + "origin": "056e610614c848c50b", + "services": { + "media_player": { + "name": "media_player", + "addr": "/rt:dev/rn:zigbee/ad:1/sv:media_player/ad:1001_0", + "enabled": true, + "props": { + "sup_playback": ["play", "pause", "next_track", "previous_track"], + "sup_modes": ["repeat", "repeat_one", "shuffle", "crossfade"], + "sup_metadata": ["album", "track", "artist", "image_url"] + }, + "intf": [ + "cmd.playback.set", + "cmd.playback.get_report", + "evt.playback.report", + "cmd.playbackmode.set", + "cmd.playbackmode.get_report", + "evt.playbackmode.report", + "cmd.volume.set", + "cmd.volume.get_report", + "evt.volume.report", + "cmd.mute.set", + "cmd.mute.get_report", + "evt.mute.report", + "cmd.metadata.get_report", + "evt.metadata.report" + ], + "metadata": null + } + }, + "metadata": null } ] diff --git a/futurehome/src/mqtt/demo_data/state.json b/futurehome/src/mqtt/demo_data/state.json index 36ee997..9748ea8 100644 --- a/futurehome/src/mqtt/demo_data/state.json +++ b/futurehome/src/mqtt/demo_data/state.json @@ -1243,5 +1243,76 @@ "name": "sensor_wattemp" } ] + }, + { + "id": 1001, + "services": [ + { + "addr": "/rt:dev/rn:zigbee/ad:1/sv:media_player/ad:1001_0", + "attributes": [ + { + "name": "playback", + "values": [ + { + "ts": "2022-05-05 00:01:50 +0200", + "val": "play", + "val_t": "string" + } + ] + }, + { + "name": "playbackmode", + "values": [ + { + "ts": "2022-05-05 00:01:50 +0200", + "val": { + "repeat": true, + "repeat_one": false, + "shuffle": false, + "crossfade": false + }, + "val_t": "bool_map" + } + ] + }, + { + "name": "volume", + "values": [ + { + "ts": "2022-05-05 00:01:50 +0200", + "val": 69, + "val_t": "int" + } + ] + }, + { + "name": "mute", + "values": [ + { + "ts": "2022-05-05 00:01:50 +0200", + "val": false, + "val_t": "bool" + } + ] + }, + { + "name": "metadata", + "values": [ + { + "ts": "2022-05-05 00:01:50 +0200", + "val": { + "album": "The Dark Side of the Moon", + "track": "Money", + "artist": "Pink Floyd", + "image_url": "https://upload.wikimedia.org/wikipedia/en/3/3b/Dark_Side_of_the_Moon.png" + }, + "val_t": "str_map" + } + ] + } + ], + "name": "media_player" + } + ] } ] diff --git a/futurehome/src/services/media_player.ts b/futurehome/src/services/media_player.ts new file mode 100644 index 0000000..31ebab1 --- /dev/null +++ b/futurehome/src/services/media_player.ts @@ -0,0 +1,226 @@ +import { sendFimpMsg } from '../fimp/fimp'; +import { + VinculumPd7Device, + VinculumPd7Service, +} from '../fimp/vinculum_pd7_device'; +import { HaMqttComponent } from '../ha/mqtt_components/_component'; +import { SensorComponent } from '../ha/mqtt_components/sensor'; +import { NumberComponent } from '../ha/mqtt_components/number'; +import { SwitchComponent } from '../ha/mqtt_components/switch'; +import { SelectComponent } from '../ha/mqtt_components/select'; +import { ImageComponent } from '../ha/mqtt_components/image'; +import { + CommandHandlers, + ServiceComponentsCreationResult, +} from '../ha/publish_device'; + +export function media_player__components( + topicPrefix: string, + device: VinculumPd7Device, + svc: VinculumPd7Service, +): ServiceComponentsCreationResult | undefined { + const components: Record = {}; + const commandHandlers: CommandHandlers = {}; + + // Extract supported properties + const supPlayback = svc.props?.sup_playback || []; + const supModes = svc.props?.sup_modes || []; + const supMetadata = svc.props?.sup_metadata || []; + + // Command topics + const playbackCommandTopic = `${topicPrefix}${svc.addr}/playback/command`; + const volumeCommandTopic = `${topicPrefix}${svc.addr}/volume/command`; + const muteCommandTopic = `${topicPrefix}${svc.addr}/mute/command`; + + // --- Main playback control as a select entity --- + if (supPlayback.length > 0) { + const playbackComponent: SelectComponent = { + unique_id: `${svc.addr}_playback`, + platform: 'select', + name: 'Playback Control', + command_topic: playbackCommandTopic, + options: supPlayback, + optimistic: false, + value_template: `{{ value_json['${svc.addr}'].playback | default('${supPlayback[0]}') }}`, + icon: 'mdi:play-pause', + }; + + components[`${svc.addr}_playback`] = playbackComponent; + + // Playback command handler + commandHandlers[playbackCommandTopic] = async (payload: string) => { + if (!supPlayback.includes(payload)) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'media_player', + cmd: 'cmd.playback.set', + val_t: 'string', + val: payload, + }); + }; + } + + // --- Volume control as a number entity --- + if (svc.intf?.includes('cmd.volume.set')) { + const volumeComponent: NumberComponent = { + unique_id: `${svc.addr}_volume`, + platform: 'number', + name: 'Volume', + command_topic: volumeCommandTopic, + min: 0, + max: 100, + step: 1, + mode: 'slider', + unit_of_measurement: '%', + optimistic: false, + value_template: `{{ value_json['${svc.addr}'].volume | default(50) }}`, + icon: 'mdi:volume-high', + }; + + components[`${svc.addr}_volume`] = volumeComponent; + + // Volume command handler + commandHandlers[volumeCommandTopic] = async (payload: string) => { + const volume = parseInt(payload, 10); + if (Number.isNaN(volume) || volume < 0 || volume > 100) { + return; + } + + await sendFimpMsg({ + address: svc.addr, + service: 'media_player', + cmd: 'cmd.volume.set', + val_t: 'int', + val: volume, + }); + }; + } + + // --- Mute control as a switch entity --- + if (svc.intf?.includes('cmd.mute.set')) { + const muteComponent: SwitchComponent = { + unique_id: `${svc.addr}_mute`, + platform: 'switch', + name: 'Mute', + command_topic: muteCommandTopic, + optimistic: false, + value_template: `{{ 'ON' if value_json['${svc.addr}'].mute else 'OFF' }}`, + payload_on: 'true', + payload_off: 'false', + icon: 'mdi:volume-off', + }; + + components[`${svc.addr}_mute`] = muteComponent; + + // Mute command handler + commandHandlers[muteCommandTopic] = async (payload: string) => { + const mute = payload === 'true' || payload === 'ON'; + + await sendFimpMsg({ + address: svc.addr, + service: 'media_player', + cmd: 'cmd.mute.set', + val_t: 'bool', + val: mute, + }); + }; + } + + // --- Playback mode controls as switch entities --- + for (const mode of supModes) { + const modeCommandTopic = `${topicPrefix}${svc.addr}/mode_${mode}/command`; + + const modeComponent: SwitchComponent = { + unique_id: `${svc.addr}_mode_${mode}`, + platform: 'switch', + name: `${mode.charAt(0).toUpperCase() + mode.slice(1)} Mode`, + command_topic: modeCommandTopic, + optimistic: false, + value_template: `{{ 'ON' if value_json['${svc.addr}'].playbackmode.${mode} else 'OFF' }}`, + payload_on: 'true', + payload_off: 'false', + icon: getPlaybackModeIcon(mode), + }; + + components[`${svc.addr}_mode_${mode}`] = modeComponent; + + // Mode command handler + commandHandlers[modeCommandTopic] = async (payload: string) => { + const enabled = payload === 'true' || payload === 'ON'; + + // We need to send the full playbackmode map, so we'll need to get current state + // For now, just send the single mode change - the device should handle merging + await sendFimpMsg({ + address: svc.addr, + service: 'media_player', + cmd: 'cmd.playbackmode.set', + val_t: 'bool_map', + val: { + [mode]: enabled, + }, + }); + }; + } + + // --- Metadata sensors --- + for (const metadata of supMetadata) { + if (metadata === 'image_url') { + // Image metadata as image entity + const imageComponent: ImageComponent = { + unique_id: `${svc.addr}_${metadata}`, + platform: 'image', + name: 'Album Art', + url_topic: `${topicPrefix}/state`, + url_template: `{{ value_json['${svc.addr}'].metadata.image_url | default('') }}`, + icon: 'mdi:image', + }; + + components[`${svc.addr}_${metadata}`] = imageComponent; + } else { + // Other metadata as sensor entities + const metadataComponent: SensorComponent = { + unique_id: `${svc.addr}_${metadata}`, + platform: 'sensor', + name: `${metadata.charAt(0).toUpperCase() + metadata.slice(1)}`, + value_template: `{{ value_json['${svc.addr}'].metadata.${metadata} | default('Unknown') }}`, + icon: getMetadataIcon(metadata), + }; + + components[`${svc.addr}_${metadata}`] = metadataComponent; + } + } + + return { + components, + commandHandlers, + }; +} + +/** + * Get appropriate icon for playback mode + */ +function getPlaybackModeIcon(mode: string): string { + const iconMap: Record = { + repeat: 'mdi:repeat', + repeat_one: 'mdi:repeat-once', + shuffle: 'mdi:shuffle', + crossfade: 'mdi:shuffle-variant', + }; + return iconMap[mode] || 'mdi:cog'; +} + +/** + * Get appropriate icon for metadata type + */ +function getMetadataIcon(metadata: string): string { + const iconMap: Record = { + album: 'mdi:album', + track: 'mdi:music-note', + artist: 'mdi:account-music', + image_url: 'mdi:image', + }; + return iconMap[metadata] || 'mdi:information'; +}