Add support for 'barrier_ctrl' service

This commit is contained in:
Adrian Jagielak 2025-07-24 23:14:34 +02:00
parent dcab7a7112
commit 58e4ea228c
No known key found for this signature in database
GPG Key ID: 0818CF7AF6C62BFB
8 changed files with 237 additions and 8 deletions

View File

@ -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)

View File

@ -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) | |

View File

@ -24,10 +24,48 @@ export type VinculumPd7Device = {
| null;
services?: Record<string, VinculumPd7Service> | 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;
};

View File

@ -1 +1,14 @@
export type EntityCategory = undefined | 'config' | 'diagnostic';
export type CoverDeviceClass =
| 'awning'
| 'blind'
| 'curtain'
| 'damper'
| 'door'
| 'garage'
| 'gate'
| 'shade'
| 'shutter'
| 'window'
| null;

View File

@ -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.

View File

@ -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,

View File

@ -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,

View File

@ -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<string, HaMqttComponent> = {};
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,
};
}