Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolves #685 : Implement new MQTT API #686

Merged
merged 11 commits into from Apr 3, 2020
9 changes: 8 additions & 1 deletion front/src/config/i18n/en.json
Expand Up @@ -355,6 +355,7 @@
"noDevices": "No MQTT devices added yet.",
"noNameLabel": "No name",
"nameLabel": "Name",
"externalIdLabel": "External ID",
"roomLabel": "Room",
"featuresLabel": "Features",
"noFeatures": "No features",
Expand All @@ -373,14 +374,20 @@
"nameLabel": "Name",
"namePlaceholder": "Enter feature name",
"externalIdLabel": "Feature external ID",
"externalIdMessage": "The feature external ID is an unique ID which helps you identify this feature outside of Gladys when sending a MQTT message. It musts start with \"mqtt:\". Read the MQTT API documentation <a href=\"https://documentation.gladysassistant.com/en/api#mqtt-api\">here</a>.",
"externalIdMessage": "The external ID is an unique ID which helps you identify this device outside of Gladys when sending a MQTT message. It musts start with \"mqtt:\". Read the MQTT API documentation <a href=\"https://documentation.gladysassistant.com/en/api#mqtt-api\">here</a>.",
"externalIdPlaceholder": "Feature MQTT message key",
"unitLabel": "Unit",
"minLabel": "Minimum value",
"minPlaceholder": "Enter feature minimum value",
"maxLabel": "Maximum value",
"maxPlaceholder": "Enter feature maximum value",
"readOnlyLabel": "Is it a sensor?",
"readOnlyButton": "Yes, it's a sensor sending data to Gladys only.",
"addButton": "Add feature",
"mqttTopicExampleLabel": "MQTT Topic",
"copyMqttTopic": "Copy MQTT Topic",
"copied": "Copied!",
"copiedFailed": "Fail to copy",
"deleteLabel": "Delete feature"
},
"setup": {
Expand Down
16 changes: 16 additions & 0 deletions front/src/routes/integration/all/mqtt/device-page/DeviceForm.jsx
Expand Up @@ -36,6 +36,22 @@ class MqttDeviceForm extends Component {
</Localizer>
</div>

<div class="form-group">
<label class="form-label">
<Text id="integration.mqtt.device.externalIdLabel" />
</label>
<Localizer>
<input
type="text"
value={props.device.external_id}
onInput={this.updateExternalId}
disabled={props.device.created_at !== undefined}
class="form-control"
placeholder={<Text id="integration.mqtt.device.externalIdLabel" />}
/>
</Localizer>
</div>

<div class="form-group">
<label class="form-label" for="room">
<Text id="integration.mqtt.device.roomLabel" />
Expand Down
@@ -1,7 +1,7 @@
import { Text, Localizer } from 'preact-i18n';
import { Component } from 'preact';
import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_UNITS } from '../../../../../../../../server/utils/constants';
import { DeviceFeatureCategoriesIcon } from '../../../../../../utils/consts';
import { DeviceFeatureCategoriesIcon, RequestStatus } from '../../../../../../utils/consts';
import get from 'get-value';

const MqttFeatureBox = ({ children, ...props }) => {
Expand Down Expand Up @@ -154,6 +154,41 @@ const MqttFeatureBox = ({ children, ...props }) => {
</div>

<div class="form-group">
<div class="form-label">
<Text id="integration.mqtt.feature.readOnlyLabel" />
</div>
<label class="custom-switch">
<input
type="checkbox"
name={`read_only_${props.featureIndex}`}
checked={props.feature.read_only}
onClick={props.updateReadOnly}
class="custom-switch-input"
/>
<span class="custom-switch-indicator" />
<span class="custom-switch-description">
<Text id="integration.mqtt.feature.readOnlyButton" />
</span>
</label>
</div>

<div class="form-group">
<label class="form-label">
<Text id="integration.mqtt.feature.mqttTopicExampleLabel" />
</label>

<pre>
<code>{props.mqttTopic}</code>
</pre>
</div>

<div class="form-group">
<button onClick={props.copyMqttTopic} class="btn btn-outline-info mr-2">
{!props.clipboardCopiedStatus && <Text id="integration.mqtt.feature.copyMqttTopic" />}
{props.clipboardCopiedStatus === RequestStatus.Success && <Text id="integration.mqtt.feature.copied" />}
{props.clipboardCopiedStatus === RequestStatus.Error && <Text id="integration.mqtt.feature.copyFailed" />}
</button>

<button onClick={props.deleteFeature} class="btn btn-outline-danger">
<Text id="integration.mqtt.feature.deleteLabel" />
</button>
Expand All @@ -180,19 +215,48 @@ class MqttFeatureBoxComponent extends Component {
updateUnit = e => {
this.props.updateFeatureProperty(e, 'unit', this.props.featureIndex);
};
updateReadOnly = () => {
const e = {
target: {
value: !this.props.feature.read_only
}
};
this.props.updateFeatureProperty(e, 'read_only', this.props.featureIndex);
};
deleteFeature = () => {
this.props.deleteFeature(this.props.featureIndex);
};
getMqttTopic = () => {
if (this.props.feature.read_only) {
return `gladys/master/device/${this.props.device.external_id}/feature/${this.props.feature.external_id}/state`;
}
return `gladys/device/${this.props.device.external_id}/feature/${this.props.feature.external_id}/state`;
};
copyMqttTopic = async () => {
try {
this.setState({ clipboardCopiedStatus: RequestStatus.Getting });
await navigator.clipboard.writeText(this.getMqttTopic());
this.setState({ clipboardCopiedStatus: RequestStatus.Success });
setTimeout(() => this.setState({ clipboardCopiedStatus: null }), 2000);
} catch (e) {
this.setState({ clipboardCopiedStatus: RequestStatus.Error });
}
};
render() {
const mqttTopic = this.getMqttTopic();
return (
<MqttFeatureBox
{...this.props}
clipboardCopiedStatus={this.state.clipboardCopiedStatus}
updateName={this.updateName}
updateExternalId={this.updateExternalId}
updateMin={this.updateMin}
updateMax={this.updateMax}
updateUnit={this.updateUnit}
updateReadOnly={this.updateReadOnly}
deleteFeature={this.deleteFeature}
copyMqttTopic={this.copyMqttTopic}
mqttTopic={mqttTopic}
/>
);
}
Expand Down
37 changes: 34 additions & 3 deletions front/src/routes/integration/all/mqtt/device-page/setup/index.js
Expand Up @@ -54,27 +54,46 @@ class MqttDeviceSetupPage extends Component {
}

updateDeviceProperty(deviceIndex, property, value) {
const device = update(this.state.device, {
let device;
if (property === 'external_id' && !value.startsWith('mqtt:')) {
if (value.length < 5) {
value = 'mqtt:';
} else {
value = `mqtt:${value}`;
}
}

device = update(this.state.device, {
[property]: {
$set: value
}
});

if (property === 'external_id') {
device = update(device, {
selector: {
$set: value
}
});
}

this.setState({
device
});
}

updateFeatureProperty(e, property, featureIndex) {
let value = e.target.value;
let device;
if (property === 'external_id' && !value.startsWith('mqtt:')) {
if (value.length < 5) {
value = 'mqtt:';
} else {
value = `mqtt:${value}`;
}
}
const device = update(this.state.device, {

device = update(this.state.device, {
features: {
[featureIndex]: {
[property]: {
Expand All @@ -84,6 +103,18 @@ class MqttDeviceSetupPage extends Component {
}
});

if (property === 'external_id') {
device = update(device, {
features: {
[featureIndex]: {
selector: {
$set: value
}
}
}
});
}

this.setState({
device
});
Expand Down Expand Up @@ -144,7 +175,7 @@ class MqttDeviceSetupPage extends Component {
id: uniqueId,
name: null,
should_poll: false,
external_id: uniqueId,
external_id: 'mqtt:',
service_id: this.props.currentIntegration.id,
features: []
};
Expand Down
24 changes: 9 additions & 15 deletions server/services/mqtt/lib/handleNewMessage.js
Expand Up @@ -8,25 +8,19 @@ const logger = require('../../../utils/logger');
* handleNewMessage('/gladys/master/heartbeat', '{}');
*/
function handleNewMessage(topic, message) {
logger.trace(`Receives MQTT message from ${topic} : ${message}`);
logger.debug(`Receives MQTT message from ${topic} : ${message}`);

try {
let forwardedMessage = false;

const extactMatch = this.topicBinds[topic];
if (extactMatch) {
forwardedMessage = true;
extactMatch(this, topic, message);
} else {
Object.keys(this.topicBinds).forEach((key) => {
const regexKey = key.replace('+', '[^/]+').replace('#', '.+');

if (topic.match(regexKey)) {
forwardedMessage = true;
this.topicBinds[key](topic, message);
}
}, this);
}
// foreach topic, we see if it matches
Object.keys(this.topicBinds).forEach((key) => {
const regexKey = key.replace('+', '[^/]+').replace('#', '.+');
if (topic.match(regexKey)) {
forwardedMessage = true;
this.topicBinds[key](topic, message);
}
}, this);

if (!forwardedMessage) {
logger.warn(`No subscription found for MQTT topic ${topic}`);
Expand Down
24 changes: 14 additions & 10 deletions server/services/mqtt/lib/handler/handleGladysMessage.js
@@ -1,4 +1,5 @@
const { EVENTS } = require('../../../../utils/constants');
const { BadParameters } = require('../../../../utils/coreErrors');
const logger = require('../../../../utils/logger');

/**
Expand All @@ -9,16 +10,19 @@ const logger = require('../../../../utils/logger');
* handleGladysMessage('gladys/device/create', '{ message: "content" }');
*/
function handleGladysMessage(topic, message) {
switch (topic) {
case 'gladys/master/device/create':
this.gladys.event.emit(EVENTS.DEVICE.NEW, JSON.parse(message));
break;
case 'gladys/master/device/state/update':
this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, JSON.parse(message));
break;
default:
logger.info(`MQTT : Gladys topic ${topic} not handled.`);
break;
const parsedTopic = topic.split('/');
// Topic = gladys/master/device/:device_external_id/feature/:device_feature_external_id/state
if (topic.startsWith('gladys/master/device/')) {
if (!parsedTopic[5]) {
throw new BadParameters('Device feature external_id is required');
}
const event = {
device_feature_external_id: parsedTopic[5],
state: message,
};
this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, event);
} else {
logger.warn(`MQTT : Gladys topic ${topic} not handled.`);
}
}

Expand Down
2 changes: 2 additions & 0 deletions server/services/mqtt/lib/index.js
Expand Up @@ -7,6 +7,7 @@ const { publish } = require('./publish');
const { subscribe } = require('./subscribe');
const { unsubscribe } = require('./unsubscribe');
const { status } = require('./status');
const { setValue } = require('./setValue');

/**
* @description Add ability to connect to a MQTT broker.
Expand Down Expand Up @@ -36,5 +37,6 @@ MqttHandler.prototype.publish = publish;
MqttHandler.prototype.subscribe = subscribe;
MqttHandler.prototype.unsubscribe = unsubscribe;
MqttHandler.prototype.status = status;
MqttHandler.prototype.setValue = setValue;

module.exports = MqttHandler;
20 changes: 0 additions & 20 deletions server/services/mqtt/lib/publishMessage.js

This file was deleted.

36 changes: 36 additions & 0 deletions server/services/mqtt/lib/setValue.js
@@ -0,0 +1,36 @@
const logger = require('../../../utils/logger');
const { ServiceNotConfiguredError } = require('../../../utils/coreErrors');

/**
* @description Control a remote MQTT device
* @param {Object} device - The device to control.
* @param {Object} deviceFeature - The binary deviceFeature to control.
* @param {string|number} value - The new value.
* @returns {Promise} Resolve when the mqtt message is published.
* @example
* setValue({ external_id: 'mqtt:light'}, { external_id: 'mqtt:light:binary'}, 1);
*/
async function setValue(device, deviceFeature, value) {
logger.debug(
`Changing state of device = ${device.external_id}, feature = ${deviceFeature.external_id}, value = ${value}`,
);

if (!this.mqttClient) {
throw new ServiceNotConfiguredError();
}

const topic = `gladys/device/${device.external_id}/feature/${deviceFeature.external_id}/state`;
return new Promise((resolve, reject) => {
this.mqttClient.publish(topic, value.toString(), undefined, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}

module.exports = {
setValue,
};