Skip to content

Commit

Permalink
Provide meaningful list of units in item-form (#2312)
Browse files Browse the repository at this point in the history
Closes openhab/openhab-core#4082.

This PR adds:

1. A curated list of units to show as a drop-down list when creating a Number item with dimension.
2. The possibility to use a different default unit on item creation than the system default unit.
3. The ability to change unit and state description for items.
4. The ability to use the `unitHint` provided by channel types for "Link channel to Item" -> "Create a new Item".

By default, the system default unit (for the configured measurement system) will be shown when editing or creating an item.

`units.js` contains a number of frequently used units by dimension and measurement system.
These will be available in a autosuggest dropdown list.
It is still possible to not select from the list and use any other string as a unit.

All units for the dimension in `units.js` will be in the dropdown list, but they will be sorted by measurement system. If the measurement system is set to US, imperial units will appear higher in the list.
Units that have not explicitely been listed as SI or US will always appear higher.

When typing a unit that is not in the curated list, a longer list will be used for autocompletion that considers allowed prefixes to base units and constructs all combinations.

`units.js` also contains a field to set a different default unit on item creation than the system default unit.

With openhab/openhab-core#4079, the REST API of channel types will provide a unit hint if defined in the binding channel types.
If such information is available, this PR adds support for this to be the the suggested unit.
If that information is unavailable, the UI will fall back to behavious described above.

---------

Also-by: Florian Hotze <[email protected]>
Signed-off-by: Mark Herwege <[email protected]>
  • Loading branch information
mherwege authored Mar 1, 2024
1 parent 148c463 commit 2f17183
Show file tree
Hide file tree
Showing 9 changed files with 604 additions and 77 deletions.
180 changes: 180 additions & 0 deletions bundles/org.openhab.ui/web/src/assets/units.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Units defines the possible units for UI unit selection.
// If nothing is defined for an allowed dimension, the UI will fall back on the OH core default unit in the configured measurement system.
// For dimensions defined, any of the fields can be omitted. Logical defaults will be used.
// Units from curated units lists will always be added to the full unit list constructed from baseUnits and prefixes.
// So it is not necessary to explicitly add what is already in the curated units for the full units list.
// However, no prefixes will be applied to these. If you want prefixes to be applied, you should add them in the respective baseUnits Array as well.

/**
* @typedef Unit
* @property {string} dimension unit dimension (required)
* @property {string[]} [units] units used in a curated shortlist of units, not specific to SI or Imperial measurement system
* @property {string[]} [unitsSI] units used in a curated shortlist of units, specific to the SI measurement system
* @property {string[]} [unitsUS] units used in a curated shortlist of units, specific to the Imperial measurement system
* @property {string} [default] default unit, to be set to override core default unit, not specific to SI or Imperial measurement system
* @property {string} [defaultsSI] default unit in SI measurement system, to be set to override OH core default SI unit
* @property {string} [defaultUS] default unit in Imperial measurement system, to be set to override OH core default Imperial unit
* @property {string[]} [baseUnits] all supported base units that don't allow metric or binary prefixes
* @property {string[]} [baseUnitsMetric] metric base units, the full list of units will include all of these with all metric prefixes
* @property {string[]} [baseUnitsBinary] binary base units, the full list of units will include all of these with all binary prefixes
*/

/**
* Defines the possible units for UI unit selection.
* @type {Unit[]}
*/
export const Units = [{
dimension: 'Acceleration',
baseUnits: ['gₙ'],
baseUnitsSI: ['m/s²']
}, {
dimension: 'AmountOfSubstance',
baseUnits: ['°dH'],
baseUnitsSI: ['mol']
}, {
dimension: 'Angle',
units: ['°', '\'', '"', 'rad']
}, {
dimension: 'Area',
unitsSI: ['m²', 'km²', 'ha'],
unitsUS: ['ft²', 'mi²'],
baseUnits: ['ca', 'a', 'in²', 'ac'],
baseUnitsMetric: ['m²']
}, {
dimension: 'DataAmount',
units: ['bit', 'B', 'kB', 'kiB', 'MB', 'MiB', 'GB', 'GiB', 'TB', 'TiB'],
baseUnitsMetric: ['bit', 'B', 'o'],
baseUnitsBinary: ['bit', 'B', 'o']
}, {
dimension: 'DataTransferRate',
units: ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'],
baseUnitsMetric: ['bit/s'],
baseUnitsBinary: ['bit/s']
}, {
dimension: 'Density',
units: ['g/l', 'g/m³', 'kg/m³'],
baseUnits: ['lb/in³'],
baseUnitsMetric: ['g/m³', 'g/mm³', 'g/cm³', 'g/dm³', 'g/ml', 'g/cl', 'g/dl', 'g/l']
}, {
dimension: 'Dimensionless',
units: ['one', '%', 'dB', 'ppm', 'ppb'],
default: '%'
}, {
dimension: 'ElectricCapcitance',
baseUnitsMetric: ['F']
}, {
dimension: 'ElectricCharge',
units: ['Ah', 'C'],
baseUnitsMetric: ['C']
}, {
dimension: 'ElectricConductance',
baseUnitsMetric: ['S']
}, {
dimension: 'ElectricConductivity',
baseUnitsMetric: ['S/m']
}, {
dimension: 'ElectricCurrent',
baseUnitsMetric: ['A']
}, {
dimension: 'ElectricInductance',
baseUnitsMetric: ['H']
}, {
dimension: 'ElectricPotential',
baseUnitsMetric: ['V']
}, {
dimension: 'ElectricResistance',
baseUnitsMetric: ['Ω']
}, {
dimension: 'Energy',
units: ['kWh', 'Wh', 'J', 'kJ', 'cal', 'kcal'],
baseUnitsMetric: ['Ws', 'Wh', 'J', 'cal']
}, {
dimension: 'Force',
units: ['N', 'kN'],
baseUnitsMetric: ['N']
}, {
dimension: 'Frequency',
units: ['Hz', 'kHz', 'MHz', 'GHz', 'rpm'],
baseUnitsMetric: ['Hz']
}, {
dimension: 'Intensity',
units: ['W/m²', 'µW/cm²'],
baseUnitsMetric: ['W/mm²', 'W/cm²', 'W/dm²', 'W/m²']
}, {
dimension: 'Length',
unitsSI: ['mm', 'cm', 'dm', 'm', 'km'],
unitsUS: ['in', 'ft', 'mi'],
baseUnits: ['yd', 'ch', 'fur', 'lea']
}, {
dimension: 'LuminousFlux',
baseUnitsMetric: ['lm']
}, {
dimension: 'LuminousIntensity',
baseUnitsMetric: ['cd']
}, {
dimension: 'MagneticFlux',
baseUnitsMetric: ['T']
}, {
dimension: 'Mass',
unitsSI: ['mg', 'g', 'kg', 't'],
baseUnits: ['lb', 'oz', 'st'],
baseUnitsMetric: ['g', 't']
}, {
dimension: 'Power',
units: ['W', 'kW', 'VA', 'kVA', 'var', 'kvar', 'dBm'],
baseUnits: ['hp', 'kgf', 'lbf'],
baseUnitsMetric: ['W', 'VA', 'var']
}, {
dimension: 'Pressure',
unitsSI: ['Pa', 'hPa', 'bar', 'mbar', 'mmHg'],
unitsUS: ['inHg', 'psi'],
baseUnits: ['atm'],
baseUnitsMetric: ['Pa', 'bar']
}, {
dimension: 'RadiationAbsorbedDose',
baseUnitsMetric: ['Gy']
}, {
dimension: 'RadiationEffectiveDose',
baseUnitsMetric: ['Sv']
}, {
dimension: 'Radioactivity',
unitsSI: ['Bq', 'Ci'],
baseUnitsMetric: ['Ci']
}, {
dimension: 'Speed',
unitsSI: ['km/h', 'm/s'],
unitsUS: ['mph', 'in/h'],
baseUnits: ['kn'],
baseUnitsMetric: ['m/s', 'm/h']
}, {
dimension: 'Temperature',
unitsSI: ['°C', 'K'],
unitsUS: ['°F', 'K'],
baseUnits: ['mired']
}, {
dimension: 'Time',
units: ['s', 'min', 'h', 'd', 'wk', 'mo', 'y']
}, {
dimension: 'Volume',
unitsSI: ['ml', 'cl', 'l', 'm³'],
unitsUS: ['gal'],
baseUnits: ['in³', 'ft³'],
baseUnitsMetric: ['l', 'm³']
}, {
dimension: 'VolumetricFlowRate',
unitsSI: ['l/s', 'l/min', 'm³/s', 'm³/min', 'm³/h', 'm³/d'],
unitsUS: ['gal/min'],
baseUnitsMetric: ['m³/s', 'm³/min', 'm³/h', 'm³/d']
}]

/**
* Metric prefixes for metric base units.
* @type {string[]}
*/
export const MetricPrefixes = ['y', 'z', 'a', 'f', 'p', 'n', 'µ', 'm', 'c', 'd', 'da', 'h', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']

/**
* Binary prefixes for binary base units.
* @type {string[]}
*/
export const BinaryPrefixes = ['ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi']
132 changes: 111 additions & 21 deletions bundles/org.openhab.ui/web/src/components/item/group-form.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<template>
<div class="group-form no-padding">
<!-- Type -->
<f7-list-item v-if="item.type === 'Group'" :disabled="!editable" title="Members Base Type" class="align-popup-list-item" smart-select :smart-select-params="{searchbar: true, openIn: 'popup', closeOnSelect: true}">
<f7-list-item v-if="item.type === 'Group'" :disabled="!editable" title="Members Base Type" class="aligned-smart-select" smart-select :smart-select-params="{searchbar: true, openIn: 'popup', closeOnSelect: true}">
<select name="select-basetype" @change="groupType = $event.target.value">
<option v-for="type in types.GroupTypes" :key="type" :value="type" :selected="item.groupType ? type === item.groupType.split(':')[0] : false">
<option v-for="type in types.GroupTypes" :key="type" :value="type" :selected="item.groupType ? type === item.groupType : false">
{{ type }}
</option>
</select>
</f7-list-item>
<!-- Dimension -->
<f7-list-item v-if="dimensions.length && item.groupType && item.groupType.startsWith('Number')" :disabled="!editable" title="Dimension" class="align-popup-list-item" smart-select :smart-select-params="{searchbar: true, openIn: 'popup', closeOnSelect: true}">
<f7-list-item v-if="dimensions.length && item.groupType && item.groupType.startsWith('Number')" :disabled="!editable" title="Dimension" class="aligned-smart-select" smart-select :smart-select-params="{searchbar: true, openIn: 'popup', closeOnSelect: true}">
<select name="select-dimension" @change="groupDimension = $event.target.value">
<option key="" value="Number" :selected="item.type === 'Number'" />
<option v-for="d in dimensions" :key="d.name" :value="d.name" :selected="'Number:' + d.name === item.groupType">
Expand All @@ -18,18 +18,19 @@
</select>
</f7-list-item>
<!-- (Internal) Unit & State Description -->
<template v-if="createMode && groupType && groupDimension">
<f7-list-input label="Unit"
type="text"
info="Used internally, for persistence and external systems. It is independent from the state visualization in the UI, which is defined through the state description."
:value="item.unit"
@input="item.unit = $event.target.value" clear-button />
<f7-list-input label="State Description Pattern"
type="text"
info="Pattern or transformation applied to the state for display purposes. Only saved if you change the pre-filled default value."
:value="item.stateDescriptionPattern"
@input="item.stateDescriptionPattern = $event.target.value" clear-button />
</template>
<f7-list-input v-show="groupType && groupDimension && dimensionsReady"
ref="groupUnit"
label="Unit"
type="text"
:info="(createMode) ? 'Type a valid unit for the dimension or select from the proposed units. Used internally, for persistence and external systems. Is independent from state visualization in the UI, which is defined through the state description pattern.' : ''"
:value="groupDimension ? groupUnit : ''"
@change="groupUnit = $event.target.value" :clear-button="editable" />
<f7-list-input v-show="groupType && groupDimension"
label="State Description Pattern"
type="text"
:info="(createMode) ? 'Pattern or transformation applied to the state for display purposes. Only saved if you change the pre-filled default value.' : ''"
:value="getStateDescription()"
@input="item.stateDescriptionPattern = $event.target.value" :clear-button:="editable" />
<!-- Aggregation Functions -->
<f7-list-item v-if="aggregationFunctions" :disabled="!editable" title="Aggregation Function" class="align-popup-list-item" smart-select :smart-select-params="{openIn: 'popup', closeOnSelect: true}">
<select name="select-function" @change="groupFunctionKey = $event.target.value">
Expand Down Expand Up @@ -60,7 +61,15 @@ export default {
props: ['item', 'createMode'],
data () {
return {
types
types,
groupUnitAutocomplete: null,
oldGroupDimension: '',
oldGroupUnit: ''
}
},
watch: {
dimensionsReady (newValue, oldValue) {
if (oldValue === false && newValue === true) this.initializeAutocompleteGroupUnit()
}
},
computed: {
Expand All @@ -74,6 +83,9 @@ export default {
set (newType) {
const previousAggregationFunctions = this.aggregationFunctions
this.$set(this.item, 'groupType', '')
if (!this.createMode) {
this.oldGroupDimension = ''
}
this.$nextTick(() => {
if (newType !== 'None') {
this.$set(this.item, 'groupType', newType)
Expand All @@ -86,18 +98,32 @@ export default {
},
groupDimension: {
get () {
const parts = this.item.groupType.split(':')
return parts.length > 1 ? parts[1] : ''
const parts = this.item.groupType?.split(':')
return parts && parts.length > 1 ? parts[1] : ''
},
set (newDimension) {
if (!this.createMode) {
this.oldGroupDimension = this.groupDimension
}
if (!newDimension) {
this.groupType = 'Number'
return
}
const dimension = this.dimensions.find((d) => d.name === newDimension)
this.$set(this.item, 'groupType', 'Number:' + dimension.name)
this.$set(this.item, 'unit', dimension.systemUnit)
this.$set(this.item, 'stateDescriptionPattern', `%.0f ${dimension.systemUnit}`)
this.groupUnit = this.getUnitHint(dimension.name)
this.$set(this.item, 'stateDescriptionPattern', this.getStateDescription())
}
},
groupUnit: {
get () {
return this.unit
},
set (newUnit) {
if (!this.createMode) {
this.oldGroupUnit = this.unit
}
this.$set(this.item, 'unit', newUnit)
}
},
groupFunctionKey: {
Expand Down Expand Up @@ -146,7 +172,71 @@ export default {
this.item.functionKey += '_' + this.item.function.params.join('_')
}
} else {
this.$set(this.item, 'functionKey', '')
this.$set(this.item, 'functionKey', 'None')
}
},
methods: {
dimensionChanged () {
if (!this.oldGroupDimension) {
return false
}
return this.oldGroupDimension !== this.dimension
},
unitChanged () {
return this.oldGroupUnit && this.item.unit && this.oldGroupUnit !== this.item.unit
},
revertDimensionChange () {
if (!this.oldGroupDimension) {
this.groupType = 'Number'
this.$set(this.item, 'unit', '')
} else {
this.groupType = 'Number:' + this.oldGroupDimension
this.$set(this.item, 'unit', this.oldGroupUnit)
}
},
getStateDescription () {
return this.item.stateDescriptionPattern ? this.item.stateDescriptionPattern : '%.0f %unit%'
},
initializeAutocompleteGroupUnit () {
const self = this
const unitControl = this.$refs.groupUnit
if (!unitControl || !unitControl.$el) return
const inputElement = this.$$(unitControl.$el).find('input')
this.groupUnitAutocomplete = this.$f7.autocomplete.create({
inputEl: inputElement,
openIn: 'dropdown',
dropdownPlaceholderText: self.getUnitHint(this.dimension),
source (query, render) {
let curatedUnits = self.groupDimension ? self.getUnitList(self.groupDimension) : []
let allUnits = self.groupDimension ? self.getFullUnitList(self.groupDimension) : []
if (!query || !query.length) {
// Render curated list by default
render(curatedUnits)
} else {
let units = curatedUnits.filter(u => u.indexOf(query) >= 0)
if (units.length) {
// Show full curated list if in curated list
render(curatedUnits)
} else {
// If no match filter on full list
render(allUnits.filter(u => u.indexOf(query) >= 0))
}
}
}
})
}
},
mounted () {
if (!this.createMode && this.groupDimension) {
this.oldGroupDimension = this.groupDimension
this.oldGroupUnit = this.groupUnit
if (this.dimensionsReady) this.initializeAutocompleteGroupUnit()
}
},
beforeDestroy () {
if (this.groupUnitAutocomplete) {
this.$f7.autocomplete.destroy(this.groupUnitAutocomplete)
this.groupUnitAutocomplete = null
}
}
}
Expand Down
Loading

0 comments on commit 2f17183

Please sign in to comment.