diff --git a/README.md b/README.md index d674de3..f276055 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ todo links to the .ts service implementations below | blinds | | | | boiler | | | | chargepoint | [Futurehome Charge](https://www.futurehome.io/en_no/shop/charge) | | -| color_ctrl | | | +| color_ctrl | | ✅ | | complex_alarm_system | | | | door_lock | | | | doorman | | | diff --git a/futurehome/CHANGELOG.md b/futurehome/CHANGELOG.md index 0eed17b..0047781 100644 --- a/futurehome/CHANGELOG.md +++ b/futurehome/CHANGELOG.md @@ -1,9 +1,5 @@ -## 1.0.0 +## 1.0.0 (release notes from the future!) - Initial release - -## 0.0.3 - -- Added initial version of the add-on code. diff --git a/futurehome/config.yaml b/futurehome/config.yaml index 05b5eb6..6f062c2 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.0.24" +version: "0.0.25" 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 22eefdd..4aa0eab 100644 --- a/futurehome/src/ha/publish_device.ts +++ b/futurehome/src/ha/publish_device.ts @@ -3,6 +3,7 @@ import { VinculumPd7Device, VinculumPd7Service } from "../fimp/vinculum_pd7_devi import { log } from "../logger"; import { basic__components } from "../services/basic"; import { battery__components } from "../services/battery"; +import { color_ctrl__components } from "../services/color_ctrl"; import { fan_ctrl__components } from "../services/fan_ctrl"; import { out_bin_switch__components } from "../services/out_bin_switch"; import { out_lvl_switch__components } from "../services/out_lvl_switch"; @@ -82,7 +83,7 @@ type HaDeviceConfig = { qos: number, } -export type HaComponent = SensorComponent | BinarySensorComponent | SwitchComponent | NumberComponent | ClimateComponent | SelectComponent | FanComponent; +export type HaComponent = SensorComponent | BinarySensorComponent | SwitchComponent | NumberComponent | ClimateComponent | SelectComponent | FanComponent | LightComponent; // Device class supported values: https://www.home-assistant.io/integrations/homeassistant/#device-class @@ -173,7 +174,28 @@ export type FanComponent = { preset_mode_state_template: string; state_value_template: string; preset_mode_value_template: string; -} +}; + +/// https://www.home-assistant.io/integrations/light.mqtt/ +export type LightComponent = { + unique_id: string; + // platform + p: "light"; + command_topic?: string; + state_topic?: string; + state_value_template?: string; + rgb_command_topic?: string; + rgb_state_topic?: string; + rgb_value_template?: string; + brightness_state_topic?: string; + brightness_value_template?: string; + optimistic?: boolean; + color_temp_command_topic?: string; + color_temp_state_topic?: string; + color_temp_value_template?: string; + min_mireds?: number; + max_mireds?: number; +}; export type ServiceComponentsCreationResult = { components: { [key: string]: HaComponent }; @@ -187,6 +209,7 @@ const serviceHandlers: { } = { basic: basic__components, battery: battery__components, + color_ctrl: color_ctrl__components, fan_ctrl: fan_ctrl__components, out_bin_switch: out_bin_switch__components, out_lvl_switch: out_lvl_switch__components, diff --git a/futurehome/src/services/color_ctrl.ts b/futurehome/src/services/color_ctrl.ts new file mode 100644 index 0000000..5acba4d --- /dev/null +++ b/futurehome/src/services/color_ctrl.ts @@ -0,0 +1,206 @@ +import { sendFimpMsg } from "../fimp/fimp"; +import { VinculumPd7Device, VinculumPd7Service } from "../fimp/vinculum_pd7_device"; +import { ServiceComponentsCreationResult, CommandHandlers, LightComponent } from "../ha/publish_device"; + +export function color_ctrl__components( + topicPrefix: string, + device: VinculumPd7Device, + svc: VinculumPd7Service +): ServiceComponentsCreationResult | undefined { + const supComponents: string[] = svc.props?.sup_components ?? []; + + if (!supComponents.length) { + return undefined; // No supported components, nothing to expose + } + + // Check if we have RGB support (minimum requirement for a useful light) + const hasRgb = supComponents.includes('red') && supComponents.includes('green') && supComponents.includes('blue'); + if (!hasRgb) { + return undefined; // No RGB support, skip this service + } + + // Determine supported color modes based on available components + const supportedColorModes: string[] = []; + + if (hasRgb) { + supportedColorModes.push('rgb'); + } + + // Check for color temperature support (Zigbee style with 'temp' component) + if (supComponents.includes('temp')) { + supportedColorModes.push('color_temp'); + } + + // Check for dual-white support (Z-Wave style with warm_w and cold_w) + if (supComponents.includes('warm_w') && supComponents.includes('cold_w')) { + supportedColorModes.push('color_temp'); + } + + // Command topics + const commandTopic = `${topicPrefix}${svc.addr}/command`; + const rgbCommandTopic = `${topicPrefix}${svc.addr}/rgb/command`; + const colorTempCommandTopic = `${topicPrefix}${svc.addr}/color_temp/command`; + + // State topic (shared with other components of the same device) + const stateTopic = `${topicPrefix}/state`; + + // Create the light component configuration + const lightComponent: LightComponent = { + unique_id: svc.addr, + p: 'light', + + // Basic on/off control + command_topic: commandTopic, + state_topic: stateTopic, + state_value_template: `{{ 'ON' if (value_json['${svc.addr}'].red > 0 or value_json['${svc.addr}'].green > 0 or value_json['${svc.addr}'].blue > 0) else 'OFF' }}`, + + // RGB color control + rgb_command_topic: rgbCommandTopic, + rgb_state_topic: stateTopic, + rgb_value_template: `{{ value_json['${svc.addr}'].red }},{{ value_json['${svc.addr}'].green }},{{ value_json['${svc.addr}'].blue }}`, + + // Brightness support (derived from RGB values) + brightness_state_topic: stateTopic, + brightness_value_template: `{{ [value_json['${svc.addr}'].red, value_json['${svc.addr}'].green, value_json['${svc.addr}'].blue] | max }}`, + + optimistic: true, + }; + + // Add color temperature support if available + if (supportedColorModes.includes('color_temp')) { + if (supComponents.includes('temp')) { + // Zigbee style - direct temperature value in Kelvin + lightComponent.color_temp_command_topic = colorTempCommandTopic; + lightComponent.color_temp_state_topic = stateTopic; + lightComponent.color_temp_value_template = `{{ (1000000 / value_json['${svc.addr}'].temp) | round(0) }}`; // Convert Kelvin to mireds + lightComponent.min_mireds = 153; // ~6500K + lightComponent.max_mireds = 370; // ~2700K + } else if (supComponents.includes('warm_w') && supComponents.includes('cold_w')) { + // Z-Wave style - warm/cold white mix + lightComponent.color_temp_command_topic = colorTempCommandTopic; + lightComponent.color_temp_state_topic = stateTopic; + // Estimate color temperature from warm_w/cold_w ratio + lightComponent.color_temp_value_template = `{{ (153 + (217 * (value_json['${svc.addr}'].warm_w / (value_json['${svc.addr}'].warm_w + value_json['${svc.addr}'].cold_w + 0.001)))) | round(0) }}`; + lightComponent.min_mireds = 153; // ~6500K (cold) + lightComponent.max_mireds = 370; // ~2700K (warm) + } + } + + // Command handlers + const commandHandlers: CommandHandlers = { + // Basic on/off command + [commandTopic]: async (payload: string) => { + if (payload === 'ON') { + // Turn on with white color (all components at max) + const colorMap: Record = { + red: 255, + green: 255, + blue: 255, + }; + + await sendFimpMsg({ + address: svc.addr!, + service: "color_ctrl", + cmd: "cmd.color.set", + val_t: "int_map", + val: colorMap, + }); + } else if (payload === 'OFF') { + // Turn off (all components to 0) + const colorMap: Record = {}; + supComponents.forEach(component => { + colorMap[component] = 0; + }); + + await sendFimpMsg({ + address: svc.addr!, + service: "color_ctrl", + cmd: "cmd.color.set", + val_t: "int_map", + val: colorMap, + }); + } + }, + + // RGB color command + [rgbCommandTopic]: async (payload: string) => { + const parts = payload.split(','); + if (parts.length !== 3) return; + + const red = parseInt(parts[0], 10); + const green = parseInt(parts[1], 10); + const blue = parseInt(parts[2], 10); + + if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) return; + if (red < 0 || red > 255 || green < 0 || green > 255 || blue < 0 || blue > 255) return; + + const colorMap: Record = { + red, + green, + blue, + }; + + await sendFimpMsg({ + address: svc.addr!, + service: "color_ctrl", + cmd: "cmd.color.set", + val_t: "int_map", + val: colorMap, + }); + }, + }; + + // Add color temperature command handler if supported + if (supportedColorModes.includes('color_temp')) { + commandHandlers[colorTempCommandTopic] = async (payload: string) => { + const mireds = parseInt(payload, 10); + if (Number.isNaN(mireds) || mireds < 153 || mireds > 370) return; + + if (supComponents.includes('temp')) { + // Zigbee style - convert mireds to Kelvin + const kelvin = Math.round(1000000 / mireds); + + const colorMap: Record = { + temp: kelvin, + }; + + await sendFimpMsg({ + address: svc.addr!, + service: "color_ctrl", + cmd: "cmd.color.set", + val_t: "int_map", + val: colorMap, + }); + } else if (supComponents.includes('warm_w') && supComponents.includes('cold_w')) { + // Z-Wave style - convert mireds to warm/cold white mix + // Linear interpolation between cold (153 mireds) and warm (370 mireds) + const warmRatio = (mireds - 153) / (370 - 153); + const coldRatio = 1 - warmRatio; + + const colorMap: Record = { + warm_w: Math.round(warmRatio * 255), + cold_w: Math.round(coldRatio * 255), + // Turn off RGB when using white + red: 0, + green: 0, + blue: 0, + }; + + await sendFimpMsg({ + address: svc.addr!, + service: "color_ctrl", + cmd: "cmd.color.set", + val_t: "int_map", + val: colorMap, + }); + } + }; + } + + return { + components: { + [svc.addr]: lightComponent, + }, + commandHandlers, + }; +}