Add Matter metadata (#3129)
Refs https://github.com/openhab/openhab-addons/pull/18486 --------- Also-by: Florian Hotze <dev@florianhotze.com> Signed-off-by: Dan Cunningham <dan@digitaldan.com>pull/3213/head
parent
60d7f8cf3f
commit
6c66733bd9
|
@ -0,0 +1,157 @@
|
|||
const thermostatSystemModeOptions = [
|
||||
{ value: '0', label: 'OFF' },
|
||||
{ value: '1', label: 'AUTO' },
|
||||
{ value: '3', label: 'COOL' },
|
||||
{ value: '4', label: 'HEAT' },
|
||||
{ value: '5', label: 'EMERGENCY_HEAT' },
|
||||
{ value: '6', label: 'PRECOOLING' },
|
||||
{ value: '7', label: 'FAN_ONLY' },
|
||||
{ value: '8', label: 'DRY' },
|
||||
{ value: '9', label: 'SLEEP' }
|
||||
]
|
||||
|
||||
const fanModeOptions = [
|
||||
{ value: '0', label: 'OFF' },
|
||||
{ value: '1', label: 'LOW' },
|
||||
{ value: '2', label: 'MEDIUM' },
|
||||
{ value: '3', label: 'HIGH' },
|
||||
{ value: '4', label: 'ON' },
|
||||
{ value: '5', label: 'AUTO' },
|
||||
{ value: '6', label: 'SMART' }
|
||||
]
|
||||
|
||||
export const deviceTypes = {
|
||||
OnOffLight: {
|
||||
attributes: []
|
||||
},
|
||||
DimmableLight: {
|
||||
attributes: []
|
||||
},
|
||||
ColorLight: {
|
||||
attributes: []
|
||||
},
|
||||
OnOffPlugInUnit: {
|
||||
attributes: []
|
||||
},
|
||||
WindowCovering: {
|
||||
attributes: []
|
||||
},
|
||||
TemperatureSensor: {
|
||||
attributes: []
|
||||
},
|
||||
HumiditySensor: {
|
||||
attributes: []
|
||||
},
|
||||
OccupancySensor: {
|
||||
attributes: []
|
||||
},
|
||||
ContactSensor: {
|
||||
attributes: []
|
||||
},
|
||||
DoorLock: {
|
||||
attributes: []
|
||||
},
|
||||
Thermostat: {
|
||||
attributes: [
|
||||
{ label: 'Local Temperature', name: 'thermostat.localTemperature', mandatory: true },
|
||||
{ label: 'Outdoor Temperature', name: 'thermostat.outdoorTemperature', mandatory: false },
|
||||
{ label: 'Occupied Heating Setpoint', name: 'thermostat.occupiedHeatingSetpoint', mandatory: false },
|
||||
{ label: 'Occupied Cooling Setpoint', name: 'thermostat.occupiedCoolingSetpoint', mandatory: false },
|
||||
{
|
||||
label: 'System Mode',
|
||||
name: 'thermostat.systemMode',
|
||||
mandatory: true,
|
||||
mapping: {
|
||||
name: 'systemMode',
|
||||
label: 'System Mode Mappings',
|
||||
type: 'TEXT',
|
||||
limitToOptions: true,
|
||||
options: thermostatSystemModeOptions
|
||||
}
|
||||
},
|
||||
{ label: 'Running Mode', name: 'thermostat.runningMode', mandatory: false }
|
||||
]
|
||||
},
|
||||
Fan: {
|
||||
attributes: [
|
||||
{ label: 'On Off', name: 'onOff.onOff', mandatory: false },
|
||||
{
|
||||
label: 'Fan Mode',
|
||||
name: 'fanControl.fanMode',
|
||||
mandatory: false,
|
||||
mapping: {
|
||||
name: 'fanMode',
|
||||
label: 'Fan Mode Mappings',
|
||||
type: 'TEXT',
|
||||
limitToOptions: true,
|
||||
options: fanModeOptions
|
||||
}
|
||||
},
|
||||
{ label: 'Percent Setting', name: 'fanControl.percentSetting', mandatory: false }
|
||||
],
|
||||
supportsSimpleMapping: true
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceTypesAndAttributes = Object.entries(deviceTypes).flatMap(([type, attributes]) => [
|
||||
type,
|
||||
...(attributes.length > 0 ? attributes.map(cluster => `${type}.${cluster.label}`) : [])
|
||||
])
|
||||
|
||||
export const isComplexDeviceType = (deviceType) => {
|
||||
return deviceTypes[deviceType]?.length > 0
|
||||
}
|
||||
|
||||
const labelParameter = {
|
||||
name: 'label',
|
||||
label: 'Custom Label',
|
||||
type: 'TEXT',
|
||||
description: 'Override the default item label in Matter'
|
||||
}
|
||||
|
||||
const fixedLabelsParameter = {
|
||||
name: 'fixedLabels',
|
||||
label: 'Fixed Labels',
|
||||
type: 'TEXT',
|
||||
description: 'Comma-separated list of key=value pairs for fixed labels (e.g. room=Office, floor=1)'
|
||||
}
|
||||
|
||||
const thermostatLimitsParameters = [
|
||||
{ name: 'thermostat-minHeatSetpointLimit', label: 'Min Heat Setpoint', type: 'DECIMAL', description: 'Minimum allowable heat setpoint (in 0.01°C)' },
|
||||
{ name: 'thermostat-maxHeatSetpointLimit', label: 'Max Heat Setpoint', type: 'DECIMAL', description: 'Maximum allowable heat setpoint (in 0.01°C)' },
|
||||
{ name: 'thermostat-minCoolSetpointLimit', label: 'Min Cool Setpoint', type: 'DECIMAL', description: 'Minimum allowable cool setpoint (in 0.01°C)' },
|
||||
{ name: 'thermostat-maxCoolSetpointLimit', label: 'Max Cool Setpoint', type: 'DECIMAL', description: 'Maximum allowable cool setpoint (in 0.01°C)' },
|
||||
{ name: 'thermostat-minSetpointDeadBand', label: 'Min Setpoint Deadband', type: 'DECIMAL', description: 'Minimum temperature gap between heating and cooling setpoints (in 0.01°C)' }
|
||||
]
|
||||
|
||||
const fanModeSequenceParameter = {
|
||||
name: 'fanControl-fanModeSequence',
|
||||
label: 'Fan Mode Sequence',
|
||||
type: 'INTEGER',
|
||||
description: 'The sequence of fan modes to cycle through',
|
||||
limitToOptions: true,
|
||||
options: [
|
||||
{ value: '0', label: 'Off-Low-Med-High' },
|
||||
{ value: '1', label: 'Off-Low-High' },
|
||||
{ value: '2', label: 'Off-Low-Med-High-Auto' },
|
||||
{ value: '3', label: 'Off-Low-High-Auto' },
|
||||
{ value: '4', label: 'Off-High-Auto' },
|
||||
{ value: '5', label: 'Off-High' }
|
||||
]
|
||||
}
|
||||
const windowCoveringInvertParameter = {
|
||||
name: 'windowCovering-invert',
|
||||
label: 'Invert Control',
|
||||
type: 'BOOLEAN'
|
||||
}
|
||||
|
||||
export const matterParameters = {
|
||||
global: [labelParameter, fixedLabelsParameter],
|
||||
OnOffLight: [labelParameter, fixedLabelsParameter],
|
||||
DimmableLight: [labelParameter, fixedLabelsParameter],
|
||||
ColorLight: [labelParameter, fixedLabelsParameter],
|
||||
PlugInUnit: [labelParameter, fixedLabelsParameter],
|
||||
Thermostat: [labelParameter, fixedLabelsParameter].concat(thermostatLimitsParameters),
|
||||
WindowCovering: [labelParameter, fixedLabelsParameter].concat(windowCoveringInvertParameter),
|
||||
Fan: [labelParameter, fixedLabelsParameter, fanModeSequenceParameter]
|
||||
}
|
|
@ -10,6 +10,7 @@ export default [
|
|||
{ name: 'autoupdate', label: 'Auto-update' },
|
||||
{ name: 'expire', label: 'Expiration Timer' },
|
||||
{ name: 'voiceSystem', label: 'Voice System' },
|
||||
{ name: 'matter', label: 'Matter' },
|
||||
{ name: 'alexa', label: 'Amazon Alexa' },
|
||||
{ name: 'homekit', label: 'Apple HomeKit' },
|
||||
{ name: 'ga', label: 'Google Assistant' },
|
||||
|
|
|
@ -0,0 +1,317 @@
|
|||
<template>
|
||||
<div v-if="ready">
|
||||
<div>
|
||||
<div style="text-align:right" class="padding-right">
|
||||
<label @click="toggleMultiple" style="cursor:pointer">Multiple</label>
|
||||
<f7-checkbox :checked="multiple" @change="toggleMultiple" />
|
||||
</div>
|
||||
<f7-list v-if="deviceTypes">
|
||||
<f7-list-item :key="classSelectKey"
|
||||
:title="'Matter Device Type'"
|
||||
smart-select
|
||||
:smart-select-params="{ openIn: 'popup', searchbar: true, closeOnSelect: !multiple }"
|
||||
ref="classes">
|
||||
<select name="parameters" @change="updateClasses" :multiple="multiple">
|
||||
<option v-if="!multiple" value="" />
|
||||
<option v-for="deviceType in getAvailableDeviceTypes()"
|
||||
:value="deviceType"
|
||||
:key="deviceType"
|
||||
:selected="isSelected(deviceType)">
|
||||
{{ deviceType }}
|
||||
</option>
|
||||
</select>
|
||||
</f7-list-item>
|
||||
</f7-list>
|
||||
<div v-if="parameters && parameters.length">
|
||||
<config-sheet :parameterGroups="parametersGroups" :parameters="parameters" :configuration="metadata.config" />
|
||||
</div>
|
||||
<f7-block class="padding-top no-padding no-margin"
|
||||
v-if="shouldShowAttributeMapping">
|
||||
<f7-block-title class="padding-horizontal" medium>
|
||||
Matter Attributes Mapping
|
||||
</f7-block-title>
|
||||
<f7-block-footer v-if="dirtyItem.size">
|
||||
<f7-button color="blue" @click="updatedLinkedItem">
|
||||
Update group members
|
||||
</f7-button>
|
||||
</f7-block-footer>
|
||||
<f7-block v-for="deviceType in classesAsArray" :key="deviceType" class="no-padding">
|
||||
<f7-block-title class="padding-left">
|
||||
{{ deviceType }}
|
||||
</f7-block-title>
|
||||
<f7-list>
|
||||
<f7-list-item v-for="attribute in deviceTypes[deviceType]?.attributes"
|
||||
:key="attribute.name"
|
||||
smart-select
|
||||
:title="attribute.mandatory ? attribute.label+'*' : attribute.label"
|
||||
:smart-select-params="{ openIn: 'popup', searchbar: true, closeOnSelect: true }">
|
||||
<select @change="updateLinkedItem(deviceType, attribute.name, $event.target.value)">
|
||||
<option value="" />
|
||||
<option v-for="mbr in item.members"
|
||||
:value="mbr.name"
|
||||
:key="mbr.id"
|
||||
:selected="isLinked(deviceType, attribute.name, mbr)">
|
||||
{{ mbr.label }} ({{ mbr.name }})
|
||||
</option>
|
||||
</select>
|
||||
</f7-list-item>
|
||||
</f7-list>
|
||||
<!-- Option mapping UI: separate from item mapping list -->
|
||||
<div v-for="attribute in deviceTypes[deviceType]?.attributes"
|
||||
:key="attribute.name + '-mapping'">
|
||||
<template v-if="getMappedChild(attribute.name) && getAttributeOptions(attribute.name, deviceType).length">
|
||||
<div class="option-mapping-fields padding-left padding-bottom">
|
||||
<div class="padding-bottom padding-top">
|
||||
<b>Mapping options for {{ attribute.label }}</b>
|
||||
</div>
|
||||
<f7-list no-hairlines-md>
|
||||
<f7-list-input
|
||||
v-for="option in getAttributeOptions(attribute.name, deviceType)"
|
||||
:key="option.label"
|
||||
type="text"
|
||||
:label="option.label"
|
||||
:value="getChildMapping(attribute.name, option.label, option.value)"
|
||||
@input="setChildMapping(attribute.name, option.label, $event.target.value)" />
|
||||
</f7-list>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</f7-block>
|
||||
<f7-block-footer>
|
||||
<small class="text-color-gray">* indicates mandatory mapping</small>
|
||||
</f7-block-footer>
|
||||
<f7-block-footer v-if="dirtyItem.size">
|
||||
<f7-button color="blue" @click="updatedLinkedItem">
|
||||
Update group members
|
||||
</f7-button>
|
||||
</f7-block-footer>
|
||||
</f7-block>
|
||||
<p class="padding">
|
||||
<f7-link color="blue" external target="_blank" :href="`${$store.state.websiteUrl}/link/matter`">
|
||||
Matter integration documentation
|
||||
</f7-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-align-center">
|
||||
<f7-preloader />
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { deviceTypes, deviceTypesAndAttributes, matterParameters } from '@/assets/definitions/metadata/matter'
|
||||
import ConfigSheet from '@/components/config/config-sheet.vue'
|
||||
|
||||
export default {
|
||||
name: 'item-metadata-matter',
|
||||
props: ['item', 'metadata', 'namespace'],
|
||||
components: {
|
||||
ConfigSheet
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
deviceTypes,
|
||||
classesDefs: deviceTypesAndAttributes,
|
||||
multiple: !!this.metadata.value && this.metadata.value.indexOf(',') > 0,
|
||||
classSelectKey: this.$f7.utils.id(),
|
||||
itemType: this.item.groupType || this.item.type,
|
||||
dirtyItem: new Set(),
|
||||
ready: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.multiple = !!this.metadata.value && this.metadata.value.indexOf(',') > 0
|
||||
this.itemType = this.item.groupType || this.item.type
|
||||
|
||||
if (!this.metadata.config) {
|
||||
this.$set(this.metadata, 'config', {})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.itemType === 'Group' && this.item.members) {
|
||||
Promise.all(
|
||||
this.item.members.map(member =>
|
||||
this.$oh.api.get(`/rest/items/${member.name}?metadata=matter`)
|
||||
)
|
||||
).then(responses => {
|
||||
this.item.members = this.item.members.map((member, index) => {
|
||||
return {
|
||||
...member,
|
||||
metadata: responses[index].metadata || {}
|
||||
}
|
||||
})
|
||||
this.ready = true
|
||||
console.debug('Updated members:', this.item.members)
|
||||
})
|
||||
} else {
|
||||
this.ready = true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
classesAsArray () {
|
||||
return (this.metadata.value) ? this.metadata.value.split(',') : []
|
||||
},
|
||||
classes () {
|
||||
if (!this.multiple) return this.metadata.value
|
||||
return this.classesAsArray
|
||||
},
|
||||
shouldShowAttributeMapping () {
|
||||
// Show attribute mapping if:
|
||||
// 1. It's a Group item (regardless of groupType)
|
||||
// 2. Has selected device types
|
||||
// 3. At least one selected device type has attributes defined
|
||||
return this.item.type === 'Group' &&
|
||||
this.classes &&
|
||||
this.classes.length &&
|
||||
this.classesAsArray.some(deviceType => this.deviceTypes[deviceType]?.attributes?.length > 0)
|
||||
},
|
||||
parametersGroups () {
|
||||
if ((!this.classes) || (!this.multiple)) return []
|
||||
return this.classesAsArray.map(type => ({ name: type, label: type }))
|
||||
},
|
||||
parameters () {
|
||||
if (!this.classes) return matterParameters.global || []
|
||||
|
||||
if (!this.multiple) {
|
||||
return matterParameters[this.classes] || matterParameters.global || []
|
||||
}
|
||||
|
||||
// For multiple selection, show parameters for all selected types
|
||||
return this.classesAsArray.flatMap(type => {
|
||||
const typeParams = matterParameters[type] || []
|
||||
return typeParams.map(opt => ({ ...opt, groupName: type }))
|
||||
}).concat(matterParameters.global || [])
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAvailableDeviceTypes () {
|
||||
if (this.item.type !== 'Group') {
|
||||
return Object.keys(this.deviceTypes).filter(type => {
|
||||
const device = this.deviceTypes[type]
|
||||
return device.attributes.length === 0 || device.supportsSimpleMapping === true
|
||||
})
|
||||
}
|
||||
|
||||
if (this.multiple) {
|
||||
return this.item.groupType
|
||||
? Object.keys(this.deviceTypes)
|
||||
: Object.keys(this.deviceTypes).filter(type => this.deviceTypes[type]?.attributes?.length > 0)
|
||||
}
|
||||
|
||||
return this.item.groupType
|
||||
? Object.keys(this.deviceTypes)
|
||||
: Object.keys(this.deviceTypes).filter(type => this.deviceTypes[type]?.attributes?.length > 0)
|
||||
},
|
||||
isLinked (deviceType, attribute, item) {
|
||||
if (!item?.metadata?.matter?.value) return false
|
||||
|
||||
const value = item.metadata.matter.value.toLowerCase()
|
||||
return attribute === null ? value === deviceType.toLowerCase() : value === attribute.toLowerCase()
|
||||
},
|
||||
isSelected (deviceType) {
|
||||
return this.multiple
|
||||
? this.classes.includes(deviceType)
|
||||
: this.classes === deviceType
|
||||
},
|
||||
toggleMultiple () {
|
||||
this.multiple = !this.multiple
|
||||
this.metadata.value = ''
|
||||
this.classSelectKey = this.$f7.utils.id()
|
||||
},
|
||||
updateClasses () {
|
||||
const value = this.$refs.classes.f7SmartSelect.getValue()
|
||||
this.metadata.value = Array.isArray(value) ? value.join(',') : value
|
||||
this.$set(this.metadata, 'config', {})
|
||||
},
|
||||
updateLinkedItem (deviceType, attribute, itemName) {
|
||||
if (!itemName) {
|
||||
// Handle unlinking
|
||||
const groupMbr = this.item.members.find(mbr =>
|
||||
mbr.metadata?.matter?.value?.toLowerCase() ===
|
||||
attribute.toLowerCase()
|
||||
)
|
||||
if (groupMbr) {
|
||||
groupMbr.metadata.matter.value = ''
|
||||
this.dirtyItem.add(groupMbr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle linking
|
||||
const groupMbr = this.item.members.find(mbr => mbr.name === itemName)
|
||||
if (groupMbr) {
|
||||
if (!groupMbr.metadata) {
|
||||
this.$set(groupMbr, 'metadata', {})
|
||||
}
|
||||
if (!groupMbr.metadata.matter) {
|
||||
this.$set(groupMbr.metadata, 'matter', { value: '', config: {} })
|
||||
}
|
||||
groupMbr.metadata.matter.value = attribute
|
||||
this.dirtyItem.add(groupMbr)
|
||||
}
|
||||
},
|
||||
updatedLinkedItem () {
|
||||
Promise.all(
|
||||
Array.from(this.dirtyItem).map(item => {
|
||||
if (!item.metadata.matter.value) {
|
||||
// If value is empty, send DELETE
|
||||
return this.$oh.api.delete(`/rest/items/${item.name}/metadata/matter`)
|
||||
} else {
|
||||
return this.$oh.api.put(`/rest/items/${item.name}/metadata/matter`, {
|
||||
value: item.metadata.matter.value,
|
||||
config: item.metadata.matter.config || {}
|
||||
})
|
||||
}
|
||||
})
|
||||
).then(() => {
|
||||
this.dirtyItem.clear()
|
||||
this.$f7.toast.create({
|
||||
text: 'Group members updated',
|
||||
closeTimeout: 2000
|
||||
}).open()
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to update group members:', err)
|
||||
})
|
||||
},
|
||||
getMappedChild (attributeName) {
|
||||
// Return the child item mapped to this attribute (all lowercase)
|
||||
const attr = attributeName.toLowerCase()
|
||||
return this.item.members && this.item.members.find(mbr =>
|
||||
mbr.metadata?.matter?.value?.toLowerCase() === attr
|
||||
)
|
||||
},
|
||||
getAttributeOptions (attributeName, deviceType) {
|
||||
// Find the attribute in deviceTypes and return its mapping options if present
|
||||
const type = this.deviceTypes[deviceType]
|
||||
if (!type || !type.attributes) return []
|
||||
const attr = type.attributes.find(a => a.name.toLowerCase() === attributeName.toLowerCase())
|
||||
if (attr && attr.mapping && attr.mapping.options) {
|
||||
return attr.mapping.options
|
||||
}
|
||||
return []
|
||||
},
|
||||
getChildMapping (attributeName, optionLabel, optionValue) {
|
||||
// Get the mapped value for this option from the mapped child's metadata.config
|
||||
const mappedChild = this.getMappedChild(attributeName)
|
||||
if (!mappedChild) return optionValue
|
||||
const attr = attributeName.toLowerCase()
|
||||
if (!mappedChild.metadata.matter.config) return optionValue
|
||||
if (!mappedChild.metadata.matter.config[attr]) return optionValue
|
||||
// config[attr] is an object: { optionLabel: mappedValue, ... }
|
||||
return mappedChild.metadata.matter.config[attr][optionLabel] || optionValue
|
||||
},
|
||||
setChildMapping (attributeName, optionLabel, newValue) {
|
||||
// Set the mapped value for this option in the mapped child's metadata.config
|
||||
const mappedChild = this.getMappedChild(attributeName)
|
||||
if (!mappedChild) return
|
||||
const attr = attributeName.toLowerCase()
|
||||
if (!mappedChild.metadata.matter.config) this.$set(mappedChild.metadata.matter, 'config', {})
|
||||
if (!mappedChild.metadata.matter.config[attr]) this.$set(mappedChild.metadata.matter.config, attr, {})
|
||||
this.$set(mappedChild.metadata.matter.config[attr], optionLabel, newValue)
|
||||
this.dirtyItem.add(mappedChild)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -77,6 +77,7 @@ import ItemMetadataExpire from '@/components/item/metadata/item-metadata-expire.
|
|||
import ItemMetadataVoiceSystem from '@/components/item/metadata/item-metadata-voicesystem.vue'
|
||||
import ItemMetadataAlexa from '@/components/item/metadata/item-metadata-alexa.vue'
|
||||
import ItemMetadataHomeKit from '@/components/item/metadata/item-metadata-homekit.vue'
|
||||
import ItemMetadataMatter from '@/components/item/metadata/item-metadata-matter.vue'
|
||||
import ItemMetadataGa from '@/components/item/metadata/item-metadata-ga.vue'
|
||||
import ItemMetadataLinktomore from '@/components/item/metadata/item-metadata-linktomore.vue'
|
||||
import DirtyMixin from '../../dirty-mixin'
|
||||
|
@ -131,6 +132,8 @@ export default {
|
|||
return ItemMetadataExpire
|
||||
case 'voiceSystem':
|
||||
return ItemMetadataVoiceSystem
|
||||
case 'matter':
|
||||
return ItemMetadataMatter
|
||||
case 'alexa':
|
||||
return ItemMetadataAlexa
|
||||
case 'homekit':
|
||||
|
|
Loading…
Reference in New Issue