Skip to content

Commit ce63002

Browse files
committed
Add history recorder
1 parent b86e1b1 commit ce63002

9 files changed

+827
-11
lines changed

README.md

+88-8
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Ideally use a Raspberry Pi 3 or Zero W, as these have Bluetooth LE on them alrea
1212

1313
* Download Raspbian Lite from https://www.raspberrypi.org/downloads/raspbian/
1414
* Copy it to an SD card with `sudo dd if=2017-11-29-raspbian-stretch-lite.img of=/dev/sdc status=progress bs=1M` on Linux (or see the instructions on the Raspbian download page above for your platform)
15-
* Unplug and re-plug the SD card and add a file called `ssh` to the `boot` drive - this will enable SSH access to the Pi
15+
* Unplug and re-plug the SD card and add a file called `ssh` to the `boot` drive - this will enable SSH access to the Pi
1616
* If you're using WiFi rather than Ethernet, see [this post on setting up WiFi via the SD card](https://raspberrypi.stackexchange.com/questions/10251/prepare-sd-card-for-wifi-on-headless-pi)
1717
* Now put the SD card in the Pi, apply power, and wait a minute
1818
* `ssh pi@raspberrypi.local` (or use PuTTY on Windows) and use the password `raspberry`
@@ -40,9 +40,8 @@ git clone https://github.com/espruino/EspruinoHub
4040
# Install EspruinoHub's required Node libraries
4141
cd EspruinoHub
4242
npm install
43-
# Optional: Install the Espruino Web IDE to allow the IDE to be used from the server
44-
git clone https://github.com/espruino/EspruinoWebIDE
45-
(cd EspruinoWebIDE && git clone https://github.com/espruino/EspruinoTools)
43+
# Optional - enable gathering of historical data by creating a 'log' directory
44+
mkdir log
4645
# Give Node.js access to Bluetooth
4746
sudo setcap cap_net_raw+eip $(eval readlink -f `which node`)
4847
```
@@ -60,6 +59,8 @@ git clone https://github.com/espruino/EspruinoHub
6059
# Install EspruinoHub's required Node libraries
6160
cd EspruinoHub
6261
npm install
62+
# Optional - enable gathering of historical data by creating a 'log' directory
63+
mkdir log
6364
# Give Node.js access to Bluetooth
6465
sudo setcap cap_net_raw+eip $(eval readlink -f `which node`)
6566
```
@@ -115,6 +116,7 @@ fi
115116
* On non-Raspberry Pi devices, Mosquitto (the MQTT server) may default to not allowing anonymous (un-authenticated) connections to MQTT. To fix this edit `/etc/mosquitto/conf.d/local.conf` and set `allow_anonymous` to `true`.
116117
* By default the HTTP server in EspruinoHub is enabled, however it can be disabled by setting `http_port` to `0` in `config.json`
117118
* The HTTP Proxy service is disabled by default and needs some configuration - see **HTTP Proxy** below
119+
* You used to need a local copy of the Espruino Web IDE, however now EspruinoHub just serves up an IFRAME which points to the online IDE, ensuring it is always up to date.
118120

119121

120122
Usage
@@ -132,9 +134,9 @@ With that server, you can:
132134

133135
* See the Intro page
134136
* See the status and log messages at http://localhost:1888/status
135-
* Access the Espruino Web IDE at http://localhost:1888/ide if you install the
136-
`espruino-web-ide` NPM package (see the 'Setting up' instructions above). You
137+
* Access the Espruino Web IDE at http://localhost:1888/ide. You
137138
can then connect to any Bluetooth LE device within range of EspruinoHub.
139+
* View real-time Signal Strength data via WebSockets at http://localhost:1888/rssi.html
138140
* View real-time MQTT data via WebSockets at http://localhost:1888/mqtt.html
139141
* View any of your own pages that are written into the `www` folder. For instance
140142
you could use [TinyDash](https://github.com/espruino/TinyDash) with the code
@@ -170,11 +172,11 @@ You can also connect to a device:
170172

171173
* `/ble/write/DEVICE/SERVICE/CHARACTERISTIC` connects and writes to the charactertistic
172174
* `/ble/read/DEVICE/SERVICE/CHARACTERISTIC` connects and reads from the charactertistic
173-
* `/ble/notify/DEVICE/SERVICE/CHARACTERISTIC` connects and starts notifications on the characteristic, which
175+
* `/ble/notify/DEVICE/SERVICE/CHARACTERISTIC` connects and starts notifications on the characteristic, which
174176
send data back on `/ble/data/DEVICE/SERVICE/CHARACTERISTIC`
175177
* `/ble/ping/DEVICE` connects, or maintains a connection to the device, and sends `/ble/pong/DEVICE` on success
176178

177-
`SERVICE` and `CHARACTERISTIC` are either known names from [attributes.js](https://github.com/espruino/EspruinoHub/blob/master/lib/attributes.js)
179+
`SERVICE` and `CHARACTERISTIC` are either known names from [attributes.js](https://github.com/espruino/EspruinoHub/blob/master/lib/attributes.js)
178180
such as `nus` and `nus_tx` or are of the form `6e400001b5a3f393e0a9e50e24dcca9e` for 128 bit uuids or `abcd` for 16 bit UUIDs.
179181

180182
After connecting, EspruinoHub will stay connected for a few seconds unless there is
@@ -188,6 +190,8 @@ on a Puck.js BLE UART connection with:
188190
/ble/data/c7:f9:36:dd:b0:ca/nus/nus_rx => "23\r\n"
189191
```
190192

193+
**You can also gather historical data over MQTT - see the `History` section
194+
below.**
191195

192196
### MQTT Command-line
193197

@@ -203,6 +207,82 @@ mosquitto_pub -h localhost -t test/topic -m "Hello world"
203207

204208
You can use the commands in the section above to make things happen from the command-line.
205209

210+
History
211+
-------
212+
213+
EspruinoHub contains code (`libs/history.js`) that subscribes to any MQTT data
214+
beginning with `/ble/` and that then stores logs of the average value
215+
every minute, 10 minutes, hour and day (see `config.js:history_times`). The
216+
averages are broadcast over MQTT as the occur, but can also be queried by sending
217+
messages to `/hist/request`.
218+
219+
For example, an Espruino device with address `f5:47:c8:0b:49:04` may broadcast
220+
advertising data with UUID `1809` (Temperature) with the following code:
221+
222+
```
223+
setInterval(function() {
224+
NRF.setAdvertising({
225+
0x1809 : [Math.round(E.getTemperature())]
226+
});
227+
}, 30000);
228+
```
229+
230+
This is decoded into `temp` by `attributes.js`, and it sends the following MQTT
231+
packets:
232+
233+
```
234+
/ble/advertise/f5:47:c8:0b:49:04 {"rssi":-53,"name":"...","serviceUuids":["6e400001b5a3f393e0a9e50e24dcca9e"]}
235+
/ble/advertise/f5:47:c8:0b:49:04/rssi -53
236+
/ble/advertise/f5:47:c8:0b:49:04/1809 [22]
237+
/ble/advertise/f5:47:c8:0b:49:04/temp 22
238+
/ble/temp/f5:47:c8:0b:49:04 22
239+
```
240+
241+
You can now subscribe with MQTT to `/hist/hour/ble/temp/f5:47:c8:0b:49:04` and
242+
every hour you will receive a packet containing the average temperature over
243+
that time.
244+
245+
However, you can also request historical data by sending the JSON:
246+
247+
```
248+
{
249+
"topic" : "/hist/hour/ble/temp/f5:47:c8:0b:49:04",
250+
"interval" : "minute",
251+
"age" : 6
252+
}
253+
```
254+
255+
to `/hist/request/a_unique_id`. EspruinoHub will then send a packet to
256+
`/hist/response/a_unique_id` containing:
257+
258+
```
259+
{
260+
"interval":"minute",
261+
"from":1531227216903, // unix timestamp (msecs since 1970)
262+
"to":1531234416903, // unix timestamp (msecs since 1970)
263+
"topic":"/hist/hour/ble/temp/f5:47:c8:0b:49:04",
264+
"times":[ array of unix timestamps ],
265+
"data":[ array of average data values ]
266+
}
267+
```
268+
269+
Requests can be of the form:
270+
271+
```
272+
{
273+
topic : "/ble/advertise/...",
274+
"interval" : "minute" / "tenminutes" / "hour" / "day"
275+
// Then time period is either:
276+
"age" : 1, // hours
277+
// or:
278+
"from" : "1 July 2018",
279+
"to" : "5 July 2018" (or anything that works in new Date(...))
280+
}
281+
```
282+
283+
For a full example of usage see `www/rssi.html`.
284+
285+
206286
HTTP Proxy
207287
----------
208288

index.js

+1
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ require("./lib/config.js").init(); // Load configuration
1818
require("./lib/service.js").init(); // Enable HTTP Proxy Service
1919
require("./lib/discovery.js").init(); // Enable Advertising packet discovery
2020
require("./lib/http.js").init(); // Enable HTTP server for status
21+
require("./lib/history.js").init(); // Enable History/Logging

lib/config.js

+12
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ broken and exiting. Higher values are useful with slowly advertising sensors.
3333
Setting a value of 0 disables the exit/restart. */
3434
exports.ble_timeout = 10;
3535

36+
/* base path for history requests and output */
37+
exports.history_path = "/hist/";
38+
39+
/* time periods used for history */
40+
exports.history_times = {
41+
minute : 60*1000,
42+
tenminutes : 10*60*1000,
43+
hour : 60*60*1000,
44+
day : 24*60*60*1000
45+
};
46+
47+
3648
function log(x) {
3749
console.log("<Config> "+x);
3850
}

lib/history.js

+198
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
var config = require('./config');
2+
var fs = require("fs");
3+
4+
var pathToLog;
5+
var historyTopics = [];
6+
7+
function log(x) {
8+
console.log("<History> "+x);
9+
}
10+
11+
// =============================================================================
12+
function getLogFileForDate(timespec, date) {
13+
if (!pathToLog) return;
14+
return pathToLog+"/"+timespec+"-"+date.getFullYear()+"-"+date.getMonth()+"-"+date.getDate();
15+
}
16+
17+
function logWrite(timespec, topic, data) {
18+
if (!pathToLog) return;
19+
log(" [LOG]"+timespec+" "+topic+" "+data);
20+
var file = getLogFileForDate(timespec, new Date());
21+
fs.appendFileSync(file, Date.now()+" "+topic+" "+data+"\n");
22+
};
23+
24+
function logReadTopic(interval, from, to, topic, callback) {
25+
var time = from.getTime();
26+
var toTime = to.getTime();
27+
if (from.getFullYear()<2018 ||
28+
toTime > Date.now()+1000*60*60*24) return; // invalid date range
29+
var files = [];
30+
while (time <= toTime) {
31+
var file = getLogFileForDate(interval, new Date(time));
32+
if (fs.existsSync(file)) files.push(file);
33+
time += 1000*60*60*24; // one day
34+
}
35+
function readFiles(result, callback) {
36+
if (!files.length) return callback(result);
37+
var file = files.shift(); // take first file off
38+
const rl = require("readline").createInterface({
39+
input: fs.createReadStream(file),
40+
crlfDelay: Infinity
41+
});
42+
rl.on('line', (line) => {
43+
var topicIdx = line.indexOf(" ");
44+
var dataIdx = line.indexOf(" ", topicIdx+1);
45+
var lTopic = line.substring(topicIdx+1, dataIdx);
46+
if (lTopic == topic) {
47+
result.times.push(parseInt(line.substr(0,topicIdx)));
48+
result.data.push(line.substr(dataIdx+1));
49+
}
50+
});
51+
rl.on('close', (line) => {
52+
readFiles(result, callback);
53+
});
54+
}
55+
readFiles({
56+
interval : interval,
57+
from : from.getTime(),
58+
to : to.getTime(),
59+
topic : topic,
60+
times : [],
61+
data : [],
62+
}, function(result) {
63+
callback(result);
64+
});
65+
};
66+
67+
// =============================================================================
68+
function onMQTTMessage(topic, message) {
69+
var msg = message.toString();
70+
if (topic.indexOf(" ")>=0) return; // ignore topics with spaces
71+
if (topic.startsWith(config.history_path)) {
72+
handleCommand(topic, msg);
73+
} else {
74+
handleData(topic, msg);
75+
}
76+
}
77+
78+
function handleData(topic, message) {
79+
var data = parseFloat(message);
80+
if (!isNaN(data)) {
81+
//log("MQTT>"+topic+" => "+data);
82+
if (topic in historyTopics) {
83+
historyTopics[topic].pushNumber(data);
84+
} else {
85+
historyTopics[topic] = new HistoryTopic(topic);
86+
historyTopics[topic].pushNumber(data);
87+
}
88+
}
89+
}
90+
91+
function handleCommand(topic, message) {
92+
var cmdRequest = config.history_path+"request/";
93+
//log("MQTT Command>"+topic+" => "+JSON.stringify(message));
94+
if (topic.startsWith(cmdRequest)) {
95+
/*
96+
interval : "minute",
97+
//use age : 1, // hour
98+
//or from : "1 July 2018", to : "5 July 2018" (or anything that works in new Date(...))
99+
topic : "/ble/advertise/..."
100+
*/
101+
var json;
102+
try {
103+
json = JSON.parse(message);
104+
} catch (e) {
105+
log("MQTT "+cmdRequest+" malformed JSON "+JSON.stringify(message));
106+
return;
107+
}
108+
var tag = topic.substr(cmdRequest.length);
109+
log("REQUEST "+tag+" "+JSON.stringify(json));
110+
// TODO: Validate request
111+
if (!json.topic) {
112+
log("MQTT "+cmdRequest+" no topic");
113+
return;
114+
}
115+
if (!(json.interval in config.history_times)) {
116+
log("MQTT "+cmdRequest+" invalid interval");
117+
return;
118+
}
119+
var dFrom,dTo;
120+
if (json.from)
121+
dFrom = new Date(json.from);
122+
if (json.age)
123+
dFrom = new Date(Date.now() - parseFloat(json.age*1000*60*60));
124+
if (json.to)
125+
dTo = new Date(json.to);
126+
else
127+
dTo = new Date();
128+
if (!dFrom || isNaN(dFrom.getTime()) ||
129+
!dTo || isNaN(dTo.getTime())) {
130+
log("MQTT "+cmdRequest+" invalid from/to or age");
131+
return;
132+
}
133+
logReadTopic(json.interval, dFrom, dTo, json.topic, function(data) {
134+
log("RESPONSE "+tag+" ("+data.data.length+" items)");
135+
require('./mqttclient.js').send(config.history_path+"response/"+tag, JSON.stringify(data));
136+
});
137+
}
138+
}
139+
140+
// =============================================================================
141+
function HistoryTopic(topic) {
142+
log("New History Topic for "+topic);
143+
this.topic = topic;
144+
this.times = {};
145+
for (var i in config.history_times)
146+
this.times[i] = { num : 0, sum : 0, time : 0 };
147+
}
148+
149+
HistoryTopic.prototype.pushNumber = function(n) {
150+
//log.write("all",this.topic,n);
151+
for (var i in config.history_times) {
152+
this.times[i].num++;
153+
this.times[i].sum+=n;
154+
}
155+
};
156+
157+
HistoryTopic.prototype.tick = function(time) {
158+
for (var period in config.history_times) {
159+
var t = this.times[period];
160+
t.time += time;
161+
if (t.time > config.history_times[period]) {
162+
if (t.num) {
163+
var avr = t.sum / t.num;
164+
logWrite(period, this.topic,avr);
165+
require('./mqttclient.js').send(config.history_path+period+this.topic, ""+avr);
166+
}
167+
this.times[period] = { num : 0, sum : 0, time : 0 };
168+
}
169+
}
170+
};
171+
172+
// =============================================================================
173+
exports.init = function() {
174+
var mqtt = require("./mqttclient.js").client;
175+
// Link in to messages
176+
mqtt.on('connect', function () {
177+
// Subscribe to any BLE data
178+
mqtt.subscribe("/ble/#");
179+
// Subscribe to history requests
180+
mqtt.subscribe(config.history_path+"#");
181+
});
182+
mqtt.on('message', onMQTTMessage);
183+
// Check all history topics and write to log if needed
184+
// TODO: could be just do this when we receive our data?
185+
setInterval(function() {
186+
Object.keys(historyTopics).forEach(function(el) {
187+
historyTopics[el].tick(1000);
188+
});
189+
}, 1000);
190+
// Handle log dir serving
191+
pathToLog = require('path').resolve(__dirname, "../log");
192+
if (require("fs").existsSync(pathToLog)) {
193+
log("log directory found at "+pathToLog+". Enabling logging.");
194+
} else {
195+
log("log directory not found. Not logging.");
196+
pathToLog = undefined;
197+
}
198+
}

lib/mqttclient.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ client.on('connect', function () {
4848
client.subscribe("/ble/ping/#");
4949
});
5050

51+
exports.client = client;
5152

5253
exports.send = function(topic, message) {
5354
if (connected) client.publish(topic, message);
@@ -64,9 +65,9 @@ function convertMessage(data) {
6465
}
6566

6667
client.on('message', function (topic, message) {
67-
log(topic+" => "+JSON.stringify(message.toString()));
68-
var path = topic.substr(1).split("/");
69-
if (path[0]=="ble") {
68+
if (topic.substr(0,5)=="/ble/") {
69+
//log(topic+" => "+JSON.stringify(message.toString()));
70+
var path = topic.substr(1).split("/");
7071
if (path[1]=="write") {
7172
var id = config.deviceToAddr(path[2]);
7273
if (discovery.inRange[id]) {

0 commit comments

Comments
 (0)