Files
integrations/ts/integrations/bosch_shc/bosch_shc.mapper.ts
T

794 lines
38 KiB
TypeScript
Raw Normal View History

import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest } from '../../core/types.js';
import type {
IBoschShcBinarySensorFeature,
IBoschShcClimateFeature,
IBoschShcCoverFeature,
IBoschShcDevice,
IBoschShcDeviceService,
IBoschShcFeatureBase,
IBoschShcLightFeature,
IBoschShcModeledCommand,
IBoschShcSensorFeature,
IBoschShcSnapshot,
IBoschShcSwitchFeature,
TBoschShcCoverState,
TBoschShcFeature,
} from './bosch_shc.types.js';
const thermostatModels = new Set(['TRV', 'TRV_GEN2', 'TRV_GEN2_DUAL']);
const wallThermostatModels = new Set(['THB', 'BWTH', 'BWTH24', 'RTH2_BAT', 'RTH2_230']);
const shutterContactModels = new Set(['SWD', 'SWD2', 'SWD2_PLUS', 'SWD2_DUAL']);
const batteryModels = new Set([
'MD',
'SWD',
'SWD2',
'SWD2_PLUS',
'SWD2_DUAL',
'SD',
'SMOKE_DETECTOR2',
'TRV',
'TRV_GEN2',
'TRV_GEN2_DUAL',
'TWINGUARD',
'WRC2',
'SWITCH2',
'THB',
'BWTH',
'BWTH24',
'RTH2_BAT',
'RTH2_230',
'WLS',
]);
const smartPlugModels = new Set(['PSM', 'PLUG_COMPACT', 'PLUG_COMPACT_DUAL']);
const lightSwitchModels = new Set(['BSM', 'MICROMODULE_LIGHT_ATTACHED', 'MICROMODULE_RELAY']);
const lightModels = new Set(['LEDVANCE_LIGHT', 'HUE_LIGHT', 'MICROMODULE_DIMMER']);
const sensorMetadata: Record<string, { name: string; unit?: string; deviceClass?: string; stateClass?: string }> = {
temperature: { name: 'Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' },
humidity: { name: 'Humidity', unit: '%', deviceClass: 'humidity' },
valvetappet: { name: 'Valvetappet', unit: '%', stateClass: 'measurement' },
purity: { name: 'Purity', unit: 'ppm' },
airquality: { name: 'Air quality' },
temperature_rating: { name: 'Temperature rating' },
humidity_rating: { name: 'Humidity rating' },
purity_rating: { name: 'Purity rating' },
power: { name: 'Power', unit: 'W', deviceClass: 'power' },
energy: { name: 'Energy', unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' },
communication_quality: { name: 'Communication quality' },
};
export class BoschShcMapper {
public static toDevices(snapshotArg: IBoschShcSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const controllerId = this.controllerDeviceId(snapshotArg);
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
id: controllerId,
integrationDomain: 'bosch_shc',
name: snapshotArg.name || snapshotArg.information?.shcIpAddress || 'Bosch Smart Home Controller',
protocol: 'http',
manufacturer: 'Bosch',
model: 'SmartHomeController',
online: snapshotArg.online !== false,
features: [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
],
state: [
{ featureId: 'connectivity', value: snapshotArg.online === false ? 'offline' : 'online', updatedAt },
],
metadata: {
host: snapshotArg.host || snapshotArg.information?.shcIpAddress,
uniqueId: this.controllerUniqueId(snapshotArg),
macAddress: snapshotArg.information?.macAddress,
softwareVersion: snapshotArg.information?.softwareUpdateState?.swInstalledVersion,
updateState: snapshotArg.information?.softwareUpdateState?.swUpdateState,
},
}];
const featuresByDevice = new Map<string, TBoschShcFeature[]>();
for (const feature of this.features(snapshotArg)) {
const entries = featuresByDevice.get(feature.deviceId) || [];
entries.push(feature);
featuresByDevice.set(feature.deviceId, entries);
}
for (const device of snapshotArg.devices) {
if (device.deleted) continue;
const featureEntries = featuresByDevice.get(device.id) || [];
const shxFeatures: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: this.available(device) ? 'online' : 'offline', updatedAt },
];
for (const feature of featureEntries) {
if (feature.platform === 'sensor') {
shxFeatures.push({ id: feature.id, capability: 'sensor', name: feature.name, readable: true, writable: false, unit: feature.unit });
state.push({ featureId: feature.id, value: feature.nativeValue, updatedAt });
} else if (feature.platform === 'binary_sensor') {
shxFeatures.push({ id: feature.id, capability: 'sensor', name: feature.name, readable: true, writable: false });
state.push({ featureId: feature.id, value: feature.isOn, updatedAt });
} else if (feature.platform === 'switch') {
shxFeatures.push({ id: feature.id, capability: 'switch', name: feature.name, readable: true, writable: true });
state.push({ featureId: feature.id, value: feature.isOn, updatedAt });
} else if (feature.platform === 'light') {
shxFeatures.push({ id: feature.id, capability: 'light', name: feature.name, readable: true, writable: true });
state.push({ featureId: feature.id, value: feature.isOn, updatedAt });
if (feature.supportsBrightness) {
shxFeatures.push({ id: `${feature.id}_brightness`, capability: 'light', name: `${feature.name} brightness`, readable: true, writable: true, unit: '%' });
state.push({ featureId: `${feature.id}_brightness`, value: feature.brightness ?? null, updatedAt });
}
} else if (feature.platform === 'cover') {
shxFeatures.push({ id: feature.id, capability: 'cover', name: feature.name, readable: true, writable: true, unit: '%' });
state.push({ featureId: feature.id, value: feature.state, updatedAt });
if (feature.position !== null) {
shxFeatures.push({ id: `${feature.id}_position`, capability: 'cover', name: `${feature.name} position`, readable: true, writable: true, unit: '%' });
state.push({ featureId: `${feature.id}_position`, value: feature.position, updatedAt });
}
} else if (feature.platform === 'climate') {
shxFeatures.push({ id: feature.id, capability: 'climate', name: feature.name, readable: true, writable: true });
state.push({ featureId: feature.id, value: feature.hvacMode, updatedAt });
if (feature.targetTemperature !== undefined) {
shxFeatures.push({ id: `${feature.id}_target_temperature`, capability: 'climate', name: `${feature.name} target temperature`, readable: true, writable: true, unit: 'C' });
state.push({ featureId: `${feature.id}_target_temperature`, value: feature.targetTemperature, updatedAt });
}
if (feature.currentTemperature !== undefined) {
shxFeatures.push({ id: `${feature.id}_current_temperature`, capability: 'climate', name: `${feature.name} current temperature`, readable: true, writable: false, unit: 'C' });
state.push({ featureId: `${feature.id}_current_temperature`, value: feature.currentTemperature, updatedAt });
}
}
}
devices.push({
id: this.deviceId(device),
integrationDomain: 'bosch_shc',
name: device.name || device.id,
protocol: 'http',
manufacturer: this.manufacturer(device),
model: device.deviceModel || 'Bosch SHC device',
online: this.available(device),
features: shxFeatures,
state,
metadata: {
rootDeviceId: device.rootDeviceId,
serial: device.serial,
profile: device.profile,
roomId: device.roomId,
status: device.status,
viaDevice: controllerId,
},
});
}
return devices;
}
public static toEntities(snapshotArg: IBoschShcSnapshot): IIntegrationEntity[] {
return this.features(snapshotArg).map((featureArg) => {
const base = {
id: this.entityId(featureArg),
uniqueId: featureArg.uniqueId,
integrationDomain: 'bosch_shc',
deviceId: this.deviceId(featureArg.device),
platform: featureArg.platform,
name: featureArg.name,
available: featureArg.available,
};
if (featureArg.platform === 'sensor') {
return {
...base,
state: featureArg.nativeValue,
attributes: {
unit: featureArg.unit,
deviceClass: featureArg.deviceClass,
stateClass: featureArg.stateClass,
...featureArg.attributes,
},
};
}
if (featureArg.platform === 'binary_sensor') {
return {
...base,
state: featureArg.isOn ? 'on' : 'off',
attributes: {
deviceClass: featureArg.deviceClass,
...featureArg.attributes,
},
};
}
if (featureArg.platform === 'switch') {
return {
...base,
state: featureArg.isOn ? 'on' : 'off',
attributes: {
deviceClass: featureArg.deviceClass,
entityCategory: featureArg.entityCategory,
switchKind: featureArg.kind,
},
};
}
if (featureArg.platform === 'light') {
return {
...base,
state: featureArg.isOn ? 'on' : 'off',
attributes: {
brightness: featureArg.brightness,
colorTemperature: featureArg.colorTemperature,
minColorTemperature: featureArg.minColorTemperature,
maxColorTemperature: featureArg.maxColorTemperature,
rgb: featureArg.rgb,
supportsBrightness: featureArg.supportsBrightness,
supportsColorTemperature: featureArg.supportsColorTemperature,
supportsRgb: featureArg.supportsRgb,
},
};
}
if (featureArg.platform === 'cover') {
return {
...base,
state: featureArg.state,
attributes: {
currentPosition: featureArg.position,
deviceClass: featureArg.deviceClass,
operationState: featureArg.operationState,
calibrated: featureArg.calibrated,
supportsOpen: featureArg.supportsOpen,
supportsClose: featureArg.supportsClose,
supportsStop: featureArg.supportsStop,
supportsPosition: featureArg.supportsPosition,
},
};
}
return {
...base,
state: featureArg.hvacMode,
attributes: {
currentTemperature: featureArg.currentTemperature,
targetTemperature: featureArg.targetTemperature,
operationMode: featureArg.operationMode,
boostMode: featureArg.boostMode,
low: featureArg.low,
summerMode: featureArg.summerMode,
supportsBoostMode: featureArg.supportsBoostMode,
supportedHvacModes: featureArg.supportedHvacModes,
},
};
});
}
public static features(snapshotArg: IBoschShcSnapshot): TBoschShcFeature[] {
return [
...this.sensorFeatures(snapshotArg),
...this.binarySensorFeatures(snapshotArg),
...this.switchFeatures(snapshotArg),
...this.lightFeatures(snapshotArg),
...this.coverFeatures(snapshotArg),
...this.climateFeatures(snapshotArg),
];
}
public static commandForService(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
if (requestArg.domain === 'switch') {
return this.switchCommand(snapshotArg, requestArg);
}
if (requestArg.domain === 'light') {
return this.lightCommand(snapshotArg, requestArg);
}
if (requestArg.domain === 'cover') {
return this.coverCommand(snapshotArg, requestArg);
}
if (requestArg.domain === 'climate') {
return this.climateCommand(snapshotArg, requestArg);
}
return undefined;
}
public static controllerDeviceId(snapshotArg: IBoschShcSnapshot): string {
return `bosch_shc.controller.${this.slug(this.controllerUniqueId(snapshotArg) || snapshotArg.host || 'controller')}`;
}
public static deviceId(deviceArg: IBoschShcDevice): string {
return `bosch_shc.device.${this.slug(deviceArg.id || deviceArg.serial || deviceArg.name || 'device')}`;
}
public static entityId(featureArg: TBoschShcFeature): string {
const deviceSlug = this.slug(featureArg.device.name || featureArg.device.id || 'bosch_shc');
const alias = this.slug(featureArg.alias);
const objectId = this.usesDefaultEntityObjectId(featureArg) ? deviceSlug : `${deviceSlug}_${alias}`;
return `${featureArg.platform}.${objectId}`;
}
private static sensorFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcSensorFeature[] {
const features: IBoschShcSensorFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const model = device.deviceModel || '';
const temperature = this.service(snapshotArg, device, 'TemperatureLevel');
if (temperature && (thermostatModels.has(model) || wallThermostatModels.has(model))) {
features.push(this.sensorFeature(device, 'temperature', this.numberState(temperature, 'temperature'), temperature));
}
const humidity = this.service(snapshotArg, device, 'HumidityLevel');
if (humidity && wallThermostatModels.has(model)) {
features.push(this.sensorFeature(device, 'humidity', this.numberState(humidity, 'humidity'), humidity));
}
const valve = this.service(snapshotArg, device, 'ValveTappet');
if (valve && thermostatModels.has(model)) {
features.push(this.sensorFeature(device, 'valvetappet', this.numberState(valve, 'position'), valve, {
valve_tappet_state: this.stringState(valve, 'value'),
}));
}
const airQuality = this.service(snapshotArg, device, 'AirQualityLevel');
if (airQuality && model === 'TWINGUARD') {
features.push(this.sensorFeature(device, 'temperature', this.numberState(airQuality, 'temperature'), airQuality));
features.push(this.sensorFeature(device, 'humidity', this.numberState(airQuality, 'humidity'), airQuality));
features.push(this.sensorFeature(device, 'purity', this.numberState(airQuality, 'purity'), airQuality));
features.push(this.sensorFeature(device, 'airquality', this.stringState(airQuality, 'combinedRating'), airQuality, {
rating_description: this.stringState(airQuality, 'description'),
}));
features.push(this.sensorFeature(device, 'temperature_rating', this.stringState(airQuality, 'temperatureRating'), airQuality));
features.push(this.sensorFeature(device, 'humidity_rating', this.stringState(airQuality, 'humidityRating'), airQuality));
features.push(this.sensorFeature(device, 'purity_rating', this.stringState(airQuality, 'purityRating'), airQuality));
}
const powerMeter = this.service(snapshotArg, device, 'PowerMeter');
if (powerMeter && (smartPlugModels.has(model) || model === 'BSM' || this.hasService(device, 'PowerSwitch'))) {
features.push(this.sensorFeature(device, 'power', this.numberState(powerMeter, 'powerConsumption'), powerMeter));
const energyWh = this.numberState(powerMeter, 'energyConsumption');
features.push(this.sensorFeature(device, 'energy', typeof energyWh === 'number' ? energyWh / 1000 : null, powerMeter));
}
const communicationQuality = this.service(snapshotArg, device, 'CommunicationQuality');
if (communicationQuality && smartPlugModels.has(model)) {
features.push(this.sensorFeature(device, 'communication_quality', this.stringState(communicationQuality, 'quality'), communicationQuality));
}
}
return features;
}
private static binarySensorFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcBinarySensorFeature[] {
const features: IBoschShcBinarySensorFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const model = device.deviceModel || '';
const shutterContact = this.service(snapshotArg, device, 'ShutterContact');
if (shutterContact && shutterContactModels.has(model)) {
const profile = device.profile || '';
features.push({
...this.featureBase(device, 'binary_sensor', 'contact', this.uniqueId(device, 'contact', true), device.name || 'Shutter contact'),
isOn: this.stringState(shutterContact, 'value') === 'OPEN',
deviceClass: profile === 'ENTRANCE_DOOR' || profile === 'FRENCH_WINDOW' ? 'door' : 'window',
attributes: { profile },
});
}
const battery = this.service(snapshotArg, device, 'BatteryLevel');
if (battery && (batteryModels.has(model) || this.hasService(device, 'BatteryLevel'))) {
const level = this.batteryWarningLevel(battery);
features.push({
...this.featureBase(device, 'binary_sensor', 'battery', this.uniqueId(device, 'battery'), `${device.name || 'Bosch SHC device'} battery`),
isOn: level !== 'OK',
deviceClass: 'battery',
attributes: { warningLevel: level },
});
}
const leakage = this.service(snapshotArg, device, 'WaterLeakageSensor');
if (leakage) {
features.push({
...this.featureBase(device, 'binary_sensor', 'water_leakage', this.uniqueId(device, 'water_leakage'), `${device.name || 'Bosch SHC device'} water leakage`),
isOn: this.stringState(leakage, 'state') === 'LEAKAGE_DETECTED',
deviceClass: 'moisture',
});
}
}
return features;
}
private static switchFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcSwitchFeature[] {
const features: IBoschShcSwitchFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const model = device.deviceModel || '';
const powerSwitch = this.service(snapshotArg, device, 'PowerSwitch');
if (powerSwitch && !lightModels.has(model)) {
const isOutlet = smartPlugModels.has(model);
features.push({
...this.featureBase(device, 'switch', 'power', this.uniqueId(device, 'power', true), device.name || 'Bosch SHC switch'),
kind: 'power_switch',
isOn: this.stringState(powerSwitch, 'switchState') === 'ON',
deviceClass: isOutlet ? 'outlet' : 'switch',
serviceId: 'PowerSwitch',
stateKey: 'switchState',
onValue: 'ON',
offValue: 'OFF',
});
}
const routing = this.service(snapshotArg, device, 'Routing');
if (routing && model === 'PSM') {
features.push({
...this.featureBase(device, 'switch', 'routing', this.uniqueId(device, 'routing'), `${device.name || 'Bosch SHC device'} routing`),
kind: 'routing',
isOn: this.stringState(routing, 'value') === 'ENABLED',
deviceClass: 'switch',
serviceId: 'Routing',
stateKey: 'value',
onValue: 'ENABLED',
offValue: 'DISABLED',
entityCategory: 'config',
});
}
const cameraLight = this.service(snapshotArg, device, 'CameraLight');
if (cameraLight && model === 'CAMERA_EYES') {
features.push({
...this.featureBase(device, 'switch', 'camera_light', this.uniqueId(device, 'camera_light'), `${device.name || 'Bosch SHC camera'} light`),
kind: 'camera_light',
isOn: this.stringState(cameraLight, 'value') === 'ON',
deviceClass: 'switch',
serviceId: 'CameraLight',
stateKey: 'value',
onValue: 'ON',
offValue: 'OFF',
});
}
const privacy = this.service(snapshotArg, device, 'PrivacyMode');
if (privacy && model === 'CAMERA_360') {
features.push({
...this.featureBase(device, 'switch', 'privacy_mode', this.uniqueId(device, 'privacy_mode'), `${device.name || 'Bosch SHC camera'} privacy mode`),
kind: 'privacy_mode',
isOn: this.stringState(privacy, 'value') === 'DISABLED',
deviceClass: 'switch',
serviceId: 'PrivacyMode',
stateKey: 'value',
onValue: 'DISABLED',
offValue: 'ENABLED',
});
}
}
return features.filter((featureArg) => lightSwitchModels.has(featureArg.device.deviceModel || '') || smartPlugModels.has(featureArg.device.deviceModel || '') || featureArg.kind !== 'power_switch');
}
private static lightFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcLightFeature[] {
const features: IBoschShcLightFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const model = device.deviceModel || '';
if (!lightModels.has(model) && !this.service(snapshotArg, device, 'BinarySwitch')) continue;
const binarySwitch = this.service(snapshotArg, device, 'BinarySwitch');
const powerSwitch = this.service(snapshotArg, device, 'PowerSwitch');
const switchService = binarySwitch || powerSwitch;
if (!switchService) continue;
const brightness = this.service(snapshotArg, device, 'MultiLevelSwitch');
const colorTemperature = this.service(snapshotArg, device, 'HueColorTemperature');
const rgb = this.service(snapshotArg, device, 'HSBColorActuator');
features.push({
...this.featureBase(device, 'light', 'light', this.uniqueId(device, 'light', true), device.name || 'Bosch SHC light'),
isOn: binarySwitch ? this.booleanState(binarySwitch, 'on') ?? null : this.stringState(switchService, 'switchState') === 'ON',
switchServiceId: binarySwitch ? 'BinarySwitch' : 'PowerSwitch',
brightness: brightness ? this.numberState(brightness, 'level') : null,
colorTemperature: colorTemperature ? this.numberState(colorTemperature, 'colorTemperature') : null,
minColorTemperature: colorTemperature ? this.numberNestedState(colorTemperature, 'colorTemperatureRange', 'minCt') : rgb ? this.numberNestedState(rgb, 'colorTemperatureRange', 'minCt') : null,
maxColorTemperature: colorTemperature ? this.numberNestedState(colorTemperature, 'colorTemperatureRange', 'maxCt') : rgb ? this.numberNestedState(rgb, 'colorTemperatureRange', 'maxCt') : null,
rgb: rgb ? this.numberState(rgb, 'rgb') : null,
supportsBrightness: Boolean(brightness),
supportsColorTemperature: Boolean(colorTemperature),
supportsRgb: Boolean(rgb),
});
}
return features;
}
private static coverFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcCoverFeature[] {
const features: IBoschShcCoverFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const shutter = this.service(snapshotArg, device, 'ShutterControl');
if (!shutter) continue;
const level = this.numberState(shutter, 'level');
const position = typeof level === 'number' ? Math.round(level * 100) : null;
const operationState = this.stringState(shutter, 'operationState');
const model = device.deviceModel || '';
features.push({
...this.featureBase(device, 'cover', 'cover', this.uniqueId(device, 'cover', true), device.name || 'Bosch SHC cover'),
serviceId: 'ShutterControl',
deviceClass: model === 'MICROMODULE_AWNING' ? 'awning' : model === 'MICROMODULE_BLINDS' ? 'blind' : 'shutter',
state: this.coverState(operationState, position),
position,
operationState: operationState ?? undefined,
calibrated: this.booleanState(shutter, 'calibrated'),
supportsOpen: true,
supportsClose: true,
supportsStop: true,
supportsPosition: true,
});
}
return features;
}
private static climateFeatures(snapshotArg: IBoschShcSnapshot): IBoschShcClimateFeature[] {
const features: IBoschShcClimateFeature[] = [];
for (const device of this.activeDevices(snapshotArg)) {
const roomClimate = this.service(snapshotArg, device, 'RoomClimateControl');
const heatingCircuit = this.service(snapshotArg, device, 'HeatingCircuit');
const climateService = roomClimate || heatingCircuit;
if (!climateService) continue;
const temperature = this.service(snapshotArg, device, 'TemperatureLevel');
const summerMode = roomClimate ? this.booleanState(roomClimate, 'summerMode') : undefined;
features.push({
...this.featureBase(device, 'climate', 'climate', this.uniqueId(device, 'climate', true), device.name || 'Bosch SHC climate'),
serviceId: roomClimate ? 'RoomClimateControl' : 'HeatingCircuit',
hvacMode: summerMode === true ? 'off' : 'heat',
currentTemperature: temperature ? this.numberState(temperature, 'temperature') : null,
targetTemperature: this.numberState(climateService, 'setpointTemperature'),
operationMode: this.stringState(climateService, 'operationMode') ?? undefined,
boostMode: roomClimate ? this.booleanState(roomClimate, 'boostMode') : undefined,
low: roomClimate ? this.booleanState(roomClimate, 'low') : undefined,
summerMode,
supportsBoostMode: roomClimate ? this.booleanState(roomClimate, 'supportsBoostMode') : undefined,
supportedHvacModes: ['heat', 'off'],
});
}
return features;
}
private static switchCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
if (!['turn_on', 'turn_off'].includes(requestArg.service)) {
return { error: `Unsupported Bosch SHC switch service: ${requestArg.service}` };
}
const feature = this.resolveFeature(snapshotArg, requestArg, 'switch') as IBoschShcSwitchFeature | undefined;
if (!feature) {
return { error: 'Bosch SHC switch service calls require a switch entity target, or an unambiguous switch device target.' };
}
const service = this.service(snapshotArg, feature.device, feature.serviceId);
if (!service) return { error: `Bosch SHC switch service ${feature.serviceId} is missing from snapshot.` };
const value = requestArg.service === 'turn_on' ? feature.onValue : feature.offValue;
return this.putServiceState(feature.device, service, { [feature.stateKey]: value }, requestArg);
}
private static lightCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
if (!['turn_on', 'turn_off'].includes(requestArg.service)) {
return { error: `Unsupported Bosch SHC light service: ${requestArg.service}` };
}
const feature = this.resolveFeature(snapshotArg, requestArg, 'light') as IBoschShcLightFeature | undefined;
if (!feature) {
return { error: 'Bosch SHC light service calls require a light entity target, or an unambiguous light device target.' };
}
const switchService = this.service(snapshotArg, feature.device, feature.switchServiceId);
if (!switchService) return { error: `Bosch SHC light service ${feature.switchServiceId} is missing from snapshot.` };
const patch: Record<string, unknown> = feature.switchServiceId === 'BinarySwitch'
? { on: requestArg.service === 'turn_on' }
: { switchState: requestArg.service === 'turn_on' ? 'ON' : 'OFF' };
if (requestArg.service === 'turn_on') {
const brightness = requestArg.data?.brightness;
const brightnessPct = requestArg.data?.brightness_pct;
if (brightness !== undefined && !this.isByte(brightness)) {
return { error: 'Bosch SHC light brightness must be an integer between 0 and 255.' };
}
if (brightnessPct !== undefined && !this.isPercent(brightnessPct)) {
return { error: 'Bosch SHC light brightness_pct must be an integer between 0 and 100.' };
}
if (brightness !== undefined || brightnessPct !== undefined) {
const brightnessService = this.service(snapshotArg, feature.device, 'MultiLevelSwitch');
if (!brightnessService) return { error: 'Bosch SHC light brightness is not supported by this device.' };
const level = typeof brightnessPct === 'number' ? brightnessPct : Math.round((brightness as number) * 100 / 255);
return this.putServiceState(feature.device, brightnessService, { level }, requestArg);
}
}
return this.putServiceState(feature.device, switchService, patch, requestArg);
}
private static coverCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
const supported = ['open_cover', 'close_cover', 'stop_cover', 'set_cover_position'];
if (!supported.includes(requestArg.service)) {
return { error: `Unsupported Bosch SHC cover service: ${requestArg.service}` };
}
const feature = this.resolveFeature(snapshotArg, requestArg, 'cover') as IBoschShcCoverFeature | undefined;
if (!feature) {
return { error: 'Bosch SHC cover service calls require a cover entity target, or an unambiguous cover device target.' };
}
const service = this.service(snapshotArg, feature.device, 'ShutterControl');
if (!service) return { error: 'Bosch SHC ShutterControl service is missing from snapshot.' };
if (requestArg.service === 'open_cover') return this.putServiceState(feature.device, service, { level: 1.0 }, requestArg);
if (requestArg.service === 'close_cover') return this.putServiceState(feature.device, service, { level: 0.0 }, requestArg);
if (requestArg.service === 'stop_cover') return this.putServiceState(feature.device, service, { operationState: 'STOPPED' }, requestArg);
const position = requestArg.data?.position;
if (!this.isPercent(position)) {
return { error: 'Bosch SHC set_cover_position requires data.position as an integer between 0 and 100.' };
}
return this.putServiceState(feature.device, service, { level: position / 100 }, requestArg);
}
private static climateCommand(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest): IBoschShcModeledCommand | { error: string } | undefined {
if (requestArg.service !== 'set_temperature') {
return { error: `Unsupported Bosch SHC climate service: ${requestArg.service}` };
}
const feature = this.resolveFeature(snapshotArg, requestArg, 'climate') as IBoschShcClimateFeature | undefined;
if (!feature) {
return { error: 'Bosch SHC climate service calls require a climate entity target, or an unambiguous climate device target.' };
}
const temperature = requestArg.data?.temperature;
if (typeof temperature !== 'number' || !Number.isFinite(temperature)) {
return { error: 'Bosch SHC set_temperature requires data.temperature as a finite number.' };
}
const service = this.service(snapshotArg, feature.device, feature.serviceId);
if (!service) return { error: `Bosch SHC climate service ${feature.serviceId} is missing from snapshot.` };
return this.putServiceState(feature.device, service, { setpointTemperature: temperature }, requestArg);
}
private static resolveFeature(snapshotArg: IBoschShcSnapshot, requestArg: IServiceCallRequest, platformArg: TBoschShcFeature['platform']): TBoschShcFeature | undefined {
const features = this.features(snapshotArg).filter((featureArg) => featureArg.platform === platformArg);
if (requestArg.target.entityId) {
const entities = this.toEntities(snapshotArg);
const entity = entities.find((entityArg) => entityArg.id === requestArg.target.entityId);
return features.find((featureArg) => featureArg.uniqueId === entity?.uniqueId);
}
if (requestArg.target.deviceId) {
const deviceFeatures = features.filter((featureArg) => this.deviceId(featureArg.device) === requestArg.target.deviceId);
return deviceFeatures.length === 1 ? deviceFeatures[0] : undefined;
}
return undefined;
}
private static putServiceState(deviceArg: IBoschShcDevice, serviceArg: IBoschShcDeviceService, patchArg: Record<string, unknown>, requestArg: IServiceCallRequest): IBoschShcModeledCommand {
const stateType = typeof serviceArg.state?.['@type'] === 'string' ? serviceArg.state['@type'] : undefined;
return {
action: 'put_device_service_state',
method: 'PUT',
path: `/devices/${this.encodeDeviceId(deviceArg.id)}/services/${serviceArg.id}/state`,
body: stateType ? { '@type': stateType, ...patchArg } : patchArg,
deviceId: deviceArg.id,
serviceId: serviceArg.id,
domain: requestArg.domain,
service: requestArg.service,
};
}
private static sensorFeature(deviceArg: IBoschShcDevice, keyArg: string, valueArg: string | number | null, serviceArg: IBoschShcDeviceService, attributesArg: Record<string, unknown> = {}): IBoschShcSensorFeature {
const metadata = sensorMetadata[keyArg] || { name: this.humanize(keyArg) };
void serviceArg;
return {
...this.featureBase(deviceArg, 'sensor', keyArg, this.uniqueId(deviceArg, keyArg), `${deviceArg.name || 'Bosch SHC device'} ${metadata.name.toLowerCase()}`),
nativeValue: valueArg,
unit: metadata.unit,
deviceClass: metadata.deviceClass,
stateClass: metadata.stateClass,
attributes: attributesArg,
};
}
private static featureBase<TPlatform extends TBoschShcFeature['platform']>(deviceArg: IBoschShcDevice, platformArg: TPlatform, aliasArg: string, uniqueIdArg: string, nameArg: string): IBoschShcFeatureBase & { platform: TPlatform } {
return {
platform: platformArg,
id: `${platformArg}_${this.slug(aliasArg)}`,
alias: aliasArg,
uniqueId: uniqueIdArg,
name: nameArg,
deviceId: deviceArg.id,
device: deviceArg,
available: this.available(deviceArg),
};
}
private static service(snapshotArg: IBoschShcSnapshot, deviceArg: IBoschShcDevice, serviceIdArg: string): IBoschShcDeviceService | undefined {
return deviceArg.services?.find((serviceArg) => serviceArg.id === serviceIdArg)
|| snapshotArg.services?.find((serviceArg) => serviceArg.deviceId === deviceArg.id && serviceArg.id === serviceIdArg);
}
private static activeDevices(snapshotArg: IBoschShcSnapshot): IBoschShcDevice[] {
return snapshotArg.devices.filter((deviceArg) => !deviceArg.deleted);
}
private static hasService(deviceArg: IBoschShcDevice, serviceIdArg: string): boolean {
return Boolean(deviceArg.deviceServiceIds?.includes(serviceIdArg) || deviceArg.services?.some((serviceArg) => serviceArg.id === serviceIdArg));
}
private static stringState(serviceArg: IBoschShcDeviceService, keyArg: string): string | null {
const value = serviceArg.state?.[keyArg];
return typeof value === 'string' ? value : null;
}
private static numberState(serviceArg: IBoschShcDeviceService, keyArg: string): number | null {
const value = serviceArg.state?.[keyArg];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : null;
}
return null;
}
private static numberNestedState(serviceArg: IBoschShcDeviceService, objectKeyArg: string, keyArg: string): number | null {
const record = asRecord(serviceArg.state?.[objectKeyArg]);
const value = record?.[keyArg];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
private static booleanState(serviceArg: IBoschShcDeviceService, keyArg: string): boolean | undefined {
const value = serviceArg.state?.[keyArg];
return typeof value === 'boolean' ? value : undefined;
}
private static batteryWarningLevel(serviceArg: IBoschShcDeviceService): string {
const entry = serviceArg.faults?.entries?.[0];
return typeof entry?.type === 'string' ? entry.type : 'OK';
}
private static coverState(operationStateArg: string | null, positionArg: number | null): TBoschShcCoverState {
if (operationStateArg === 'OPENING') return 'opening';
if (operationStateArg === 'CLOSING') return 'closing';
if (positionArg === 0) return 'closed';
if (typeof positionArg === 'number' && positionArg > 0) return 'open';
if (operationStateArg === 'STOPPED') return 'stopped';
return 'unknown';
}
private static available(deviceArg: IBoschShcDevice): boolean {
return deviceArg.status === undefined || deviceArg.status === 'AVAILABLE';
}
private static manufacturer(deviceArg: IBoschShcDevice): string {
const value = deviceArg.manufacturer || 'Bosch';
return value.toUpperCase() === 'BOSCH' ? 'Bosch' : value;
}
private static uniqueId(deviceArg: IBoschShcDevice, aliasArg: string, defaultArg = false): string {
const base = deviceArg.serial || deviceArg.id;
return defaultArg ? base : `${base}_${aliasArg}`;
}
private static usesDefaultEntityObjectId(featureArg: TBoschShcFeature): boolean {
return featureArg.platform === 'binary_sensor' && featureArg.alias === 'contact'
|| featureArg.platform === 'switch' && featureArg.alias === 'power'
|| featureArg.platform === 'light' && featureArg.alias === 'light'
|| featureArg.platform === 'cover' && featureArg.alias === 'cover'
|| featureArg.platform === 'climate' && featureArg.alias === 'climate';
}
private static controllerUniqueId(snapshotArg: IBoschShcSnapshot): string | undefined {
return formatMac(snapshotArg.uniqueId || snapshotArg.information?.macAddress || '') || snapshotArg.host;
}
private static encodeDeviceId(valueArg: string): string {
return valueArg.replace(/#/g, '%23');
}
private static isByte(valueArg: unknown): valueArg is number {
return typeof valueArg === 'number' && Number.isInteger(valueArg) && valueArg >= 0 && valueArg <= 255;
}
private static isPercent(valueArg: unknown): valueArg is number {
return typeof valueArg === 'number' && Number.isInteger(valueArg) && valueArg >= 0 && valueArg <= 100;
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'bosch_shc';
}
private static humanize(valueArg: string): string {
return valueArg.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
}
}
export const formatMac = (valueArg?: string): string | undefined => {
if (!valueArg) return undefined;
const trimmed = valueArg.trim();
if (/^[0-9a-f]{2}(-[0-9a-f]{2}){5}$/i.test(trimmed)) return trimmed.toLowerCase();
const compact = trimmed.replace(/[:.-]/g, '').toLowerCase();
if (!/^[0-9a-f]{12}$/.test(compact)) return undefined;
return compact.match(/../g)?.join('-');
};
const asRecord = (valueArg: unknown): Record<string, unknown> | undefined => {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg)
? valueArg as Record<string, unknown>
: undefined;
};