Skip to content

Commit

Permalink
Non-initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
kabbi committed Nov 16, 2020
0 parents commit 4d04d7b
Show file tree
Hide file tree
Showing 11 changed files with 1,000 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
MQTT_URL=mqtt://keeper.local
MESH_MQTT_PREFIX=mesh2mqtt
HASS_MQTT_PREFIX=mesh2hass
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/.vscode
/node_modules
keychain.json
.env
.seqs
4 changes: 4 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
mesh2mqtt
---------

A demo project for my smart house to make BLE Mesh and Home Assistant work together.

System components:
- ble mesh network (i'm using some yeelight bulbs and some custom nrf52 devices)
- meshbridge - custom esp32 firmware to relay mesh messages to mqtt and vice versa
- mesh2mqtt.js - handles all the mesh stack functions - crypto, segmentation, parsing, outputs nice jsons to mqtt topics
- mesh2hass.js - listens those jsons and provides hass mqtt discovery and high-level control endpoints

This does not currently support auto-discovery of mesh devices, so you should configure them manually in `devices.js`

Supported device types:
- `onoff-client-button` - mesh buttons (actually, anything using Generic OnOff Client can be configured as hass button)

6 changes: 6 additions & 0 deletions devices.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = [
{
addr: '1006',
type: 'onoff-client-button',
},
];
12 changes: 12 additions & 0 deletions keychain.json.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"network": "1234567890abcdef1234567890abcdef",
"devices": {
"1": "1234567890abcdef1234567890abcdef",
"2": "1234567890abcdef1234567890abcdef",
"1234": "1234567890abcdef1234567890abcdef"
},
"apps": {
"blinker": "1234567890abcdef1234567890abcdef",
"living-room": "1234567890abcdef1234567890abcdef"
}
}
123 changes: 123 additions & 0 deletions mesh2hass.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const fs = require('fs');
const debug = require('debug')('app');
const mqtt = require('./mqtt');
require('dotenv').config();

const devices = require('./devices');

const client = mqtt.connect(process.env.MQTT_URL || 'localhost');
const prefix = process.env.HASS_MQTT_PREFIX || 'mesh2hass';
const meshPrefix = process.env.MESH_MQTT_PREFIX || 'mesh2mqtt';

const transactionsSeen = new Set();

const deviceHandlers = {
'onoff-client-button': async (config) => {
const { addr } = config;

await client.publish(
`homeassistant/device_automation/${addr}/action_short_press/config`,
JSON.stringify({
automation_type: 'trigger',
device: {
identifiers: [`blemesh_${addr}`],
name: addr,
},
payload: 'button_short_press',
subtype: 'button_1',
topic: `${prefix}/${addr}/action`,
type: 'action',
}),
);

await client.handle(
`${meshPrefix}/${addr}/models/generic-onoff/set-unack`,
(match, payload) => {
const { status, transactionId } = JSON.parse(payload);
const transactionKey = `${addr}-${transactionId}`;
if (transactionsSeen.has(transactionKey)) {
return;
}

transactionsSeen.add(transactionKey);
client.publish(`${prefix}/${addr}/action`, 'button_short_press');
},
);
},
};

client.on('connect', async () => {
console.log('- setting up devices');

for (const device of devices) {
const handler = deviceHandlers[device.type];
if (!handler) {
console.error('Unsupported device type', device.type);
continue;
}

console.log(`- [${device.addr}] ${device.type}`);
handler(device);
}
});

// accessLayer.on('incoming', (msg) => {
// debug('incoming message', msg);
// const id = msg.meta.from.toString(16).padStart(4, '0');

// if (id === '1006') {
// if (!devicesSeen.has(msg.meta.from)) {
// devicesSeen.add(msg.meta.from);
// client.publish(
// `homeassistant/device_automation/${id}/action_short_press/config`,
// JSON.stringify({
// automation_type: 'trigger',
// device: {
// identifiers: [`blemesh_${id}`],
// name: id,
// },
// payload: 'button_short_press',
// subtype: 'button_1',
// topic: `mesh2mqtt/devices/${id}/action`,
// type: 'action',
// }),
// );
// }
// if (msg.type === 'GenericOnOffSetUnacknowledged') {
// const transactionKey = `${id}:${msg.payload.transactionId}`;
// if (msg.payload.transactionId && transactionsSeen.has(transactionKey)) {
// return;
// }
// client.publish(`${prefix}/devices/${id}/action`, 'button_short_press');
// transactionsSeen.add(transactionKey);
// }
// }

// if (id === '0015') {
// if (!devicesSeen.has(msg.meta.from)) {
// devicesSeen.add(msg.meta.from);
// client.publish(
// `homeassistant/light/${id}/light/config`,
// JSON.stringify({
// device: {
// identifiers: [`blemesh_${id}`],
// name: id,
// },
// schema: 'json',
// command_topic: `mesh2mqtt/devices/${id}/light/set`,
// state_topic: `mesh2mqtt/devices/${id}/light/state`,
// name: `${id}_light`,
// unique_id: id,
// }),
// );
// }
// if (msg.type === 'GenericOnOffStatus') {
// client.publish(
// `${prefix}/devices/${id}/light/status`,
// JSON.stringify({
// state: `${msg.payload.currentStatus}`.toUpperCase(),
// }),
// );
// }
// }
// });
113 changes: 113 additions & 0 deletions mesh2mqtt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
const fs = require('fs');
const debug = require('debug')('app');
const {
NetworkLayer,
AccessLayer,
UpperLayer,
LowerLayer,
Keychain,
} = require('mesh-first-try');
const { kebabCase } = require('lodash');
const mqtt = require('./mqtt');
require('dotenv').config();

const client = mqtt.connect(process.env.MQTT_URL || 'localhost');
const prefix = process.env.MESH_MQTT_PREFIX || 'mesh2mqtt';
const ownMeshAddr = 0x7fc;
let bridgeAddr; // FIXME

const keychain = new Keychain();
keychain.load(require('./keychain.json'));

// TODO: Make configurable
const TypeToTopic = {
GenericOnOffGet: ['generic-onoff', 'get'],
GenericOnOffSet: ['generic-onoff', 'set'],
GenericOnOffStatus: ['generic-onoff', 'status'],
GenericOnOffSetUnacknowledged: ['generic-onoff', 'set-unack'],
LightLightnessStatus: ['light-lightness', 'status'],
LightLightnessSetUnacknowledged: ['light-lightness', 'set-unack'],
LightCTLTemperatureStatus: ['light-ctl', 'temp-status'],
LightCTLTemperatureSetUnacknowledged: ['light-ctl', 'temp-set-unack'],
};

// Setup mesh stuff
const networkLayer = new NetworkLayer(keychain);
const lowerLayer = new LowerLayer(keychain);
const upperLayer = new UpperLayer();
const accessLayer = new AccessLayer();

// Connect all the layers together
networkLayer.on('incoming', (networkMessage) => {
lowerLayer.handleIncoming(networkMessage);
});
lowerLayer.on('incoming', (lowerTransportMessage) => {
upperLayer.handleIncoming(lowerTransportMessage);
});
upperLayer.on('incoming', (accessMessage) => {
accessLayer.handleIncoming(accessMessage);
});
accessLayer.on('outgoing', (accessMessage) => {
upperLayer.handleOutgoing(accessMessage);
});
upperLayer.on('outgoing', (lowerTransportMessage) => {
lowerLayer.handleOutgoing(lowerTransportMessage);
});
lowerLayer.on('outgoing', (networkMessage) => {
networkLayer.handleOutgoing(networkMessage);
});

// Handle all the mqtt topics
client.on('connect', async () => {
await client.publish(`${prefix}/online`, 'true');

// Route meshbridge messages from mqtt to mesh stack and back
await client.handle('meshbridge/:addr/msg', (match, payload) => {
bridgeAddr = match.params.addr;
networkLayer.handleIncoming(payload);
});
networkLayer.on('outgoing', async (payload) => {
debug('sending', payload.toString('hex'));
await client.publish(`meshbridge/${bridgeAddr}/msg/send`, payload);
});

// Model behaviour, outgoing
await client.handle(
`${prefix}/:addr/models/:model/:op/send`,
(match, payload) => {
const { addr, model, op } = match.params;

const type = Object.keys(TypeToTopic).find((key) => {
const [modelName, opName] = TypeToTopic[key];
return modelName === model && opName === op;
});

accessLayer.handleOutgoing({
type,
appKey: 'mi',
payload: JSON.parse(payload),
meta: {
to: Number.parseInt(addr, 16),
from: ownMeshAddr,
ttl: 5,
},
});
},
);

// Model behaviour, incoming
accessLayer.on('incoming', async (msg) => {
debug('incoming message', msg);
const addr = msg.meta.from.toString(16).padStart(4, '0');

await client.publish(`${prefix}/rx`, JSON.stringify(msg));

const topic = TypeToTopic[msg.type];
if (topic) {
await client.publish(
`${prefix}/${addr}/models/${topic.join('/')}`,
JSON.stringify(msg.payload),
);
}
});
});
36 changes: 36 additions & 0 deletions mqtt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const mqtt = require('async-mqtt');
const { parse, match } = require('path-to-regexp');

const patchClient = (client) => {
client.handle = async (topicPattern, handler) => {
const tokens = parse(topicPattern);
const matcher = match(topicPattern);

const wildcardTopic = tokens
.map((v) => {
if (typeof v === 'string') {
return v;
}
return `${v.prefix}+`;
})
.join('');

await client.subscribe(wildcardTopic);
client.on('message', (topic, payload) => {
const matched = matcher(topic);
if (!matched) {
return;
}

handler(matched, payload);
});
};
};

module.exports = {
connect: (...args) => {
const client = mqtt.connect(...args);
patchClient(client);
return client;
},
};
Loading

0 comments on commit 4d04d7b

Please sign in to comment.