111
This commit is contained in:
@@ -5,7 +5,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import vm from 'node:vm'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parse, compileScript } from 'vue/compiler-sfc'
|
||||
import { parse, compileScript, compileStyle } from 'vue/compiler-sfc'
|
||||
import { createSSRApp, h, isRef } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
@@ -183,3 +183,47 @@ test('auth and both questionnaires render safely; loading records never show an
|
||||
assert.ok(!html.includes('type="number"')); assert.ok(!html.includes('<textarea'))
|
||||
}
|
||||
})
|
||||
|
||||
test('reduced-motion stops all loading animations rather than making infinite loops faster', () => {
|
||||
const readStylesheet = relative => {
|
||||
const filename = path.join(root, relative)
|
||||
const result = compileStyle({ source: fs.readFileSync(filename, 'utf8'), filename, id: 'test-motion' })
|
||||
assert.deepEqual(result.errors, [], relative)
|
||||
return result.rawResult.root
|
||||
}
|
||||
const stylesheet = readStylesheet('styles/page-polish.css')
|
||||
const reducedMotion = stylesheet.nodes.find(node => node.type === 'atrule'
|
||||
&& node.name === 'media' && node.params === '(prefers-reduced-motion: reduce)')
|
||||
assert.ok(reducedMotion, 'keep the reduced-motion preference supported')
|
||||
const rule = reducedMotion.nodes.find(node => node.type === 'rule'
|
||||
&& ['*', '*::before', '*::after'].every(selector => node.selectors.includes(selector)))
|
||||
assert.ok(rule, 'cover page indicators, device indicators and Vant loading animations')
|
||||
const declarations = Object.fromEntries(rule.nodes.filter(node => node.type === 'decl')
|
||||
.map(node => [node.prop, node]))
|
||||
assert.equal(declarations.animation?.value, 'none')
|
||||
assert.equal(declarations.animation?.important, true)
|
||||
assert.equal(declarations['transition-duration']?.value, '0s')
|
||||
assert.equal(declarations['transition-duration']?.important, true)
|
||||
reducedMotion.walkDecls('animation-duration', declaration => {
|
||||
assert.notEqual(declaration.value, '.01ms', 'never accelerate an infinite loading animation')
|
||||
})
|
||||
|
||||
const loadingRule = stylesheet.nodes.find(node => node.type === 'rule' && node.selector === '.page-loading-mark')
|
||||
assert.ok(loadingRule.nodes.some(node => node.prop === 'animation' && node.value === 'page-spin .9s linear infinite'),
|
||||
'normal motion keeps the existing loading speed')
|
||||
const deviceStyles = readStylesheet('styles/device-control.css')
|
||||
const deviceLoadingRule = deviceStyles.nodes.find(node => node.type === 'rule' && node.selector === '.control-page .operation-spinner')
|
||||
assert.ok(deviceLoadingRule.nodes.some(node => node.prop === 'animation' && node.value === 'operation-spin 0.75s linear infinite'))
|
||||
})
|
||||
|
||||
test('loading indicators keep visible status text even when their animation is stopped', async () => {
|
||||
for (const [name, routePath, message] of [
|
||||
['DashboardView', '/dashboard', '正在读取健康档案与舒睡设备…'],
|
||||
['ProfileView', '/profile', '正在读取您的健康档案…'],
|
||||
]) {
|
||||
const html = await render(name, { loading: true }, routePath)
|
||||
assert.ok(html.includes('page-loading-mark'), name)
|
||||
assert.ok(html.includes(message), name)
|
||||
assert.ok(html.includes('role="status"'), name)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,9 +22,11 @@ const sceneResult = (message, sceneCode = 'sleep') => ({ success: true, status:
|
||||
function environment(overrides = {}) {
|
||||
const intervals = new Map(), timeouts = new Map(), storage = new Map(), disposed = [], mounted = [], toasts = []
|
||||
let id = 0
|
||||
const watchers = []
|
||||
const vue = {
|
||||
ref: value => ({ value }), reactive: value => value,
|
||||
computed: getter => ({ get value() { return getter() } }),
|
||||
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() }, set value(value) { getter.set?.(value) } }),
|
||||
watch: (getter, callback) => { watchers.push({ getter, callback, value: getter() }) },
|
||||
onUnmounted: handler => disposed.push(handler), onMounted: handler => mounted.push(handler),
|
||||
}
|
||||
const healthApi = {
|
||||
@@ -64,7 +66,9 @@ function environment(overrides = {}) {
|
||||
vm.runInNewContext(source, context, { filename: file })
|
||||
return module.exports
|
||||
}
|
||||
return { load, healthApi, intervals, timeouts, toasts, vue, dispose: () => disposed.forEach(fn => fn()), mount: () => mounted.forEach(fn => fn()) }
|
||||
return { load, healthApi, intervals, timeouts, toasts, vue,
|
||||
flushWatches: () => { for (const watcher of watchers) { const value = watcher.getter(); if (value !== watcher.value) { watcher.callback(value, watcher.value); watcher.value = value } } },
|
||||
dispose: () => disposed.forEach(fn => fn()), mount: () => mounted.forEach(fn => fn()) }
|
||||
}
|
||||
|
||||
test('parameter conversion rejects junk and unavailable data; light zero and hardware limits are respected', () => {
|
||||
@@ -240,6 +244,164 @@ const device = (moduleKey, category, entities = [], extra = {}) => ({
|
||||
})
|
||||
const entity = (entityId, domain, state, attributes = {}, available = true) => ({ entityId, domain, state, attributes, available })
|
||||
|
||||
const standardLight = (id = 'light.strip', mode = 'color_temp', attributes = {}) => device('', 'Lighting', [entity(id, 'light', 'on', {
|
||||
supported_color_modes: [mode], min_color_temp_kelvin: 2700, max_color_temp_kelvin: 6500, supported_features: 4,
|
||||
brightness: 128, color_temp_kelvin: 3000, effect_list: ['阅读', '助眠'], effect: '阅读', ...attributes,
|
||||
})], { deviceId: id, primaryControlEntityId: id })
|
||||
|
||||
test('configured strip defaults to its supported sleep effect without inventing a reported effect or sending on page load', () => {
|
||||
const env = environment(), { useLightControls } = env.load('devices/lighting/useLightControls')
|
||||
const item = standardLight('light.strip', 'color_temp', { effect: null, effect_list: ['阅读模式', '助眠模式'] })
|
||||
item.moduleKey = 'sleep-light-strip'; item.defaultLightEffect = '助眠模式'
|
||||
let writes = 0
|
||||
const hook = useLightControls(() => item, () => false, async () => { writes++; return true })
|
||||
assert.equal(hook.effectTarget.value, '助眠模式'); assert.equal(hook.defaultEffect.value, '助眠模式')
|
||||
assert.equal(hook.capability.value.effect, undefined); assert.equal(writes, 0)
|
||||
item.entities[0].attributes.effect = '阅读模式'
|
||||
assert.equal(hook.effectTarget.value, '阅读模式'); assert.equal(hook.capability.value.effect, '阅读模式')
|
||||
item.defaultLightEffect = '未声明模式'; item.entities[0].attributes.effect = null
|
||||
assert.equal(hook.effectTarget.value, ''); assert.equal(hook.defaultEffect.value, '')
|
||||
})
|
||||
|
||||
test('multiple lights use only their own fade status and continue polling when the original light has stopped', async () => {
|
||||
let current = { entityId: 'light.primary', running: false, brightnessPercent: 0, mode: 'none', lights: [
|
||||
{ entityId: 'light.primary', running: false, brightnessPercent: 0, mode: 'none' },
|
||||
{ entityId: 'light.strip', running: true, brightnessPercent: 46, mode: 'sleep' },
|
||||
] }
|
||||
const env = environment({ lightTransitionStatus: async () => current })
|
||||
const helpers = env.load('devices/lighting/transitions'), { deviceSummary } = env.load('devices/deviceSummary')
|
||||
const strip = standardLight(); strip.moduleKey = 'sleep-light-strip'
|
||||
assert.equal(helpers.lightTransitionForDevice(strip, current).brightnessPercent, 46)
|
||||
assert.equal(helpers.anyLightTransitionRunning(current), true)
|
||||
assert.equal(helpers.lightTransitionForDevice(standardLight('light.unrelated'), current), undefined)
|
||||
const summary = deviceSummary(strip, { light: current })
|
||||
assert.equal(summary.statusText, '助眠渐暗中'); assert.equal(summary.metricText, '亮度 46%')
|
||||
let ended = 0
|
||||
const params = env.load('devices/parameterRegistry').createDeviceParameterState()
|
||||
params.lightBrightness = 10
|
||||
const hook = env.load('composables/useDeviceTransitions').useDeviceTransitions(params, async () => { ended++ })
|
||||
await hook.syncLightTransitionStatus()
|
||||
assert.equal(env.intervals.size, 1); assert.equal(params.lightBrightness, 10)
|
||||
current = { ...current, lights: current.lights.map(light => ({ ...light, running: false, brightnessPercent: 0 })) }
|
||||
for (const tick of [...env.intervals.values()]) tick()
|
||||
await flush(); assert.equal(env.intervals.size, 0); assert.equal(ended, 1)
|
||||
env.dispose()
|
||||
})
|
||||
|
||||
test('explicit entity markers and legacy single-light responses cannot leak into another lamp', () => {
|
||||
const helpers = environment().load('devices/lighting/transitions')
|
||||
const primary = standardLight('light.primary'); primary.moduleKey = 'lighting'
|
||||
const strip = standardLight()
|
||||
const legacy = { running: true, brightnessPercent: 50, mode: 'sleep' }
|
||||
assert.equal(helpers.lightTransitionForDevice(primary, legacy), legacy)
|
||||
assert.equal(helpers.lightTransitionForDevice(strip, legacy), undefined)
|
||||
assert.equal(helpers.lightTransitionForDevice(primary, { ...legacy, entityId: 'light.strip' }), undefined)
|
||||
})
|
||||
|
||||
test('a failed new-light fade is not reported as success merely because the original light finished', async () => {
|
||||
let current = { entityId: 'light.primary', running: true, success: true, brightnessPercent: 1, mode: 'sleep', lights: [
|
||||
{ entityId: 'light.primary', running: true, success: true, brightnessPercent: 1, mode: 'sleep' },
|
||||
{ entityId: 'light.strip', running: true, success: true, brightnessPercent: 2, mode: 'sleep' },
|
||||
] }
|
||||
const env = environment({ lightTransitionStatus: async () => current })
|
||||
const hook = env.load('composables/useDeviceControl').useDeviceControl(); await hook.loadStatus()
|
||||
current = { ...current, running: false, message: '原灯渐暗完成', lights: [
|
||||
{ entityId: 'light.primary', running: false, success: true, mode: 'sleep', message: '原灯渐暗完成' },
|
||||
{ entityId: 'light.strip', running: false, success: false, mode: 'sleep', message: '灯带离线,渐变中断' },
|
||||
] }
|
||||
for (const tick of [...env.intervals.values()]) tick()
|
||||
await flush(); await flush()
|
||||
assert.equal(hook.actionFeedback.value.type, 'fail')
|
||||
assert.equal(hook.actionFeedback.value.message, '灯带离线,渐变中断')
|
||||
env.dispose()
|
||||
})
|
||||
|
||||
test('standard light capabilities are per entity, not per category or another lamps configured parameters', () => {
|
||||
const { lightCapabilities } = environment().load('devices/lighting/capabilities')
|
||||
const item = standardLight(), capability = lightCapabilities(item)
|
||||
assert.equal(capability.available, true); assert.equal(capability.canDim, true); assert.equal(capability.brightnessPercent, 50)
|
||||
assert.equal(capability.temperatureRange.min, 2700); assert.equal(capability.temperatureRange.max, 6500)
|
||||
assert.equal(capability.colorTemperature, 3000); assert.equal(capability.effect, '阅读')
|
||||
const simple = lightCapabilities(standardLight('light.simple', 'onoff', { effect_list: [], supported_features: 0 }))
|
||||
assert.equal(simple.canDim, false); assert.equal(simple.temperatureRange, undefined); assert.equal(simple.effects.length, 0)
|
||||
const dim = lightCapabilities(standardLight('light.dim', 'brightness'))
|
||||
assert.equal(dim.canDim, true); assert.equal(dim.temperatureRange, undefined)
|
||||
const rgb = lightCapabilities(standardLight('light.rgb', 'rgb'))
|
||||
assert.equal(rgb.canDim, true); assert.equal(rgb.temperatureRange, undefined)
|
||||
})
|
||||
|
||||
test('light unknown main state, foreign primary and missing capability cannot borrow online auxiliary data', () => {
|
||||
const { lightCapabilities } = environment().load('devices/lighting/capabilities'), item = standardLight()
|
||||
item.entities.push(entity('light.foreign', 'light', 'on', { supported_color_modes: ['color_temp'] }))
|
||||
item.entities[0].state = 'unknown'; assert.equal(lightCapabilities(item).available, false)
|
||||
assert.equal(lightCapabilities(item).brightnessPercent, undefined); assert.equal(lightCapabilities(item).colorTemperature, undefined)
|
||||
item.entities[0].state = 'on'; item.entities[0].available = false
|
||||
assert.equal(lightCapabilities(item).available, false)
|
||||
item.primaryControlEntityId = 'light.missing'; assert.equal(lightCapabilities(item).entity, undefined)
|
||||
const noCapabilities = standardLight(); noCapabilities.entities[0].attributes = {}
|
||||
assert.equal(lightCapabilities(noCapabilities).canDim, false); assert.equal(lightCapabilities(noCapabilities).temperatureRange, undefined)
|
||||
})
|
||||
|
||||
test('light ranges, feature flags and reported readings reject malformed or unsupported values', () => {
|
||||
const { lightCapabilities } = environment().load('devices/lighting/capabilities')
|
||||
for (const min of [undefined, 0, -1, 2700.5, 'garbage'])
|
||||
assert.equal(lightCapabilities(standardLight('light.strip', 'color_temp', { min_color_temp_kelvin: min })).temperatureRange, undefined)
|
||||
for (const max of [undefined, 2700, 2000, Infinity, 3000000000])
|
||||
assert.equal(lightCapabilities(standardLight('light.strip', 'color_temp', { max_color_temp_kelvin: max })).temperatureRange, undefined)
|
||||
for (const supported_features of [undefined, 0, 3, -1, 4.5, Infinity])
|
||||
assert.equal(lightCapabilities(standardLight('light.strip', 'color_temp', { supported_features })).effects.length, 0)
|
||||
const item = standardLight('light.strip', 'color_temp', { brightness: null, color_temp_kelvin: null, effect: '未声明模式' })
|
||||
assert.equal(lightCapabilities(item).brightnessPercent, undefined); assert.equal(lightCapabilities(item).effect, undefined)
|
||||
item.entities[0].state = 'off'; assert.equal(lightCapabilities(item).brightnessPercent, 0)
|
||||
})
|
||||
|
||||
test('light drafts are instance-local and standard sends use exactly the displayed devices main entity', async () => {
|
||||
const env = environment(), { useLightControls } = env.load('devices/lighting/useLightControls'), writes = []
|
||||
let item = standardLight('light.first'), busy = false
|
||||
const hook = useLightControls(() => item, () => busy, async (...args) => { writes.push(args); return true })
|
||||
hook.brightnessTarget.value = 61; await hook.setBrightness()
|
||||
assert.equal(writes[0][0], 'light.first'); assert.equal(writes[0][1], 'light_brightness'); assert.equal(writes[0][2], 61); assert.equal(writes[0][3], 'light.first')
|
||||
hook.temperatureTarget.value = 6500; await hook.setTemperature()
|
||||
assert.equal(writes[1][1], 'light_color_temperature'); assert.equal(writes[1][2], 6500)
|
||||
hook.effectTarget.value = '助眠'; await hook.setEffect(); assert.equal(writes[2][1], 'light_effect')
|
||||
hook.temperatureTarget.value = 2200; assert.equal(await hook.setTemperature(), false)
|
||||
hook.effectTarget.value = '删除遥控器'; assert.equal(await hook.setEffect(), false)
|
||||
busy = true; hook.brightnessTarget.value = 60; assert.equal(await hook.setBrightness(), false)
|
||||
busy = false; hook.brightnessTarget.value = 87
|
||||
item = standardLight('light.second', 'brightness', { brightness: 51 }); env.flushWatches()
|
||||
assert.equal(hook.brightnessTarget.value, 20); assert.equal(hook.capability.value.temperatureRange, undefined)
|
||||
await hook.setBrightness(); assert.equal(writes[3][0], 'light.second'); assert.equal(writes[3][3], 'light.second')
|
||||
item.entities[0].state = 'unavailable'; assert.equal(await hook.setBrightness(), false)
|
||||
assert.equal(writes.length, 4)
|
||||
})
|
||||
|
||||
test('late light operation cannot reset another devices draft and failed operation restores true reading', async () => {
|
||||
const env = environment(), { useLightControls } = env.load('devices/lighting/useLightControls'), pending = deferred()
|
||||
let item = standardLight('light.first')
|
||||
const hook = useLightControls(() => item, () => false, () => pending.promise)
|
||||
hook.brightnessTarget.value = 70; const task = hook.setBrightness()
|
||||
item = standardLight('light.second'); env.flushWatches(); hook.brightnessTarget.value = 83
|
||||
pending.resolve(false); await task
|
||||
assert.equal(hook.brightnessTarget.value, 83)
|
||||
const failed = useLightControls(() => item, () => false, async () => false)
|
||||
failed.brightnessTarget.value = 70; await failed.setBrightness()
|
||||
assert.equal(failed.brightnessTarget.value, 50)
|
||||
})
|
||||
|
||||
test('new light overview does not inherit the configured bedroom lamps transition runtime', () => {
|
||||
const { deviceSummary } = environment().load('devices/deviceSummary'), item = standardLight()
|
||||
const summary = deviceSummary(item, { light: { running: true, mode: 'wake', brightnessPercent: 77 } })
|
||||
assert.equal(summary.statusText, '已点亮'); assert.equal(summary.metricText, '亮度 50% · 3000K')
|
||||
assert.equal(summary.canToggle, true); assert.equal(summary.icon, 'lighting')
|
||||
})
|
||||
|
||||
test('readonly generic button unknown state is not mislabeled as an unavailable action', () => {
|
||||
const { entityStateLabel } = environment().load('devices/entityState')
|
||||
assert.equal(entityStateLabel(entity('button.test', 'button', 'unknown')), '动作按钮(尚未触发)')
|
||||
assert.equal(entityStateLabel(entity('button.test', 'button', 'unavailable', {}, false)), '不可用')
|
||||
assert.equal(entityStateLabel(entity('sensor.test', 'sensor', 'unknown', {}, false)), '状态未回读')
|
||||
})
|
||||
|
||||
const outletDevice = () => device('outlet', 'Outlet', [entity('switch.service', 'switch', 'on'), entity('switch.power', 'switch', 'off')],
|
||||
{ primaryControlEntityId: 'switch.power' })
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ const device = (deviceId, name, moduleKey, category, entities = [], extra = {})
|
||||
deviceId, name, moduleKey, category, entities, online: true, manufacturer: '测试品牌', model: '测试型号', areaName: '睡眠空间', ...extra,
|
||||
})
|
||||
const devices = [
|
||||
device('lamp', '演示卧室灯', 'lighting', 'Lighting', [reading('light.room', 'light', 'on', { brightness: 128 })], { primaryControlEntityId: 'light.room' }),
|
||||
device('lamp', '演示卧室灯', 'lighting', 'Lighting', [reading('light.room', 'light', 'on', { brightness: 128,
|
||||
supported_color_modes: ['color_temp'], min_color_temp_kelvin: 2700, max_color_temp_kelvin: 6500,
|
||||
color_temp_kelvin: 3000, supported_features: 4, effect_list: ['阅读', '助眠'], effect: '阅读' })], { primaryControlEntityId: 'light.room' }),
|
||||
device('mattress', '演示智能床垫', 'mattress', 'Sleep'),
|
||||
device('fragrance', '演示香氛机', 'fragrance', 'Fragrance', [reading('switch.fragrance', 'switch', 'off')], { primaryControlEntityId: 'switch.fragrance' }),
|
||||
device('speaker', '演示音箱', 'audio', 'Media', [reading('media_player.room', 'media_player', 'idle', { volume_level: .7 })], { primaryControlEntityId: 'media_player.room' }),
|
||||
@@ -213,6 +215,84 @@ test('outlet overview has a dedicated compact tile and trusted quick power switc
|
||||
for (const text of ['8472528', '充电保护、过载保护', '插座电源状态']) assert.ok(!html.includes(text), text)
|
||||
})
|
||||
|
||||
const standardLight = (mode = 'color_temp', attributes = {}) => device('strip', '新灯带', '', 'Lighting', [reading('light.strip', 'light', 'off', {
|
||||
supported_color_modes: [mode], min_color_temp_kelvin: 2700, max_color_temp_kelvin: 6500, supported_features: 4,
|
||||
brightness: null, color_temp_kelvin: null, effect: null, effect_list: ['阅读模式', '助眠模式'], ...attributes,
|
||||
}), { ...reading('number.packed', 'number', '842138120'), name: '内部保护设置' }], { primaryControlEntityId: 'light.strip' })
|
||||
const lightSnapshot = item => ({ connected: true, devices: [item], summary: { totalDevices: 1, onlineDevices: item.online ? 1 : 0 }, controlParameters: {} })
|
||||
|
||||
test('configured sleep strip preselects its default and displays its own fade without copying wake or massage features', async () => {
|
||||
const item = standardLight(); item.moduleKey = 'sleep-light-strip'; item.defaultLightEffect = '助眠模式'
|
||||
item.entities[0].state = 'on'; item.entities[0].attributes.brightness = 128
|
||||
const transition = { entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'wake', lights: [
|
||||
{ entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'wake' },
|
||||
{ entityId: 'light.strip', running: true, brightnessPercent: 46, mode: 'sleep' },
|
||||
] }
|
||||
const { html } = await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)), lightTransition: ref(transition) })
|
||||
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)[0]
|
||||
assert.ok(card.includes('助眠渐暗中 · 46%')); assert.match(card, /<strong class="p-val"[^>]*>46%<\/strong>/)
|
||||
assert.match(card, /<option value="助眠模式"[^>]* selected>/)
|
||||
assert.ok(card.includes('舒睡默认 助眠模式')); assert.ok(card.includes('已加入一键舒睡和关闭'))
|
||||
assert.ok(card.includes('每5秒降低1%')); assert.ok(card.includes('实际模式以顶部回读为准'))
|
||||
for (const text of ['晨起唤醒', '灯灭停止按摩', '11%', '不自动加入舒睡']) assert.ok(!card.includes(text), text)
|
||||
const overview = (await render('/device-control', { haStatus: ref(lightSnapshot(item)), lightTransition: ref(transition) })).html
|
||||
assert.ok(overview.includes('助眠渐暗中')); assert.ok(overview.includes('亮度 46%'))
|
||||
})
|
||||
|
||||
test('both configured light details keep their own runtime brightness when displayed from one shared response', async () => {
|
||||
const strip = standardLight(); strip.moduleKey = 'sleep-light-strip'; strip.defaultLightEffect = '助眠模式'; strip.entities[0].state = 'on'
|
||||
const snapshot = { ...lightSnapshot(strip), devices: [devices[0], strip] }
|
||||
const transition = { entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'sleep', lights: [
|
||||
{ entityId: 'light.room', running: true, brightnessPercent: 11, mode: 'sleep' },
|
||||
{ entityId: 'light.strip', running: true, brightnessPercent: 46, mode: 'sleep' },
|
||||
] }
|
||||
const primary = (await render('/device-control/lamp', { haStatus: ref(snapshot), lightTransition: ref(transition) })).html
|
||||
assert.match(primary, /<strong class="p-val"[^>]*>11%<\/strong>/)
|
||||
const detail = (await render('/device-control/strip', { haStatus: ref(snapshot), lightTransition: ref(transition) })).html
|
||||
assert.match(detail, /<strong class="p-val"[^>]*>46%<\/strong>/)
|
||||
})
|
||||
|
||||
test('unbound new strip has standard brightness temperature and effects without copying scene light controls', async () => {
|
||||
const item = standardLight()
|
||||
const { html } = await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)), lightTransition: ref({ running: true, mode: 'wake', brightnessPercent: 77 }) })
|
||||
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)[0]
|
||||
for (const text of ['灯光控制', '新灯带', '灯光亮度', '灯光色温调节', '2700K 暖光', '6500K 冷光', '灯光模式', '阅读模式', '助眠模式', '已熄灭']) assert.ok(html.includes(text), text)
|
||||
for (const text of ['开始助眠渐暗', '晨起唤醒中', '842138120', '内部保护设置', '查看全部能力', '设备能力']) assert.ok(!card.includes(text), text)
|
||||
assert.equal((html.match(/van-slider__bar/g) ?? []).length, 2)
|
||||
assert.match(card, /<strong class="p-val"[^>]*>0%<\/strong>/)
|
||||
assert.doesNotMatch(card, /<strong class="p-val"[^>]*>2700K<\/strong>/)
|
||||
assert.ok(html.includes('目标色温 2700K')); assert.ok(!html.includes('van-slider--disabled'))
|
||||
})
|
||||
|
||||
test('only actually supported controls render for onoff-only brightness-only and alternate-range lamps', async () => {
|
||||
const item = standardLight('onoff', { supported_features: 0, effect_list: [] })
|
||||
let html = (await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)) })).html
|
||||
assert.ok(html.includes('目前仅提供开关')); assert.ok(!html.includes('van-slider')); assert.ok(!html.includes('灯光色温调节')); assert.ok(!html.includes('选择灯光模式'))
|
||||
item.entities[0].attributes.supported_color_modes = ['brightness']
|
||||
html = (await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)) })).html
|
||||
assert.ok(html.includes('灯光亮度')); assert.ok(!html.includes('灯光色温调节')); assert.equal((html.match(/van-slider__bar/g) ?? []).length, 1)
|
||||
item.entities[0].attributes = { supported_color_modes: ['color_temp'], min_color_temp_kelvin: 2000, max_color_temp_kelvin: 4000 }
|
||||
html = (await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)) })).html
|
||||
assert.ok(html.includes('2000K 暖光')); assert.ok(html.includes('4000K 冷光')); assert.ok(!html.includes('6500K'))
|
||||
})
|
||||
|
||||
test('offline main light disables standard controls even when an auxiliary entity remains online', async () => {
|
||||
const item = standardLight(); item.entities[0].available = false; item.entities[0].state = 'unavailable'
|
||||
const { html } = await render('/device-control/strip', { haStatus: ref(lightSnapshot(item)) })
|
||||
assert.ok(html.includes('灯光主控当前不可用')); assert.ok(html.includes('van-slider--disabled'))
|
||||
assert.doesNotMatch(html, /<strong class="p-val"[^>]*>0%<\/strong>/)
|
||||
assert.match(html, /<select[^>]*disabled/)
|
||||
const card = html.match(/<section class="device-card"[\s\S]*?<\/section>/)[0]
|
||||
assert.ok(card.match(/<button[^>]*disabled/)); assert.ok(!card.includes('目标色温 2700K'))
|
||||
})
|
||||
|
||||
test('configured scene light composes shared controls with its own fade and wake extensions', async () => {
|
||||
const { html } = await render('/device-control/lamp')
|
||||
for (const text of ['灯光亮度', '灯光色温调节', '灯光模式', '开始助眠渐暗', '晨起唤醒', '灯灭停止按摩']) assert.ok(html.includes(text), text)
|
||||
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1)
|
||||
assert.ok(!html.includes('不自动加入舒睡或唤醒场景'))
|
||||
})
|
||||
|
||||
test('mattress detail retains full controls and never renders other devices or scene-wide actions', async () => {
|
||||
const { html } = await render('/device-control/mattress')
|
||||
assert.equal((html.match(/<section class="device-card"/g) ?? []).length, 1)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { entityStateLabel } from '../../devices/entityState'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { operationBusy, toggleDevice, isDeviceTurnedOn, isDeviceControlAvailable, switchingDeviceId } = useDeviceControlContext()
|
||||
const expanded = ref(false)
|
||||
@@ -25,7 +26,7 @@ const entities = computed(() => expanded.value ? props.device.entities : props.d
|
||||
<div class="params-body">
|
||||
<div v-for="entity in entities" :key="entity.entityId" class="param-row">
|
||||
<span class="p-title">{{ entity.name }}</span>
|
||||
<span class="p-val">{{ entity.available && !['unknown', 'unavailable'].includes(entity.state) ? entity.state : '不可用' }} {{ entity.unit || '' }}</span>
|
||||
<span class="p-val">{{ entityStateLabel(entity) }} {{ entity.unit || '' }}</span>
|
||||
</div>
|
||||
<button v-if="device.entities.length > 6" class="text-refresh" @click="expanded = !expanded">
|
||||
{{ expanded ? '收起能力' : '查看全部能力' }}
|
||||
|
||||
@@ -1,94 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
import { Button as VanButton, Slider as VanSlider } from 'vant'
|
||||
import { computed } from 'vue'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { useDeviceCardState } from '../../devices/useDeviceCardState'
|
||||
import { lightCapabilities } from '../../devices/lighting/capabilities'
|
||||
import StandardLightCard from './StandardLightCard.vue'
|
||||
import { lightTransitionForDevice } from '../../devices/lighting/transitions'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice }>()
|
||||
const { device: lightDevice } = toRefs(props)
|
||||
const { localParams, operationBusy, switchingDeviceId, isDeviceTurnedOn, toggleDevice, applyParam, controlLightTransition, lightTransition, loading } = useDeviceControlContext()
|
||||
const state = useDeviceCardState(() => props.device)
|
||||
const { operationBusy, lightTransition, controlLightTransition } = useDeviceControlContext()
|
||||
const transition = computed(() => lightTransitionForDevice(props.device, lightTransition.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card" v-if="lightDevice">
|
||||
<div class="device-head">
|
||||
<div class="dev-info">
|
||||
<span class="category-tag">卧室灯光</span>
|
||||
<h3>{{ lightDevice.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: lightDevice.online }">
|
||||
{{ lightDevice.online ? '设备在线' : '设备离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<VanButton
|
||||
size="small"
|
||||
round
|
||||
:type="isDeviceTurnedOn(lightDevice) ? 'primary' : 'default'"
|
||||
:color="isDeviceTurnedOn(lightDevice) ? '#0c5045' : ''"
|
||||
:loading="switchingDeviceId === lightDevice.deviceId"
|
||||
:disabled="operationBusy || !state.powerAvailable()"
|
||||
@click="toggleDevice(lightDevice)"
|
||||
>
|
||||
{{ state.powerLabel('已点亮', '已熄灭') }}
|
||||
</VanButton>
|
||||
<StandardLightCard :device="device" category-label="卧室灯光"
|
||||
:reported-brightness="transition?.running ? transition.brightnessPercent : undefined"
|
||||
note="手动调节会停止当前渐变;本灯保留助眠、唤醒及灯灭停止按摩的专属联动。">
|
||||
<template #before-controls>
|
||||
<div class="light-mode-panel">
|
||||
<div>
|
||||
<strong v-if="transition?.running">{{ transition.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中' }} · {{ transition.brightnessPercent }}%</strong>
|
||||
<strong v-else>渐变灯光</strong>
|
||||
<small>{{ transition?.message || '开灯从50%逐渐变暗;晨起从当前亮度逐渐变亮至70%' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="params-body">
|
||||
<div class="light-mode-panel">
|
||||
<div>
|
||||
<strong v-if="lightTransition?.running">
|
||||
{{ lightTransition.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中' }} · {{ lightTransition.brightnessPercent }}%
|
||||
</strong>
|
||||
<strong v-else>渐变灯光</strong>
|
||||
<small>{{ lightTransition?.message || '开灯从50%逐渐变暗;晨起从当前亮度逐渐变亮至70%' }}</small>
|
||||
</div>
|
||||
<div class="light-mode-actions">
|
||||
<button type="button" :disabled="operationBusy || !state.available('brightness')" @click="controlLightTransition('sleep')">🌙 开始助眠渐暗</button>
|
||||
<button type="button" :disabled="operationBusy || !state.available('brightness')" @click="controlLightTransition('wake')">🌅 晨起唤醒</button>
|
||||
<button v-if="lightTransition?.running" type="button" :disabled="operationBusy" @click="controlLightTransition('stop')">停止渐变</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 亮度滑块 -->
|
||||
<div class="param-row">
|
||||
<div class="param-header">
|
||||
<span class="p-title">灯光亮度</span>
|
||||
<strong class="p-val">{{ lightTransition?.running ? `${localParams.lightBrightness}%` : state.display('brightness', localParams.lightBrightness, '%') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.lightBrightness"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="operationBusy || !state.available('brightness')"
|
||||
active-color="#d6a657"
|
||||
@change="applyParam(lightDevice.deviceId, 'brightness', localParams.lightBrightness)"
|
||||
/>
|
||||
<div class="slider-scale">
|
||||
<span>0% 关闭灯光</span>
|
||||
<span>50% 舒缓</span>
|
||||
<span>100% 满亮</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 色温滑块 -->
|
||||
<div class="param-row">
|
||||
<div class="param-header">
|
||||
<span class="p-title">灯光色温调节</span>
|
||||
<strong class="p-val">{{ state.display('color_temp', localParams.lightColorTemp, 'K') }}</strong>
|
||||
</div>
|
||||
<VanSlider
|
||||
v-model="localParams.lightColorTemp"
|
||||
:min="2200"
|
||||
:max="5000"
|
||||
:step="50"
|
||||
:disabled="operationBusy || !state.available('color_temp')"
|
||||
active-color="#c87f2b"
|
||||
@change="applyParam(lightDevice.deviceId, 'color_temp', localParams.lightColorTemp)"
|
||||
/>
|
||||
<div class="slider-scale">
|
||||
<span>2200K 超暖橙光 (利于褪黑素)</span>
|
||||
<span>5000K 清爽白光</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="light-mode-actions">
|
||||
<button type="button" :disabled="operationBusy || !lightCapabilities(device).available || !lightCapabilities(device).canDim" @click="controlLightTransition('sleep')">🌙 开始助眠渐暗</button>
|
||||
<button type="button" :disabled="operationBusy || !lightCapabilities(device).available || !lightCapabilities(device).canDim" @click="controlLightTransition('wake')">🌅 晨起唤醒</button>
|
||||
<button v-if="transition?.running" type="button" :disabled="operationBusy" @click="controlLightTransition('stop')">停止渐变</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</StandardLightCard>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Button as VanButton } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { lightCapabilities } from '../../devices/lighting/capabilities'
|
||||
import StandardLightControls from './StandardLightControls.vue'
|
||||
import { lightTransitionForDevice } from '../../devices/lighting/transitions'
|
||||
const props = withDefaults(defineProps<{ device: HomeAssistantDiscoveredDevice; categoryLabel?: string; note?: string; reportedBrightness?: number }>(),
|
||||
{ categoryLabel: '灯光控制' })
|
||||
const { operationBusy, switchingDeviceId, toggleDevice, lightTransition } = useDeviceControlContext()
|
||||
const capability = computed(() => lightCapabilities(props.device))
|
||||
const transition = computed(() => lightTransitionForDevice(props.device, lightTransition.value))
|
||||
const brightness = computed(() => props.reportedBrightness ?? (transition.value?.running ? transition.value.brightnessPercent : undefined))
|
||||
const note = computed(() => props.note ?? (props.device.defaultLightEffect
|
||||
? '已加入一键舒睡和关闭;从50%开始,每5秒降低1%,最终关灯。手动调节仅停止本灯渐变。'
|
||||
: '按本灯实际上报的能力显示参数;单独控制,不自动加入舒睡或唤醒场景。'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="device-card">
|
||||
<div class="device-head">
|
||||
<div class="dev-info"><span class="category-tag">{{ categoryLabel }}</span><h3>{{ device.name }}</h3>
|
||||
<span class="status-dot" :class="{ online: capability.available }">{{ capability.available ? capability.entity?.state === 'on' ? '已点亮' : '已熄灭' : '灯光主控当前不可用' }}</span>
|
||||
</div>
|
||||
<VanButton size="small" round :type="capability.entity?.state === 'on' ? 'primary' : 'default'"
|
||||
:loading="switchingDeviceId === device.deviceId" :disabled="operationBusy || !capability.available" @click="toggleDevice(device)">
|
||||
{{ capability.available ? capability.entity?.state === 'on' ? '关闭' : '开启' : '暂不可用' }}
|
||||
</VanButton>
|
||||
</div>
|
||||
<div class="params-body">
|
||||
<slot name="before-controls" />
|
||||
<p v-if="device.defaultLightEffect && (transition?.running || transition?.message)" class="scale-note"
|
||||
:class="{ unavailable: transition?.success === false }">{{ transition?.running ? `助眠渐暗中 · ${transition.brightnessPercent}%` : transition?.message }}</p>
|
||||
<StandardLightControls :device="device" :reported-brightness="brightness" />
|
||||
<slot name="after-controls" />
|
||||
<p class="scale-note">{{ note }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { Slider as VanSlider } from 'vant'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { useDeviceControlContext } from '../../devices/controlContext'
|
||||
import { useLightControls } from '../../devices/lighting/useLightControls'
|
||||
const props = defineProps<{ device: HomeAssistantDiscoveredDevice; reportedBrightness?: number }>()
|
||||
const { operationBusy, applyParam } = useDeviceControlContext()
|
||||
const { capability, brightnessReading, brightnessTarget, temperatureTarget, effectTarget, defaultEffect, setBrightness, setTemperature, setEffect } =
|
||||
useLightControls(() => props.device, () => operationBusy.value, applyParam, () => props.reportedBrightness)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="capability.canDim" class="param-row">
|
||||
<div class="param-header"><span class="p-title">灯光亮度</span><strong class="p-val">{{ brightnessReading === undefined ? '--' : `${brightnessReading}%` }}</strong></div>
|
||||
<VanSlider v-model="brightnessTarget" :min="0" :max="100" :step="1" :disabled="operationBusy || !capability.available"
|
||||
active-color="#d6a657" aria-label="待设置的灯光亮度" @change="setBrightness" />
|
||||
<div class="slider-scale"><span>0% 关闭</span><span>100% 满亮</span></div>
|
||||
<p v-if="capability.available" class="scale-note">目标亮度 {{ brightnessTarget }}%;滑动后设置,非当前回读值。</p>
|
||||
</div>
|
||||
<div v-if="capability.temperatureRange" class="param-row">
|
||||
<div class="param-header"><span class="p-title">灯光色温调节</span><strong class="p-val">{{ capability.colorTemperature === undefined ? '--' : `${capability.colorTemperature}K` }}</strong></div>
|
||||
<VanSlider v-model="temperatureTarget" :min="capability.temperatureRange.min" :max="capability.temperatureRange.max" :step="1"
|
||||
:disabled="operationBusy || !capability.available" active-color="#c87f2b" aria-label="待设置的灯光色温" @change="setTemperature" />
|
||||
<div class="slider-scale"><span>{{ capability.temperatureRange.min }}K 暖光</span><span>{{ capability.temperatureRange.max }}K 冷光</span></div>
|
||||
<p v-if="capability.available" class="scale-note">目标色温 {{ temperatureTarget }}K;滑动后设置,关灯时调节会点亮灯。</p>
|
||||
</div>
|
||||
<div v-if="capability.effects.length" class="param-row">
|
||||
<div class="param-header"><span class="p-title">灯光模式</span><strong class="p-val">{{ capability.effect || '--' }}</strong></div>
|
||||
<select v-model="effectTarget" class="standard-light-select" aria-label="选择灯光模式" :disabled="operationBusy || !capability.available" @change="setEffect">
|
||||
<option value="" disabled>选择要设置的模式</option>
|
||||
<option v-for="effect in capability.effects" :key="effect" :value="effect">{{ effect }}</option>
|
||||
</select>
|
||||
<p class="scale-note">使用这盏灯实际提供的模式;选择后可能点亮灯。</p>
|
||||
<p v-if="defaultEffect" class="scale-note">舒睡默认 {{ defaultEffect }};实际模式以顶部回读为准。</p>
|
||||
</div>
|
||||
<p v-if="!capability.canDim && !capability.temperatureRange && !capability.effects.length" class="scale-note">这盏灯未上报可用的调光能力,目前仅提供开关。</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.standard-light-select { width: 100%; min-height: 40px; padding: 8px 12px; border: 1px solid #d9e6de; border-radius: 10px; background: #fff; color: #174d40; font: inherit; font-size: 13px; }
|
||||
.standard-light-select:disabled { opacity: .5; }
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ import { parameterHasReading, parameterIsAvailable } from '../devices/parameterS
|
||||
import { primaryDeviceEntity, reportedPower } from '../devices/deviceSummary'
|
||||
import { climateCapabilities } from '../devices/climate/capabilities'
|
||||
import { outletCapabilities } from '../devices/outlet/capabilities'
|
||||
import { lightCapabilities } from '../devices/lighting/capabilities'
|
||||
|
||||
/** 页面公共交互;家具参数同步/左右预选/渐变轮询各有独立模块。 */
|
||||
export function useDeviceControl() {
|
||||
@@ -35,7 +36,10 @@ export function useDeviceControl() {
|
||||
const { lightTransition, musicTransition, syncLightTransitionStatus, syncMusicTransitionStatus } =
|
||||
useDeviceTransitions(localParams, async () => {
|
||||
await loadStatus(false, true)
|
||||
if (lightTransition.value?.message) setFeedback(lightTransition.value.success ? 'success' : 'fail', lightTransition.value.message)
|
||||
const status = lightTransition.value
|
||||
const failure = status?.lights?.find(light => light.success === false && light.message)
|
||||
const completed = failure ?? status?.lights?.find(light => light.mode !== 'none' && light.message) ?? status
|
||||
if (completed?.message) setFeedback(completed.success ? 'success' : 'fail', completed.message)
|
||||
})
|
||||
let active = true
|
||||
let loadSequence = 0
|
||||
@@ -67,6 +71,7 @@ export function useDeviceControl() {
|
||||
if (device.moduleKey === 'outlet' || device.category === 'Outlet') return outletCapabilities(device).available
|
||||
const target = primaryDeviceEntity(device)
|
||||
if (target?.domain === 'climate') return climateCapabilities(device).canToggle
|
||||
if (target?.domain === 'light') return lightCapabilities(device).available
|
||||
return Boolean(target && ['switch', 'humidifier', 'light', 'media_player'].includes(target.domain) && reportedPower(target) !== null)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { onUnmounted, ref } from 'vue'
|
||||
import { healthApi, type HomeAssistantLightTransition, type TcmMusicTransition } from '../services/health'
|
||||
import type { DeviceParameterState } from '../devices/parameterRegistry'
|
||||
import { clamp } from '../devices/parameterState'
|
||||
import { anyLightTransitionRunning } from '../devices/lighting/transitions'
|
||||
|
||||
export function useDeviceTransitions(parameters: DeviceParameterState, onLightEnded: () => Promise<void>) {
|
||||
const lightTransition = ref<HomeAssistantLightTransition>()
|
||||
@@ -35,16 +36,17 @@ export function useDeviceTransitions(parameters: DeviceParameterState, onLightEn
|
||||
lightTransition.value = status
|
||||
if (status.running) {
|
||||
parameters.lightBrightness = clamp(status.brightnessPercent, 0, 100)
|
||||
if (!lightTimer) lightTimer = setInterval(() => { void pollLight() }, 5000)
|
||||
} else if (lightTimer) { clearInterval(lightTimer); lightTimer = undefined }
|
||||
}
|
||||
if (anyLightTransitionRunning(status) && !lightTimer) lightTimer = setInterval(() => { void pollLight() }, 5000)
|
||||
else if (!anyLightTransitionRunning(status) && lightTimer) { clearInterval(lightTimer); lightTimer = undefined }
|
||||
}
|
||||
async function pollLight() {
|
||||
if (!active || lightPolling) return
|
||||
lightPolling = true
|
||||
try {
|
||||
const wasRunning = lightTransition.value?.running
|
||||
const wasRunning = anyLightTransitionRunning(lightTransition.value)
|
||||
await syncLightTransitionStatus()
|
||||
if (active && wasRunning && !lightTransition.value?.running) await onLightEnded()
|
||||
if (active && wasRunning && !anyLightTransitionRunning(lightTransition.value)) await onLightEnded()
|
||||
} catch { /* 状态读取失败不伪造成功,下个周期重试。 */ }
|
||||
finally { lightPolling = false }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import GenericDeviceCard from '../components/devices/GenericDeviceCard.vue'
|
||||
import ClimateCard from '../components/devices/ClimateCard.vue'
|
||||
import CurtainCard from '../components/devices/CurtainCard.vue'
|
||||
import OutletCard from '../components/devices/OutletCard.vue'
|
||||
import StandardLightCard from '../components/devices/StandardLightCard.vue'
|
||||
import { deviceModuleKey } from './deviceKinds'
|
||||
|
||||
/** 新增专属卡片只注册这里;主页面不增加家具分支。 */
|
||||
@@ -22,5 +23,10 @@ const cards: Record<string, Component> = {
|
||||
export function deviceCardFor(device: HomeAssistantDiscoveredDevice): Component {
|
||||
if (device.category === 'BodyComposition') return ScaleCard
|
||||
// 新版没有模块绑定的设备只展示通用能力,不能误用同类设备的已配置参数。
|
||||
return cards[deviceModuleKey(device)] ?? GenericDeviceCard
|
||||
const specialized = cards[deviceModuleKey(device)]
|
||||
if (specialized) return specialized
|
||||
// 没有专属模块的标准灯仍可调光;不根据家具名称或类别给所有设备套相同控件。
|
||||
if (device.entities.some(entity => entity.entityId === device.primaryControlEntityId && entity.domain === 'light'))
|
||||
return StandardLightCard
|
||||
return GenericDeviceCard
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { deviceModuleKey } from './deviceKinds'
|
||||
import { climateCapabilities, climateModeLabels } from './climate/capabilities'
|
||||
import { curtainCapabilities, curtainStateLabels } from './curtain/capabilities'
|
||||
import { outletCapabilities } from './outlet/capabilities'
|
||||
import { lightCapabilities } from './lighting/capabilities'
|
||||
import { lightTransitionForDevice } from './lighting/transitions'
|
||||
|
||||
export type DeviceIconKind = 'dehumidifier' | 'lighting' | 'audio' | 'fragrance' | 'mattress' | 'environment' | 'scale' | 'climate' | 'curtain' | 'outlet' | 'sleep-monitor' | 'other'
|
||||
type SummaryInput = {
|
||||
@@ -59,6 +61,14 @@ function powerText(power: boolean | null, running = '运行中'): string {
|
||||
}
|
||||
|
||||
const presenters: Record<string, Presenter> = {
|
||||
'standard-light'(device, input, power) {
|
||||
const capability = lightCapabilities(device)
|
||||
const transition = lightTransitionForDevice(device, input.light)
|
||||
if (transition?.running) return { statusText: transition.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中', metricText: `亮度 ${transition.brightnessPercent}%` }
|
||||
const readings = [power === true && capability.brightnessPercent !== undefined ? `亮度 ${capability.brightnessPercent}%` : '',
|
||||
capability.colorTemperature !== undefined ? `${capability.colorTemperature}K` : ''].filter(Boolean)
|
||||
return { statusText: powerText(power, '已点亮'), metricText: readings.join(' · ') || '查看本灯支持的控制' }
|
||||
},
|
||||
outlet(device) {
|
||||
const capability = outletCapabilities(device)
|
||||
return { statusText: capability.available ? capability.powerOn ? '已通电' : '已断电' : '插座主开关不可用',
|
||||
@@ -66,9 +76,10 @@ const presenters: Record<string, Presenter> = {
|
||||
},
|
||||
lighting(device, input, power) {
|
||||
const brightness = numberValue(primaryDeviceEntity(device)?.attributes?.brightness)
|
||||
const transition = lightTransitionForDevice(device, input.light)
|
||||
return {
|
||||
statusText: input.light?.running ? input.light.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中' : powerText(power, '已点亮'),
|
||||
metricText: input.light?.running ? `亮度 ${input.light.brightnessPercent}%`
|
||||
statusText: transition?.running ? transition.mode === 'wake' ? '晨起唤醒中' : '助眠渐暗中' : powerText(power, '已点亮'),
|
||||
metricText: transition?.running ? `亮度 ${transition.brightnessPercent}%`
|
||||
: power === true && brightness !== undefined ? `亮度 ${Math.round(brightness / 255 * 100)}%` : '亮度 · 色温 · 渐变',
|
||||
}
|
||||
},
|
||||
@@ -110,14 +121,17 @@ const presenters: Record<string, Presenter> = {
|
||||
|
||||
/** 总览只呈现少量真实读数,完整可控参数交给专属详情卡片。 */
|
||||
export function deviceSummary(device: HomeAssistantDiscoveredDevice, input: SummaryInput = {}): DeviceSummary {
|
||||
const key = deviceModuleKey(device)
|
||||
const icon = categoryIcons[device.category] ?? 'other'
|
||||
const target = primaryDeviceEntity(device)
|
||||
const moduleKey = deviceModuleKey(device)
|
||||
const key = presenters[moduleKey] ? moduleKey : target?.domain === 'light' ? 'standard-light' : moduleKey
|
||||
const icon = key === 'standard-light' ? 'lighting' : categoryIcons[device.category] ?? 'other'
|
||||
const audioControl = key === 'audio'
|
||||
const outletControl = key === 'outlet' || device.category === 'Outlet'
|
||||
const audioRunning = audioControl && input.music?.running === true
|
||||
const powerOn = outletControl ? outletCapabilities(device).powerOn : audioRunning ? true : reportedPower(target)
|
||||
const powerOn = outletControl ? outletCapabilities(device).powerOn : audioRunning ? true
|
||||
: target?.domain === 'light' ? lightCapabilities(device).available ? target.state === 'on' : null : reportedPower(target)
|
||||
const canToggle = outletControl ? outletCapabilities(device).available
|
||||
: target?.domain === 'light' ? lightCapabilities(device).available
|
||||
: target?.domain === 'climate' ? key === 'climate' && climateCapabilities(device).canToggle
|
||||
: Boolean(target && (device.online || audioRunning) &&
|
||||
['switch', 'humidifier', 'light', 'media_player'].includes(target.domain) && powerOn !== null)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { HomeAssistantEntity } from '../services/health'
|
||||
|
||||
/** 按钮是动作而非持续状态,unknown 不代表这个动作离线;通用列表仍只读,不开放危险动作。 */
|
||||
export function entityStateLabel(entity: HomeAssistantEntity): string {
|
||||
if (entity.state === 'unavailable' || !entity.available && entity.state !== 'unknown') return '不可用'
|
||||
if (entity.domain === 'button' && entity.available) return entity.state === 'unknown' ? '动作按钮(尚未触发)' : `最近触发:${entity.state}`
|
||||
if (entity.state === 'unknown' || !entity.state) return '状态未回读'
|
||||
return entity.state
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { numberValue } from '../parameterState'
|
||||
|
||||
const dimmableModes = ['brightness', 'color_temp', 'hs', 'rgb', 'rgbw', 'rgbww', 'xy', 'white']
|
||||
const strings = (value: unknown): string[] => Array.isArray(value)
|
||||
? [...new Set(value.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())))] : []
|
||||
|
||||
/** 标准能力只取当前设备的主灯实体;不借用全局参数、其他灯的范围或当前读数。 */
|
||||
export function lightCapabilities(device: HomeAssistantDiscoveredDevice) {
|
||||
const entity = device.entities.find(item => item.entityId === device.primaryControlEntityId && item.domain === 'light')
|
||||
const attributes = entity?.attributes ?? {}, modes = strings(attributes.supported_color_modes)
|
||||
const available = Boolean(device.online && entity?.available && ['on', 'off'].includes(entity.state))
|
||||
const canDim = modes.some(mode => dimmableModes.includes(mode))
|
||||
const min = numberValue(attributes.min_color_temp_kelvin), max = numberValue(attributes.max_color_temp_kelvin)
|
||||
const temperatureRange = modes.includes('color_temp') && min !== undefined && max !== undefined &&
|
||||
Number.isInteger(min) && Number.isInteger(max) && min > 0 && max > min && max <= 2147483647 ? { min, max } : undefined
|
||||
const features = numberValue(attributes.supported_features)
|
||||
const effects = features !== undefined && Number.isInteger(features) && features >= 0 && features <= 2147483647 && (features & 4) !== 0
|
||||
? strings(attributes.effect_list) : []
|
||||
const brightness = numberValue(attributes.brightness), kelvin = numberValue(attributes.color_temp_kelvin)
|
||||
const brightnessPercent = available && canDim ? entity!.state === 'off' ? 0
|
||||
: brightness !== undefined && Number.isInteger(brightness) && brightness > 0 && brightness <= 255 ? Math.round(brightness / 255 * 100) : undefined : undefined
|
||||
const colorTemperature = available && temperatureRange && kelvin !== undefined && Number.isInteger(kelvin) &&
|
||||
kelvin >= temperatureRange.min && kelvin <= temperatureRange.max ? kelvin : undefined
|
||||
const effect = available && typeof attributes.effect === 'string' && effects.includes(attributes.effect) ? attributes.effect : undefined
|
||||
return { entity, available, canDim, temperatureRange, effects, brightnessPercent, colorTemperature, effect }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clamp, numberValue, type ParameterSnapshot } from '../parameterState'
|
||||
export const defaults = { lightBrightness: 50, lightColorTemp: 2700 }
|
||||
export const labels = { brightness: '灯光亮度', color_temp: '灯光色温' }
|
||||
export const labels = { brightness: '灯光亮度', color_temp: '灯光色温', light_brightness: '灯光亮度',
|
||||
light_color_temperature: '灯光色温', light_effect: '灯光模式' }
|
||||
export function sync(state: typeof defaults, controls: ParameterSnapshot) {
|
||||
const brightness = controls.brightness
|
||||
if (brightness?.available) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { HomeAssistantDiscoveredDevice, HomeAssistantLightTransition } from '../../services/health'
|
||||
import { deviceModuleKey } from '../deviceKinds'
|
||||
|
||||
/** 渐变回读按实体归属,不让两盏灯共享同一个亮度或运行状态。 */
|
||||
export function lightTransitionForDevice(device: HomeAssistantDiscoveredDevice, status?: HomeAssistantLightTransition) {
|
||||
const entityId = device.primaryControlEntityId
|
||||
if (!entityId || !device.entities.some(entity => entity.entityId === entityId && entity.domain === 'light')) return undefined
|
||||
if (status?.lights?.length) return status.lights.find(light => light.entityId === entityId)
|
||||
if (status?.entityId) return status.entityId === entityId ? status : undefined
|
||||
// 兼容旧单灯接口,仅原专属卧室灯可使用没有实体标记的回读。
|
||||
return deviceModuleKey(device) === 'lighting' ? status : undefined
|
||||
}
|
||||
|
||||
export function anyLightTransitionRunning(status?: HomeAssistantLightTransition) {
|
||||
return Boolean(status?.running || status?.lights?.some(light => light.running))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { HomeAssistantDiscoveredDevice } from '../../services/health'
|
||||
import { lightCapabilities } from './capabilities'
|
||||
|
||||
type Apply = (deviceId: string, parameter: string, value: unknown, entityId?: string) => Promise<boolean>
|
||||
|
||||
/** 每张卡片有独立编辑草稿;回读与待设置值分开,切换设备时不继承上一盏灯。 */
|
||||
export function useLightControls(device: () => HomeAssistantDiscoveredDevice, busy: () => boolean, apply: Apply,
|
||||
reportedBrightness: () => number | undefined = () => undefined) {
|
||||
const capability = computed(() => lightCapabilities(device()))
|
||||
const brightnessDraft = ref<number>(), temperatureDraft = ref<number>(), effectDraft = ref<string>()
|
||||
const identity = () => `${device().deviceId}\0${device().primaryControlEntityId ?? ''}`
|
||||
watch(identity, () => { brightnessDraft.value = undefined; temperatureDraft.value = undefined; effectDraft.value = undefined })
|
||||
const brightnessReading = computed(() => {
|
||||
const reported = reportedBrightness()
|
||||
return capability.value.available && reported !== undefined && Number.isInteger(reported) && reported >= 0 && reported <= 100
|
||||
? reported : capability.value.brightnessPercent
|
||||
})
|
||||
const brightnessTarget = computed({ get: () => brightnessDraft.value ?? brightnessReading.value ?? 50,
|
||||
set: (value: number) => { brightnessDraft.value = value } })
|
||||
const temperatureTarget = computed({ get: () => temperatureDraft.value ?? capability.value.colorTemperature ?? capability.value.temperatureRange?.min ?? 0,
|
||||
set: (value: number) => { temperatureDraft.value = value } })
|
||||
const defaultEffect = computed(() => capability.value.effects.includes(device().defaultLightEffect ?? '') ? device().defaultLightEffect! : '')
|
||||
const effectTarget = computed({ get: () => effectDraft.value ?? capability.value.effect ?? defaultEffect.value,
|
||||
set: (value: string) => { effectDraft.value = value } })
|
||||
|
||||
async function send(parameter: string, value: unknown, reset: () => void) {
|
||||
const current = capability.value, owner = identity(), item = device()
|
||||
if (busy() || !current.available || !current.entity) return false
|
||||
const result = await apply(item.deviceId, parameter, value, current.entity.entityId)
|
||||
if (owner === identity()) reset()
|
||||
return result
|
||||
}
|
||||
function setBrightness() {
|
||||
const value = brightnessTarget.value
|
||||
if (!capability.value.canDim || !Number.isInteger(value) || value < 0 || value > 100) return Promise.resolve(false)
|
||||
return send('light_brightness', value, () => { brightnessDraft.value = undefined })
|
||||
}
|
||||
function setTemperature() {
|
||||
const range = capability.value.temperatureRange, value = temperatureTarget.value
|
||||
if (!range || !Number.isInteger(value) || value < range.min || value > range.max) return Promise.resolve(false)
|
||||
return send('light_color_temperature', value, () => { temperatureDraft.value = undefined })
|
||||
}
|
||||
function setEffect() {
|
||||
const value = effectTarget.value
|
||||
if (!capability.value.effects.includes(value)) return Promise.resolve(false)
|
||||
return send('light_effect', value, () => { effectDraft.value = undefined })
|
||||
}
|
||||
return { capability, brightnessReading, brightnessTarget, temperatureTarget, effectTarget, defaultEffect, setBrightness, setTemperature, setEffect }
|
||||
}
|
||||
@@ -181,6 +181,7 @@ export type HomeAssistantDiscoveredDevice = {
|
||||
category: HealthDeviceCategory | string
|
||||
online: boolean
|
||||
primaryControlEntityId?: string | null
|
||||
defaultLightEffect?: string | null
|
||||
entities: HomeAssistantEntity[]
|
||||
metrics?: Record<string, unknown>
|
||||
}
|
||||
@@ -229,12 +230,14 @@ export type HomeAssistantDeviceSwitchOutput = {
|
||||
}
|
||||
|
||||
export type HomeAssistantLightTransition = {
|
||||
entityId?: string
|
||||
success: boolean
|
||||
running: boolean
|
||||
mode: 'none' | 'sleep' | 'wake'
|
||||
brightnessPercent: number
|
||||
targetPercent: number
|
||||
message: string
|
||||
lights?: HomeAssistantLightTransition[]
|
||||
}
|
||||
|
||||
export type TcmMusicTransition = {
|
||||
|
||||
@@ -174,5 +174,10 @@ input, textarea { accent-color: var(--green); }
|
||||
#app .control-page .control-content { padding: 12px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
||||
/* 停止动画,不把无限循环的加载图标加速;加载文字仍由页面正常显示。 */
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition-duration: 0s !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user