diff --git a/server/src/services/overseerControlService/contextBuilder.js b/server/src/services/overseerControlService/contextBuilder.js index 8ba450ee..43ad43e4 100644 --- a/server/src/services/overseerControlService/contextBuilder.js +++ b/server/src/services/overseerControlService/contextBuilder.js @@ -21,9 +21,25 @@ function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster ); const entities = Array.isArray(homeAssistantState?.entities) ? homeAssistantState.entities : []; if (entities.length) { - lines.push('home_assistant_entities:'); + lines.push('home_assistant_room_lights:'); entities.slice(0, 24).forEach((entity) => { - lines.push(`- ${entity.id} (${entity.type || 'entity'}) state=${entity.state || 'unknown'} available=${entity.available ? 'yes' : 'no'}`); + const haType = String(entity.type || 'entity'); + const details = [ + 'kind=room_light', + `ha_domain=${haType}`, + `state=${entity.state || 'unknown'}`, + `available=${entity.available ? 'yes' : 'no'}`, + ]; + + // All configured Home Assistant controls in this list represent room + // lighting from the overseer's point of view, including outlet-backed + // lamps that Home Assistant exposes as switches. The original HA domain is + // still shown because only true light-domain entities can accept color + // payloads; switch-domain lamps remain valid on/off room lights. + details.push(`supports_color=${entity.supportsColor ? 'yes' : 'no'}`); + if (entity.colorHex) details.push(`color=${entity.colorHex}`); + + lines.push(`- ${entity.id} ${details.join(' ')}`); }); } const roverLines = (Array.isArray(roster) ? roster : []).slice(0, 6).map((rover) => { diff --git a/server/src/services/overseerControlService/tools/haSetEntity.js b/server/src/services/overseerControlService/tools/haSetEntity.js index 8e2da124..e9cdf7a9 100644 --- a/server/src/services/overseerControlService/tools/haSetEntity.js +++ b/server/src/services/overseerControlService/tools/haSetEntity.js @@ -1,7 +1,7 @@ module.exports = { id: 'ha_set_entity', signature: 'ha_set_entity(entity_id, state)', - description: 'Set Home Assistant controllable entity on/off.', + description: 'Turn a configured room light or Home Assistant controllable entity on/off, including switch-backed lamps.', parameters: { type: 'object', properties: { @@ -25,6 +25,12 @@ module.exports = { async execute({ args = {}, homeAssistantService }) { const entityId = String(args?.entity_id || args?.entityId || '').trim(); if (!entityId) throw new Error('ha_set_entity requires args.entity_id'); + + // Configured Home Assistant entities are the room-control surface the + // overseer is allowed to use. Some physical room lights are exposed by Home + // Assistant as switches because they are outlet-backed lamps, so this tool + // intentionally permits every configured entity for on/off control instead + // of limiting itself to HA's light domain. const allowed = new Set( (homeAssistantService.getState()?.entities || []).map((entry) => String(entry?.id || '')).filter(Boolean), ); diff --git a/server/src/services/overseerControlService/tools/haSetLightColor.js b/server/src/services/overseerControlService/tools/haSetLightColor.js new file mode 100644 index 00000000..639b2975 --- /dev/null +++ b/server/src/services/overseerControlService/tools/haSetLightColor.js @@ -0,0 +1,85 @@ +function normalizeColorHex(value) { + const raw = String(value || '').trim(); + const withoutHash = raw.startsWith('#') ? raw.slice(1) : raw; + + // Overseer tool calls should be strict here because invalid color strings + // otherwise travel all the way to the Home Assistant runtime before failing. + // Keeping the accepted format to CSS-style hex also gives the model one clear + // representation to use instead of making it choose between RGB, HSL, names, + // or Home Assistant-specific payloads. + if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(withoutHash)) { + throw new Error('ha_set_light_color requires args.color_hex as #rgb or #rrggbb'); + } + + // Expand shorthand colors before handing them to the shared HA service so + // logs, downstream payloads, and future tool results all use the same stable + // six-digit color shape. + const expanded = + withoutHash.length === 3 + ? withoutHash + .split('') + .map((char) => char + char) + .join('') + : withoutHash; + return `#${expanded.toLowerCase()}`; +} + +function findConfiguredEntity(homeAssistantService, entityId) { + const entities = homeAssistantService.getState()?.entities || []; + + // The overseer only gets to act on entities that the local config already + // exposes. This mirrors ha_set_entity and prevents a model-generated entity id + // from becoming an arbitrary Home Assistant service call. + return entities.find((entry) => String(entry?.id || '') === entityId) || null; +} + +module.exports = { + id: 'ha_set_light_color', + signature: 'ha_set_light_color(entity_id, color_hex)', + description: 'Set a configured Home Assistant color-capable light to a hex color.', + parameters: { + type: 'object', + properties: { + entity_id: { type: 'string', minLength: 1 }, + color_hex: { + type: 'string', + pattern: '^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$', + description: 'CSS-style RGB hex color such as #ff0000, #00ff88, or #f0a.', + }, + }, + required: ['entity_id', 'color_hex'], + additionalProperties: false, + }, + availability(ctx = {}) { + const mode = String(ctx.mode || ''); + if (mode === 'admin' || mode === 'lockdown') { + return { available: false, reason: `policy_lock:site_mode_${mode}` }; + } + if (ctx.homeAssistantState?.lightPolicy?.lockedOn) { + return { available: false, reason: 'policy_lock:lights_locked_on' }; + } + if (!ctx.homeAssistantState?.connected) return { available: false, reason: 'unavailable' }; + return { available: true, reason: null }; + }, + async execute({ args = {}, homeAssistantService }) { + const entityId = String(args?.entity_id || args?.entityId || '').trim(); + if (!entityId) throw new Error('ha_set_light_color requires args.entity_id'); + + // Accepting args.color as a compatibility alias keeps manual/internal calls + // forgiving, while the public tool schema still teaches the model to send + // the clearer color_hex argument. + const colorHex = normalizeColorHex(args?.color_hex ?? args?.colorHex ?? args?.color); + const entity = findConfiguredEntity(homeAssistantService, entityId); + if (!entity) throw new Error('ha_set_light_color entity_id not configured'); + + // setLightColor already requires a HA light, but checking the normalized + // entity state here gives the overseer a more specific error and blocks + // switch/outlet-backed lamps before they become invalid HA color commands. + if (entity.type !== 'light') throw new Error('ha_set_light_color requires a light entity'); + if (entity.available === false) throw new Error('ha_set_light_color light entity unavailable'); + if (!entity.supportsColor) throw new Error('ha_set_light_color light does not report color support'); + + await homeAssistantService.setLightColor(entityId, colorHex); + return { ok: true, entity_id: entityId, color_hex: colorHex }; + }, +}; diff --git a/server/src/services/overseerControlService/tools/index.js b/server/src/services/overseerControlService/tools/index.js index bdd2d556..1fa9de0f 100644 --- a/server/src/services/overseerControlService/tools/index.js +++ b/server/src/services/overseerControlService/tools/index.js @@ -10,6 +10,7 @@ const neatoSendHome = require('./neatoSendHome'); // const neatoLocate = require('./neatoLocate'); const neatoClearErrors = require('./neatoClearErrors'); const haSetEntity = require('./haSetEntity'); +const haSetLightColor = require('./haSetLightColor'); const buttonBoxAddCount = require('./buttonBoxAddCount'); const TOOL_DEFINITIONS = [ @@ -25,6 +26,7 @@ const TOOL_DEFINITIONS = [ // neatoLocate, neatoClearErrors, haSetEntity, + haSetLightColor, buttonBoxAddCount, ]; const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));