Skip to content

Commit

Permalink
feat(core): Introduce ConsumableMonitoringCapability properties and r…
Browse files Browse the repository at this point in the history
…emove mqtt consumables hack
  • Loading branch information
Hypfer committed Nov 15, 2021
1 parent cf0f2d6 commit a998ee1
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 115 deletions.
14 changes: 13 additions & 1 deletion backend/lib/core/capabilities/ConsumableMonitoringCapability.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const Capability = require("./Capability");
const ConsumableStateAttribute = require("../../entities/state/attributes/ConsumableStateAttribute");
const NotImplementedError = require("../NotImplementedError");

/**
Expand All @@ -10,7 +11,7 @@ class ConsumableMonitoringCapability extends Capability {
* This function polls the current consumables state and stores the attributes in our robotState
*
* @abstract
* @returns {Promise<Array<import("../../entities/state/attributes/ConsumableStateAttribute")>>}
* @returns {Promise<Array<ConsumableStateAttribute>>}
*/
async getConsumables() {
throw new NotImplementedError();
Expand Down Expand Up @@ -42,12 +43,23 @@ class ConsumableMonitoringCapability extends Capability {
}
}

/**
*
* @return {{availableConsumables: Array<{type: ConsumableStateAttribute.TYPE, subType: ConsumableStateAttribute.SUB_TYPE, unit: ConsumableStateAttribute.UNITS}>}}
*/
getProperties() {
return {
availableConsumables: []
};
}


getType() {
return ConsumableMonitoringCapability.TYPE;
}
}


ConsumableMonitoringCapability.TYPE = "ConsumableMonitoringCapability";

module.exports = ConsumableMonitoringCapability;
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ const ComponentType = require("../homeassistant/ComponentType");
const DataType = require("../homie/DataType");
const HassAnchor = require("../homeassistant/HassAnchor");
const InLineHassComponent = require("../homeassistant/components/InLineHassComponent");
const Logger = require("../../Logger");
const PropertyMqttHandle = require("../handles/PropertyMqttHandle");
const Semaphore = require("semaphore");
const stateAttrs = require("../../entities/state/attributes");
const Unit = require("../common/Unit");
const {Commands} = require("../common");
Expand All @@ -23,8 +21,7 @@ class ConsumableMonitoringCapabilityMqttHandle extends CapabilityMqttHandle {
super(Object.assign(options, {
friendlyName: "Consumables monitoring",
helpMayChange: {
"Properties": "Consumables depend on the robot model and may be discovered at runtime. Always look" +
" for changes in `$properties` while `$state` is `init`.",
"Properties": "Consumables depend on the robot model.",
"Property datatype and units": "Some robots send consumables as remaining time, others send them as " +
"endurance percent remaining.",
}
Expand All @@ -48,92 +45,96 @@ class ConsumableMonitoringCapabilityMqttHandle extends CapabilityMqttHandle {
})
);

this.registeredConsumables = [];
this.lastGetConsumables = 0;
this.findNewConsumablesMutex = Semaphore(1);

this.onStatusSubscriberEventTimeout = null;
this.capability.getProperties().availableConsumables.forEach(consumable => {
this.addNewConsumable(
this.genConsumableTopicId(consumable.type, consumable.subType),
consumable.type,
consumable.subType,
consumable.unit
);
});
}

/**
* @private
* @param {import("../../entities/state/attributes/ConsumableStateAttribute")} attribute
* @param {import("../../entities/state/attributes/ConsumableStateAttribute").TYPE} type
* @param {import("../../entities/state/attributes/ConsumableStateAttribute").SUB_TYPE} subType
* @return {string}
*/
genConsumableTopicId(attribute) {
let name = attribute.type;
if (attribute.subType !== stateAttrs.ConsumableStateAttribute.SUB_TYPE.NONE) {
name += "-" + attribute.subType;
genConsumableTopicId(type, subType) {
let name = type;
if (subType !== stateAttrs.ConsumableStateAttribute.SUB_TYPE.NONE) {
name += "-" + subType;
}
return name;
}

/**
* @private
* @param {import("../../entities/state/attributes/ConsumableStateAttribute")} attribute
* @param {import("../../entities/state/attributes/ConsumableStateAttribute").TYPE} type
* @param {import("../../entities/state/attributes/ConsumableStateAttribute").SUB_TYPE} subType
* @return {string}
*/
genConsumableFriendlyName(attribute) {
genConsumableFriendlyName(type, subType) {
let name = "";
if (attribute.subType !== stateAttrs.ConsumableStateAttribute.SUB_TYPE.NONE && attribute.subType !== stateAttrs.ConsumableStateAttribute.SUB_TYPE.ALL) {
name += SUBTYPE_MAPPING[attribute.subType] + " ";
if (subType !== stateAttrs.ConsumableStateAttribute.SUB_TYPE.NONE && subType !== stateAttrs.ConsumableStateAttribute.SUB_TYPE.ALL) {
name += SUBTYPE_MAPPING[subType] + " ";
}
name += TYPE_MAPPING[attribute.type];
name += TYPE_MAPPING[type];
return name;
}

/**
* @private
* @param {string} topicId
* @param {import("../../entities/state/attributes/ConsumableStateAttribute")} attr
* @return {Promise<void>}
* @param {import("../../entities/state/attributes/ConsumableStateAttribute").TYPE} type
* @param {import("../../entities/state/attributes/ConsumableStateAttribute").SUB_TYPE} subType
* @param {import("../../entities/state/attributes/ConsumableStateAttribute").UNITS} unit
* @return {void}
*/
async addNewConsumable(topicId, attr) {
this.registeredConsumables.push(topicId);

addNewConsumable(topicId, type, subType, unit) {
this.registerChild(
new PropertyMqttHandle({
parent: this,
controller: this.controller,
topicName: topicId,
friendlyName: this.genConsumableFriendlyName(attr),
friendlyName: this.genConsumableFriendlyName(type, subType),
datatype: DataType.INTEGER,
unit: attr.remaining.unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ? Unit.PERCENT : undefined,
format: attr.remaining.unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ? "0:100" : undefined,
unit: unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ? Unit.PERCENT : undefined,
format: unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ? "0:100" : undefined,
getter: async () => {
const newAttr = this.robot.state.getFirstMatchingAttribute({
attributeClass: stateAttrs.ConsumableStateAttribute.name,
attributeType: attr.type,
attributeSubType: attr.subType
attributeType: type,
attributeSubType: subType
});

if (newAttr) {
// Raw value for Home Assistant
await HassAnchor.getAnchor(HassAnchor.ANCHOR.CONSUMABLE_VALUE + topicId).post(newAttr.remaining.value);

// Convert value to seconds for Homie
return newAttr.remaining.value * (attr.remaining.unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ? 1 : 60);
return newAttr.remaining.value * (unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ? 1 : 60);
}

return null;
},
helpText: attr.remaining.unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ?
helpText: unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ?
"This handle returns the consumable remaining endurance percentage." :
"This handle returns the consumable remaining endurance time as an ISO8601 duration. " +
"The controlled Home Assistant component will report it as seconds instead."
"This handle returns the consumable remaining endurance time as an int representing seconds remaining."
}).also((prop) => {
this.controller.withHass((hass) => {
prop.attachHomeAssistantComponent(
new InLineHassComponent({
hass: hass,
robot: this.robot,
name: this.capability.getType() + "_" + topicId.replace("-", "_"),
friendlyName: this.genConsumableFriendlyName(attr),
friendlyName: this.genConsumableFriendlyName(type, subType),
componentType: ComponentType.SENSOR,
baseTopicReference: HassAnchor.getTopicReference(HassAnchor.REFERENCE.HASS_CONSUMABLE_STATE + topicId),
autoconf: {
state_topic: HassAnchor.getTopicReference(HassAnchor.REFERENCE.HASS_CONSUMABLE_STATE + topicId),
unit_of_measurement: attr.remaining.unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ? "Percent" : "Minutes",
unit_of_measurement: unit === stateAttrs.ConsumableStateAttribute.UNITS.PERCENT ? "Percent" : "Minutes",
icon: "mdi:progress-wrench",
},
topics: {
Expand All @@ -146,84 +147,6 @@ class ConsumableMonitoringCapabilityMqttHandle extends CapabilityMqttHandle {
);
}

findNewConsumables() {
return new Promise((resolve, reject) => {
this.findNewConsumablesMutex.take(async () => {
try {
const consumables = this.robot.state.getMatchingAttributes(this.getInterestingStatusAttributes()[0]);
const newConsumables = {};
for (const attr of consumables) {
const topicId = this.genConsumableTopicId(attr);
if (!this.registeredConsumables.includes(topicId)) {
newConsumables[topicId] = attr;
}
}

if (Object.keys(newConsumables).length > 0) {
await this.controller.reconfigure(async () => {
try {
await this.deconfigure({
cleanHomie: false,
cleanHass: false,
});

for (const [topicId, attr] of Object.entries(newConsumables)) {
await this.addNewConsumable(topicId, attr);
}

await this.configure();

this.findNewConsumablesMutex.leave();
resolve();
} catch (e) {
this.findNewConsumablesMutex.leave();
reject(e);
}
});
} else {
this.findNewConsumablesMutex.leave();
resolve();
}
} catch (e) {
this.findNewConsumablesMutex.leave();
reject(e);
}

});
});
}

async refresh() {
await this.findNewConsumables();
await super.refresh();

// Warning: hack
// Avoid causing a recursion chain (newly added consumables will cause refresh to be called)
if (this.lastGetConsumables + this.controller.refreshInterval > Date.now()) {
return;
}
this.lastGetConsumables = Date.now();

setTimeout(() => {
this.capability.getConsumables().catch((reason => {
Logger.warn("Failed to get consumables:", reason);
}));
}, 10000);
}

onStatusSubscriberEvent(eventType, attribute, previousAttribute) {
if (this.refreshRequired(eventType, attribute, previousAttribute)) {
if (this.onStatusSubscriberEventTimeout) {
clearTimeout(this.onStatusSubscriberEventTimeout);
}

// As this callback is being called for each consumable on each update, debouncing this means that we don't have n duplicate updates on each poll
this.onStatusSubscriberEventTimeout = setTimeout(() => {
this.refresh().then();
}, 250);
}
}

getInterestingStatusAttributes() {
return [{attributeClass: stateAttrs.ConsumableStateAttribute.name}];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,28 @@ class Dreame1CConsumableMonitoringCapability extends ConsumableMonitoringCapabil
return consumable;
}
}

getProperties() {
return {
availableConsumables: [
{
type: ConsumableStateAttribute.TYPE.BRUSH,
subType: ConsumableStateAttribute.SUB_TYPE.MAIN,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.BRUSH,
subType: ConsumableStateAttribute.SUB_TYPE.SIDE_RIGHT,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.FILTER,
subType: ConsumableStateAttribute.SUB_TYPE.MAIN,
unit: ConsumableStateAttribute.UNITS.MINUTES
}
]
};
}
}

module.exports = Dreame1CConsumableMonitoringCapability;
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,33 @@ class DreameConsumableMonitoringCapability extends ConsumableMonitoringCapabilit
return consumable;
}
}

getProperties() {
return {
availableConsumables: [
{
type: ConsumableStateAttribute.TYPE.BRUSH,
subType: ConsumableStateAttribute.SUB_TYPE.MAIN,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.BRUSH,
subType: ConsumableStateAttribute.SUB_TYPE.SIDE_RIGHT,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.FILTER,
subType: ConsumableStateAttribute.SUB_TYPE.MAIN,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.SENSOR,
subType: ConsumableStateAttribute.SUB_TYPE.ALL,
unit: ConsumableStateAttribute.UNITS.MINUTES
}
]
};
}
}

module.exports = DreameConsumableMonitoringCapability;
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,33 @@ class RoborockConsumableMonitoringCapability extends ConsumableMonitoringCapabil
throw new Error("No such consumable");
}
}

getProperties() {
return {
availableConsumables: [
{
type: ConsumableStateAttribute.TYPE.BRUSH,
subType: ConsumableStateAttribute.SUB_TYPE.MAIN,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.BRUSH,
subType: ConsumableStateAttribute.SUB_TYPE.SIDE_RIGHT,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.FILTER,
subType: ConsumableStateAttribute.SUB_TYPE.MAIN,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.SENSOR,
subType: ConsumableStateAttribute.SUB_TYPE.ALL,
unit: ConsumableStateAttribute.UNITS.MINUTES
}
]
};
}
}

const CONSUMABLE_TYPE_MAP = Object.freeze({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ class ViomiConsumableMonitoringCapability extends ConsumableMonitoringCapability
async resetConsumable(type, subType) {
throw new Error("Not implemented");
}

getProperties() {
return {
availableConsumables: [
{
type: ConsumableStateAttribute.TYPE.BRUSH,
subType: ConsumableStateAttribute.SUB_TYPE.MAIN,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.BRUSH,
subType: ConsumableStateAttribute.SUB_TYPE.SIDE_RIGHT,
unit: ConsumableStateAttribute.UNITS.MINUTES
},
{
type: ConsumableStateAttribute.TYPE.FILTER,
subType: ConsumableStateAttribute.SUB_TYPE.MAIN,
unit: ConsumableStateAttribute.UNITS.MINUTES
}
]
};
}
}

module.exports = ViomiConsumableMonitoringCapability;
Loading

0 comments on commit a998ee1

Please sign in to comment.