diff --git a/server/src/services/homeAssistantService/runtimeEngine.js b/server/src/services/homeAssistantService/runtimeEngine.js index e389e0ef..66acc316 100644 --- a/server/src/services/homeAssistantService/runtimeEngine.js +++ b/server/src/services/homeAssistantService/runtimeEngine.js @@ -80,6 +80,27 @@ function normalizeRgbColor(color) { function createRuntimeEngine(deps) { const { logger, enabled, haConfig, callHomeAssistantService } = deps; + async function turnOnLightAtFullBrightness(entityId, serviceData = {}) { + /* + Every server-owned interaction that turns on or changes a light must + also restore it to full brightness. Home Assistant remembers a bulb's + previous brightness, so sending only a color or color temperature can + otherwise make a light appear unexpectedly dim even though this service + requested an on-state. + + Keeping this rule in one helper makes it apply consistently to ordinary + on commands, RGB changes, white-temperature changes, bulk operations, + random scenes, and lock-on behavior. brightness_pct is deliberately + written after the caller's service data so future call sites cannot + accidentally override the service-wide 100 percent requirement. + */ + await callHomeAssistantService('light', 'turn_on', { + entity_id: entityId, + ...serviceData, + brightness_pct: 100, + }); + } + function emitUpdate(getState) { events.emit('update', getState()); } @@ -143,7 +164,14 @@ function createRuntimeEngine(deps) { const domain = String(meta.domain || (meta.type === 'light' ? 'light' : 'switch')).toLowerCase(); const service = nextState === 'on' ? 'turn_on' : 'turn_off'; const source = String(options?.source || 'unknown'); - await callHomeAssistantService(domain, service, { entity_id: entityId }); + if (domain === 'light' && service === 'turn_on') { + await turnOnLightAtFullBrightness(entityId); + } else { + // Off commands and non-light domains do not accept a meaningful light + // brightness value, so their existing Home Assistant payload stays + // intentionally unchanged. + await callHomeAssistantService(domain, service, { entity_id: entityId }); + } logger.info('Issued Home Assistant command', { entityId, domain, service, source }); } @@ -467,7 +495,7 @@ function createRuntimeEngine(deps) { if (!runtime.connection) throw new Error('Home Assistant not connected'); const normalized = normalizeRgbColor(color); - await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, rgb_color: normalized }); + await turnOnLightAtFullBrightness(entityId, { rgb_color: normalized }); logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized }); } @@ -481,7 +509,7 @@ function createRuntimeEngine(deps) { const normalizedKelvin = Number.isFinite(nextKelvin) ? Math.max(2000, Math.min(6500, Math.round(nextKelvin))) : DEFAULT_WHITE_KELVIN; - await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, color_temp_kelvin: normalizedKelvin }); + await turnOnLightAtFullBrightness(entityId, { color_temp_kelvin: normalizedKelvin }); logger.info('Issued Home Assistant white command', { entityId, colorTempKelvin: normalizedKelvin }); } diff --git a/server/src/services/homeAssistantService/runtimeEngine.test.js b/server/src/services/homeAssistantService/runtimeEngine.test.js new file mode 100644 index 00000000..e56af405 --- /dev/null +++ b/server/src/services/homeAssistantService/runtimeEngine.test.js @@ -0,0 +1,83 @@ +// Home Assistant Runtime Engine Tests +// Purpose: Verifies the service-wide full-brightness rule for light commands. +// Scope: Exercises injected Home Assistant calls without opening a real connection or starting the server. + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { createRuntimeEngine } = require('./runtimeEngine'); +const { entityConfig, entityState, runtime } = require('./state'); + +function createHarness() { + const calls = []; + const engine = createRuntimeEngine({ + enabled: true, + haConfig: { whiteKelvin: 4000 }, + callHomeAssistantService: async (domain, service, serviceData) => { + calls.push({ domain, service, serviceData }); + }, + // These tests only verify outbound service payloads. A no-op logger keeps + // the harness faithful to the runtime dependency contract without adding + // unrelated output to the test run. + logger: { + info() {}, + warn() {}, + }, + }); + + return { calls, engine }; +} + +test('light interactions force full brightness without changing switches or off commands', async (t) => { + const { calls, engine } = createHarness(); + + /* + runtimeEngine uses the shared entity registry populated from configuration + in production. Seed the smallest representative registry here and restore + the shared state afterward so this focused unit test cannot leak state into + other Home Assistant tests added later. + */ + entityConfig.clear(); + entityState.clear(); + entityConfig.set('light.room', { id: 'light.room', type: 'light', domain: 'light' }); + entityConfig.set('switch.lamp', { id: 'switch.lamp', type: 'switch', domain: 'switch' }); + runtime.connection = {}; + t.after(() => { + entityConfig.clear(); + entityState.clear(); + runtime.connection = null; + }); + + await engine.setEntityState('light.room', 'on'); + await engine.setLightColor('light.room', [12, 34, 56]); + await engine.setLightWhite('light.room', 4500); + await engine.setEntityState('light.room', 'off'); + await engine.setEntityState('switch.lamp', 'on'); + + assert.deepEqual(calls, [ + { + domain: 'light', + service: 'turn_on', + serviceData: { entity_id: 'light.room', brightness_pct: 100 }, + }, + { + domain: 'light', + service: 'turn_on', + serviceData: { entity_id: 'light.room', rgb_color: [12, 34, 56], brightness_pct: 100 }, + }, + { + domain: 'light', + service: 'turn_on', + serviceData: { entity_id: 'light.room', color_temp_kelvin: 4500, brightness_pct: 100 }, + }, + { + domain: 'light', + service: 'turn_off', + serviceData: { entity_id: 'light.room' }, + }, + { + domain: 'switch', + service: 'turn_on', + serviceData: { entity_id: 'switch.lamp' }, + }, + ]); +});