diff --git a/futurehome/CHANGELOG.md b/futurehome/CHANGELOG.md index 8cf60b3..fdd21b4 100644 --- a/futurehome/CHANGELOG.md +++ b/futurehome/CHANGELOG.md @@ -7,6 +7,7 @@ - Do not expose 'battery' entity twice if it supports both level and low/high binary state. - Changed the default 'sensor_lumin' unit from 'Lux' to 'lx'. - Added support for 'indicator_ctrl' service (identify devices). +- Added support for 'barrier_ctrl' service (devices like garage doors, barriers, and window shades). # 0.1.0 (24.07.2025) diff --git a/futurehome/README.md b/futurehome/README.md index f1ecc8c..a92d623 100644 --- a/futurehome/README.md +++ b/futurehome/README.md @@ -60,7 +60,7 @@ todo: links to the .ts service implementations below | Service | Example device | Implementation status | | --- | --- | --- | | _alarms_ services | [Brannvarsler](https://www.futurehome.io/en_no/shop/brannvarsler-230v) | | -| barrier_ctrl | | | +| barrier_ctrl | | ✅ | | basic | | ✅ | | battery | | ✅ | | chargepoint | [Futurehome Charge](https://www.futurehome.io/en_no/shop/charge) | | diff --git a/futurehome/src/fimp/vinculum_pd7_device.ts b/futurehome/src/fimp/vinculum_pd7_device.ts index 127aee2..761465f 100644 --- a/futurehome/src/fimp/vinculum_pd7_device.ts +++ b/futurehome/src/fimp/vinculum_pd7_device.ts @@ -24,10 +24,48 @@ export type VinculumPd7Device = { | null; services?: Record | null; type?: { - // User-defined device type (e.g. "sensor", "chargepoint", or "light") - type?: string | null; - // User-defined device subtype (e.g. "presence" or "car_charger") - subtype?: string | null; + // User-defined device type + type?: + | 'appliance' + | 'battery' + | 'blinds' + | 'boiler' + | 'chargepoint' + | 'door_lock' + | 'fan' + | 'fire_detector' + | 'garage_door' + | 'gas_detector' + | 'gate' + | 'heat_detector' + | 'heat_pump' + | 'heater' + | 'leak_detector' + | 'light' + | 'media_player' + | 'meter' + | 'sensor' + | 'siren' + | 'thermostat' + | 'input' + | 'water_valve' + | string + | null; + // User-defined device subtype + subtype?: + | 'car_charger' + | 'door' + | 'door_lock' + | 'garage' + | 'lock' + | 'main_elec' + | 'presence' + | 'scene' + | 'window' + | 'window_lock' + | 'inverter' + | string + | null; } | null; }; diff --git a/futurehome/src/ha/mqtt_components/_enums.ts b/futurehome/src/ha/mqtt_components/_enums.ts index 47fe407..b980ff7 100644 --- a/futurehome/src/ha/mqtt_components/_enums.ts +++ b/futurehome/src/ha/mqtt_components/_enums.ts @@ -1 +1,14 @@ export type EntityCategory = undefined | 'config' | 'diagnostic'; + +export type CoverDeviceClass = + | 'awning' + | 'blind' + | 'curtain' + | 'damper' + | 'door' + | 'garage' + | 'gate' + | 'shade' + | 'shutter' + | 'window' + | null; diff --git a/futurehome/src/ha/mqtt_components/cover.ts b/futurehome/src/ha/mqtt_components/cover.ts index 7fab989..1144dca 100644 --- a/futurehome/src/ha/mqtt_components/cover.ts +++ b/futurehome/src/ha/mqtt_components/cover.ts @@ -1,3 +1,5 @@ +import { CoverDeviceClass } from './_enums'; + /** * Represents a MQTT Cover component for Home Assistant MQTT Discovery. * @@ -38,9 +40,9 @@ export interface CoverComponent { /** * Sets the [class of the device](https://www.home-assistant.io/integrations/cover/#device_class), * changing the device state and icon that is displayed on the frontend. - * The `device_class` can be `null`. + * The `device_class` can be `null` (generic cover). */ - device_class?: string | null; + device_class?: CoverDeviceClass; /** * Flag which defines if the entity should be enabled when first added. diff --git a/futurehome/src/ha/publish_device.ts b/futurehome/src/ha/publish_device.ts index 088a6c1..90f81dc 100644 --- a/futurehome/src/ha/publish_device.ts +++ b/futurehome/src/ha/publish_device.ts @@ -4,6 +4,7 @@ import { VinculumPd7Service, } from '../fimp/vinculum_pd7_device'; import { log } from '../logger'; +import { barrier_ctrl__components } from '../services/barrier_ctrl'; import { basic__components } from '../services/basic'; import { battery__components } from '../services/battery'; import { color_ctrl__components } from '../services/color_ctrl'; @@ -158,6 +159,7 @@ const serviceHandlers: { svc: VinculumPd7Service, ) => ServiceComponentsCreationResult | undefined; } = { + barrier_ctrl: barrier_ctrl__components, basic: basic__components, battery: battery__components, color_ctrl: color_ctrl__components, diff --git a/futurehome/src/index.ts b/futurehome/src/index.ts index 151534e..10a0375 100644 --- a/futurehome/src/index.ts +++ b/futurehome/src/index.ts @@ -238,7 +238,8 @@ import { delay } from './utils'; case 'evt.presence.report': case 'evt.scene.report': case 'evt.sensor.report': - case 'evt.setpoint.report': { + case 'evt.setpoint.report': + case 'evt.state.report': { haUpdateStateSensorReport({ topic, value: msg.val, diff --git a/futurehome/src/services/barrier_ctrl.ts b/futurehome/src/services/barrier_ctrl.ts new file mode 100644 index 0000000..1dee914 --- /dev/null +++ b/futurehome/src/services/barrier_ctrl.ts @@ -0,0 +1,172 @@ +import { sendFimpMsg } from '../fimp/fimp'; +import { + VinculumPd7Device, + VinculumPd7Service, +} from '../fimp/vinculum_pd7_device'; +import { HaMqttComponent } from '../ha/mqtt_components/_component'; +import { CoverDeviceClass } from '../ha/mqtt_components/_enums'; +import { CoverComponent } from '../ha/mqtt_components/cover'; +import { + CommandHandlers, + ServiceComponentsCreationResult, +} from '../ha/publish_device'; + +export function barrier_ctrl__components( + topicPrefix: string, + device: VinculumPd7Device, + svc: VinculumPd7Service, +): ServiceComponentsCreationResult | undefined { + const components: Record = {}; + const commandHandlers: CommandHandlers = {}; + + // Main cover component + const commandTopic = `${topicPrefix}${svc.addr}/command`; + const positionCommandTopic = `${topicPrefix}${svc.addr}/set_position`; + const stopCommandTopic = `${topicPrefix}${svc.addr}/stop`; + + // Determine device class based on device type/functionality + let deviceClass: CoverDeviceClass; + if ( + device.type?.type === 'garage_door' || + device.functionality === 'security' + ) { + deviceClass = 'garage'; + } else if (device.type?.type === 'gate') { + deviceClass = 'gate'; + } else if ( + device.type?.type === 'blinds' || + device.functionality === 'shading' + ) { + deviceClass = 'blind'; + } else { + deviceClass = null; + } + + // Check if position control is supported + const supportsPosition = svc.props?.sup_tposition === true; + const supportedTargetStates = svc.props?.sup_tstates || []; + + const coverComponent: CoverComponent = { + unique_id: svc.addr, + platform: 'cover', + device_class: deviceClass, + command_topic: commandTopic, + optimistic: false, + value_template: `{{ value_json['${svc.addr}'].state }}`, + // Standard Home Assistant cover payloads + payload_open: 'OPEN', + payload_close: 'CLOSE', + payload_stop: 'STOP', + state_open: 'open', + state_closed: 'closed', + state_opening: 'opening', + state_closing: 'closing', + state_stopped: 'stopped', + }; + + // Add position support if available + if (supportsPosition) { + coverComponent.set_position_topic = positionCommandTopic; + coverComponent.position_template = `{{ value_json['${svc.addr}'].position | default(0) }}`; + coverComponent.position_closed = 0; + coverComponent.position_open = 100; + } + + // Add stop command if supported + if (svc.intf?.includes('cmd.op.stop')) { + coverComponent.command_topic = stopCommandTopic; + } + + components[svc.addr] = coverComponent; + + // Command handlers + commandHandlers[commandTopic] = async (payload: string) => { + let targetState: string; + + switch (payload) { + case 'OPEN': + targetState = 'open'; + break; + case 'CLOSE': + targetState = 'closed'; + break; + case 'STOP': + if (svc.intf?.includes('cmd.op.stop')) { + await sendFimpMsg({ + address: svc.addr, + service: 'barrier_ctrl', + cmd: 'cmd.op.stop', + val_t: 'null', + val: null, + }); + } + return; + default: + return; + } + + // Only send target state if it's supported + if (supportedTargetStates.includes(targetState)) { + await sendFimpMsg({ + address: svc.addr, + service: 'barrier_ctrl', + cmd: 'cmd.tstate.set', + val_t: 'string', + val: targetState, + }); + } + }; + + // Position command handler (if position control is supported) + if (supportsPosition) { + commandHandlers[positionCommandTopic] = async (payload: string) => { + const position = parseInt(payload, 10); + if (Number.isNaN(position) || position < 0 || position > 100) { + return; + } + + // Determine target state based on position + let targetState: string; + if (position === 0) { + targetState = 'closed'; + } else if (position === 100) { + targetState = 'open'; + } else { + // For partial positions, we'll use 'open' as the target state + targetState = 'open'; + } + + // Only send if target state is supported + if (supportedTargetStates.includes(targetState)) { + await sendFimpMsg({ + address: svc.addr, + service: 'barrier_ctrl', + cmd: 'cmd.tstate.set', + val_t: 'string', + val: targetState, + props: { + position: position.toString(), + }, + }); + } + }; + } + + // Stop command handler (separate topic if needed) + if (svc.intf?.includes('cmd.op.stop')) { + commandHandlers[stopCommandTopic] = async (_payload: string) => { + await sendFimpMsg({ + address: svc.addr, + service: 'barrier_ctrl', + cmd: 'cmd.op.stop', + val_t: 'null', + val: null, + }); + }; + } + + return { + components, + commandHandlers, + }; +}