Skip to content

Commit

Permalink
MQTT and HTTP can both be used at same time
Browse files Browse the repository at this point in the history
  • Loading branch information
Jordan Hotmann committed May 26, 2022
1 parent 1c86470 commit c50ea6f
Show file tree
Hide file tree
Showing 11 changed files with 124 additions and 37 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The recommended and supported installation method is using [Docker](https://www.
| Environmental Variable | Description | Default | Example |
| ----- | ----- | ----- | ----- |
| HTTP_HOST | The protocol, hostname, and port (if necessary) for clients to connect to your server | | `http://192.168.0.2:8000` or `https://pinpoint.example.com` |
| MQTT_HOST | The hostname for clients to connect to the MQTT server. If set, client configuration links will use MQTT instead of HTTP settings. | | `pinpointmqtt.example.com` |
| MQTT_HOST | The hostname for clients to connect to the MQTT server. If not set, MQTT mode will be disabled. | | `pinpointmqtt.example.com` |
| ADMIN_PASSWORD | The password for the admin account | `pinpointadmin` | `mysupersecretpassword` |
| APPRISE_HOST | Either `cli` to use the [Apprise CLI](https://github.com/caronc/apprise) to send notifications or the protocol, hostname, and port of an [Apprise API](https://github.com/caronc/apprise-api) server | `cli` | `http://127.0.0.1:8000` |
| APPRISE_EMAIL_URL | [Apprise URI](https://github.com/caronc/apprise/wiki) for sending emails to users. The user's email address will be appended to this to build the final URI. If not set, notifications will be disabled. | | `mailgun://[email protected]/my-mailgun-token/` |
Expand Down
14 changes: 14 additions & 0 deletions bin/www
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,27 @@
require('dotenv').config();

const aedes = require('aedes')();
const async = require('async');
const { CronJob } = require('cron');
const http = require('http');
const ws = require('websocket-stream');
const app = require('../src/app');
const { CardSeen } = require('../src/models/CardSeen');
const { User } = require('../src/models/User');
const { Device } = require('../src/models/Device');
const mqtt = require('../src/mqtt');

// Ensure device links are up to date with current settings (in case they changed)
(async () => {
const allUsers = await User.getAll();
await async.each(allUsers, async (user) => {
const allDevices = await user.getDevices();
await async.each(allDevices, async (device) => {
await device.updateConfigLinks(user);
});
});
})();

// Get port from environment and store in Express.

const port = normalizePort(process.env.PORT || '8000');
Expand Down
63 changes: 40 additions & 23 deletions src/models/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ const { Base } = require('./Base');
userId: "string",
name: "string",
initials: ["string"],
configLink: "string",
configLink: "string", <- old way
httpConfigLink: "string",
mqttConfigLink: "string",
createdAt: Date,
updatedAt: Date,
}
Expand All @@ -24,27 +26,39 @@ class Device extends Base {
static async create(name, initials, card, user) {
const existing = await Device.findOne({ userId: user._id, name });
if (existing) return null;
const configLink = getDeviceConfig(user, name, initials);
const mqttConfigLink = getMqttDeviceConfig(user, name, initials);
const httpConfigLink = getHttpDeviceConfig(user, name, initials);
const device = new Device({
userId: user._id,
name,
initials,
card,
configLink,
httpConfigLink,
mqttConfigLink
});
await device.save();
return device;
}

async update(name, initials, card, user) {
this.configLink = getDeviceConfig(user, name, initials);
this.mqttConfigLink = getMqttDeviceConfig(user, name, initials);
this.httpConfigLink = getHttpDeviceConfig(user, name, initials);
if (this.configLink) this.configLink = null;
this.name = name;
this.initials = initials;
this.card = card;
const device = await this.save();
return device;
}

async updateConfigLinks(user) {
this.mqttConfigLink = getMqttDeviceConfig(user, this.name, this.initials);
this.httpConfigLink = getHttpDeviceConfig(user, this.name, this.initials);
if (this.configLink) this.configLink = null;
const device = await this.save();
return device;
}

static async getByUserId(userId) {
const devices = await Device.find({ userId });
return devices;
Expand All @@ -55,7 +69,27 @@ function cleanString(str) {
return str.replace(/[^A-Za-z0-9]/g, '');
}

function getDeviceConfig(userData, deviceName, initials) {
function getHttpDeviceConfig(userData, deviceName, initials) {
const httpConfig = {
_type: 'configuration',
autostartOnBoot: true,
deviceId: cleanString(deviceName),
locatorInterval: 300,
mode: 3,
monitoring: 1,
password: userData.passwordHash,
ping: 30,
pubExtendedData: true,
tid: initials,
tls: true,
url: `${process.env.HTTP_HOST}/pub`,
username: userData.username,
};
const configBuffer = Buffer.from(JSON.stringify(httpConfig), 'utf8');
return `owntracks:///config?inline=${configBuffer.toString('base64')}`;
}

function getMqttDeviceConfig(userData, deviceName, initials) {
const mqttConfig = {
_type: 'configuration',
autostartOnBoot: true,
Expand All @@ -81,24 +115,7 @@ function getDeviceConfig(userData, deviceName, initials) {
username: userData.username,
ws: true,
};

const httpConfig = {
_type: 'configuration',
autostartOnBoot: true,
deviceId: cleanString(deviceName),
locatorInterval: 300,
mode: 3,
monitoring: 1,
password: userData.passwordHash,
ping: 30,
pubExtendedData: true,
tid: initials,
tls: true,
url: `${process.env.HTTP_HOST}/pub`,
username: userData.username,
}

const configBuffer = Buffer.from(JSON.stringify(process.env.MQTT_HOST ? mqttConfig : httpConfig), 'utf8');
const configBuffer = Buffer.from(JSON.stringify(mqttConfig), 'utf8');
return `owntracks:///config?inline=${configBuffer.toString('base64')}`;
}

Expand Down
11 changes: 10 additions & 1 deletion src/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class User extends Base {
let sharers = [];

groups.forEach((group) => {
const members = group.members.map((member) => member.userId);
const members = group.members.filter((member) => member.accepted).map((member) => member.userId);
sharers = sharers.concat(members);
});

Expand All @@ -114,6 +114,15 @@ class User extends Base {
return [...new Set(sharers)];
}

async getFriendsAndGroupies() {
const groups = await this.getGroups();
let friends = [...this.friends, this.username];

friends.concat(groups.flatMap((group) => group.members.filter((member) => member.accepted).map((member) => member.username)));

return [...new Set(friends)];
}

static async getByUsername(uname) {
const user = await this.findOne({ username: uname });
return user;
Expand Down
18 changes: 12 additions & 6 deletions src/mqtt.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const { User } = require('./models/User');
const { Device } = require('./models/Device');
const { CardSeen } = require('./models/CardSeen');
const { Location } = require('./models/Location');

let aedes;
const clientMap = {};
Expand Down Expand Up @@ -49,16 +50,24 @@ module.exports.authorizePublish = async (client, packet, callback) => {
console.log(`${client.id} (${username}) published to ${packet.topic}: ${packet.payload}`);
if (packet.topic.startsWith(`owntracks/${username}/`)) {
callback(null);
await publishToFriends(client, packet);
await publishToPinpoint(client, packet);
} else {
callback(new Error('Invalid topic'));
}
};

async function publishToFriends(client, packet) {
async function publishToPinpoint(client, packet) {
const username = clientMap[client.id];
const user = await User.getByUsername(username);
if (!user) return;

const deviceRegex = new RegExp(`^owntracks/${username}/`);
const deviceName = packet.topic.replace(deviceRegex, '');
const device = await Device.findOne({ userId: user._id, name: deviceName });
if (!device) return;

await Location.create(JSON.parse(packet.payload.toString()), user, device._id);

const { friends } = user;
const groups = await user.getGroups();
await async.eachSeries(groups, async (group) => {
Expand All @@ -70,14 +79,11 @@ async function publishToFriends(client, packet) {
});
if (!friends.includes(username)) friends.push(username);
await async.eachSeries(friends, async (friend) => {
console.log(`Forwarding packet to ${friend}`);
const newPacket = { ...packet };
newPacket.topic = newPacket.topic.replace(/^owntracks/g, friend);
console.log(`Forwarding packet to ${newPacket.topic}`);
aedes.publish(newPacket);
// publish card if unseen
const deviceRegex = new RegExp(`^owntracks/${username}/`);
const deviceName = packet.topic.replace(deviceRegex, '');
const device = await Device.findOne({ userId: user._id, name: deviceName });
if (device && device.card) {
const friendData = await User.getByUsername(friend);
const seen = await CardSeen.findOne({ deviceId: device._id, seerId: friendData._id });
Expand Down
25 changes: 22 additions & 3 deletions src/routes/pub.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const async = require('async');
const express = require('express');
const userMw = require('../middleware/user');
const mqtt = require('../mqtt');
const { Device } = require('../models/Device');
const { CardSeen } = require('../models/CardSeen');
const { Location } = require('../models/Location');
Expand All @@ -11,14 +12,31 @@ router.post('/', userMw.one, async (req, res) => {
if (typeof req.body === 'object' && req.body?._type === 'location') {
const deviceName = deviceNameFromTopic(req.user.username, req.body.topic);
const userDevice = (await Device.getByUserId(req.User._id)).find((device) => device.name === deviceName);
if (!userDevice) {
console.log(`${req.user.username} posted a location update for an invalid device: ${deviceName}`);
return;
}
console.log(`${req.user.username} posted a location update for device: ${deviceName}`);
await Location.create(req.body, req.User, userDevice._id);
const returnData = [];
const returnUsernames = [];
const sharers = await req.User.getUsersSharingWith();
if (!sharers.includes(req.User._id)) sharers.push(req.User._id);

// Publish to friends MQTT topics
if (process.env.MQTT_HOST) {
const friends = await req.User.getFriendsAndGroupies();
friends.forEach((friend) => {
console.log(`Publishing location to ${friend}/${req.User.username}/${deviceName}: ${JSON.stringify(req.body)}`);
try {
mqtt.publish({ cmd: 'publish', topic: `${friend}/${req.User.username}/${deviceName}`, payload: JSON.stringify(req.body) }, (err) => { if (err) console.error(err); });
} catch (e) {
console.log(e);
}
});
}

// Loop through all people that share with the user who just posted their location
const sharers = await req.User.getUsersSharingWith();
if (!sharers.includes(req.User._id)) sharers.push(req.User._id);
await async.eachSeries(sharers, async (sharer) => {
const lastLocs = await Location.getLastByUserId(sharer); // Get last location for all devices for the user
console.log(`Sharer ${sharer} has ${lastLocs.length} location(s) to share`)
Expand All @@ -42,8 +60,9 @@ router.post('/', userMw.one, async (req, res) => {
console.log(`Returning ${returnUsernames.length} user location(s): ${returnUsernames.join(', ')}`);
console.log(`Response size ${JSON.stringify(returnData).length} bytes`);
return res.send(returnData);
} else {
res.send('Got it');
}
res.send('Got it');
});

function deviceNameFromTopic(username, topic) {
Expand Down
4 changes: 4 additions & 0 deletions src/routes/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ router.get('/dismiss-help', userMw.one, async (req, res) => {
res.send('');
});

router.get('/show-help', userMw.one, async (req, res) => {
res.render('user-help.html');
});

// !!!! devices !!!!

router.get('/add-device', async (req, res) => {
Expand Down
7 changes: 7 additions & 0 deletions src/views/navbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
</li>
{% endif %}
</ul>
{% if userData and userData.helpDismissed %}
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<button class="btn btn-outline-secondary" hx-get="/user/show-help" hx-target="#help">Help</button>
</li>
</ul>
{% endif %}
</div>
</div>
{% endblock %}
9 changes: 9 additions & 0 deletions src/views/user-devices.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
{% for device in userDevices %}
<tr>
<th scope="row">{{ device.name }}</th>
{% if device.httpConfigLink %}
<th scope="row">
<a class="btn btn-info" href="{{ device.httpConfigLink }}" role="button">OwnTracks HTTP Config</a>
{% if settings.mqttEnabled and device.mqttConfigLink %}
<a class="btn btn-info" href="{{ device.mqttConfigLink }}" role="button">OwnTracks MQTT Config</a>
{% endif %}
</th>
{% else %}
<th scope="row"><a class="btn btn-info" href="{{ device.configLink | replace(urlRegExp, 'owntracks:///c') }}" role="button">OwnTracks Link</a></th>
{% endif %}
<td scope="row">
<button class="btn btn-secondary" role="button" data-bs-toggle="modal" data-bs-target="#add-edit-device-modal"
hx-get="/user/edit-device/{{ device._id }}" hx-target="#add-edit-device-dialog">Edit</button>
Expand Down
4 changes: 2 additions & 2 deletions src/views/user-help.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ <h5 class="card-title">Welcome to Pinpoint!</h5>
<li class="list-group-item">
<div class="container">
<div class="row justify-content-start align-items-center">
<div class="col">📲 Select the "OwnTracks Link" on a device with OwnTracks installed and you will be prompted to import the configuration.</div>
<div class="col">📲 Select one of the "OwnTracks Config" links on a device with OwnTracks installed and you will be prompted to import the configuration. MQTT will give you instant updates when a friend updates their location, whereas HTTP polls periodically for updates and uses slightly less battery.</div>
</div>
</div>
</li>
Expand All @@ -53,6 +53,6 @@ <h5 class="card-title">Welcome to Pinpoint!</h5>
</div>
</li>
</ul>
<button class="btn btn-secondary" hx-get="/user/dismiss-help" hx-target="#user-help" hx-swap="outerHTML">Dismiss</button>
<button class="btn btn-secondary" hx-get="/user/dismiss-help" hx-target="#help">Dismiss</button>
</div>
</div>
4 changes: 3 additions & 1 deletion src/views/user.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{% extends "navbar.html" %}

{% block body %}
{% if not userData.helpDismissed %}{% include "user-help.html" %}{% endif %}
<div id="help">
{% if not userData.helpDismissed %}{% include "user-help.html" %}{% endif %}
</div>
<h2>Devices</h2>

<table id="devices" class="table table-striped table-hover align-middle">
Expand Down

0 comments on commit c50ea6f

Please sign in to comment.