Skip to content

[WIP] A Weekend Full of Tweaks #1

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

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
12 changes: 12 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@
name = "ruuvari"
version = "0.1.0"

[[bin]]
name = "ruuvari-rx"
path = "src/main.rs"

[lib]
name = "ruuvari"
path = "lib/lib.rs"

[dependencies]
chrono = { version = "0.4.2", features = [ "serde" ] }
env_logger = "0.5.7"
log = "0.4.1"
rouille = "2.1.0"
serde = "1.0.33"
serde_derive = "1.0.33"
serde_json = "1.0.13"
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
# ruuvari

Simple example on how to parse [Ruuvi Station](https://play.google.com/store/apps/details?id=com.ruuvi.station) gateway output.
ruuvari is receiver for various software supporting RuuviTag.

# Supported Sources

## Ruuvi Station

Ruuvi Station is Android software to receive data from sensors. Configured
Gateway URL can be used with ruuvari.

* Play Store: [Ruuvi Station](https://play.google.com/store/apps/details?id=com.ruuvi.station).
* GitHub: [Ruuvi Station](https://github.com/ruuvi/com.ruuvi.station).

## Beacon Scanner

Beacon Scanner is Android software to receive data from sensors. Beacon Scanner
supports multiple differend kinds of beacons, but right now only RuuviTag is
supported. Done [us](http://tarlab.fi/) other beacons and we might add support :)

* Play Store: [Beacon Scanner](https://play.google.com/store/apps/details?id=com.bridou_n.beaconscanner)
* GitHub: [Beacon Scanner](https://github.com/Bridouille/android-beacon-scanner)
102 changes: 102 additions & 0 deletions lib/beaconscanner.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! Events sent by Beacon Scanner Android software
//!
//! https://github.com/Bridouille/android-beacon-scanner

use chrono::{Utc, Local, TimeZone};
use serde_json::{self, Value};

use event::{self, Event, ToRuuvariEvent};

#[derive(Debug, Deserialize)]
pub struct Beacons {
beacons: Vec<Beacon>,
reader: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Beacon {
beacon_address: String,
beacon_type: String,
distance: f64,
eddystone_url_data: Value,
hashcode: usize,
is_blocked: bool,
last_minute_seen: usize,
/// Milliseconds since 1970-01-01 00:00:00 local time
last_seen: usize,
manufacturer: usize,
rssi: isize,
ruuvi_data: RuuviData,
tx_power: isize,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuuviData {
/// in hPA
air_pressure: f32,
/// in relative humidity
humidity: f32,
/// in °C
temperature: f32,
}

impl ToRuuvariEvent for Beacons {
fn from_json(input: &str) -> event::Result<Vec<Event>> {
let value: Self = serde_json::from_str(input)?;
value.to_events()
}

fn to_events(&self) -> event::Result<Vec<Event>> {
let events: Vec<Event> = self.beacons.iter().map(to_event).collect();

if events.is_empty() {
return Err(event::Error::EmptyEvent);
}

Ok(events)
}
}

fn to_event(beacon: &Beacon) -> Event {
let seconds = (beacon.last_seen / 1000) as i64;
let localtime = Local.timestamp(seconds, 0);

Event {
beacon_address: beacon.beacon_address.clone(),
air_pressure: beacon.ruuvi_data.air_pressure,
humidity: beacon.ruuvi_data.humidity,
temperature: beacon.ruuvi_data.temperature,
rssi: beacon.rssi,
timestamp: localtime.with_timezone(&Utc),
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json;

#[test]
fn test_json() {
let raw = r##"{"beacons":[{"beaconAddress":"D7:58:D2:87:08:F8","beaconType":"ruuvitag","distance":2.5337382706296463,"eddystoneUrlData":{"url":"https://ruu.vi/#BCwVAMCUr"},"hashcode":1141403717,"isBlocked":false,"lastMinuteSeen":25396428,"lastSeen":1523785721504,"manufacturer":65194,"rssi":-60,"ruuviData":{"airPressure":993,"humidity":22,"temperature":21},"txPower":-48}],"reader":"Scanner 1"}"##;
let event: Beacons = serde_json::from_str(raw).expect("serde_json::from_str");
assert_eq!(event.reader, "Scanner 1");
}

#[test]
fn test_json_multiple_beacons() {
let raw = r##"{"beacons":[{"beaconAddress":"D7:58:D2:87:08:F8","beaconType":"ruuvitag","distance":1.939022861124338,"eddystoneUrlData":{"url":"https://ruu.vi/#BDQXAMn0r"},"hashcode":1141403717,"isBlocked":false,"lastMinuteSeen":25396939,"lastSeen":1523816361122,"manufacturer":65194,"rssi":-57,"ruuviData":{"airPressure":1017,"humidity":26,"temperature":23},"txPower":-48},{"beaconAddress":"D1:D8:2A:09:D6:C1","beaconType":"ruuvitag","distance":15.18942027557396,"eddystoneUrlData":{"url":"https://ruu.vi/#BIgWAMn0T"},"hashcode":984684823,"isBlocked":false,"lastMinuteSeen":25396939,"lastSeen":1523816361120,"manufacturer":65194,"rssi":-81,"ruuviData":{"airPressure":1017,"humidity":68,"temperature":22},"txPower":-48}],"reader":"Scanner 1"}"##;
let event: Beacons = serde_json::from_str(raw).expect("serde_json::from_str");
assert_eq!(event.beacons.len(), 2);
}

#[test]
fn test_json_to_event1() {
let raw = r##"{"beacons":[{"beaconAddress":"D7:58:D2:87:08:F8","beaconType":"ruuvitag","distance":2.5337382706296463,"eddystoneUrlData":{"url":"https://ruu.vi/#BCwVAMCUr"},"hashcode":1141403717,"isBlocked":false,"lastMinuteSeen":25396428,"lastSeen":1523785721504,"manufacturer":65194,"rssi":-60,"ruuviData":{"airPressure":993,"humidity":22,"temperature":21},"txPower":-48}],"reader":"Scanner 1"}"##;
let event: Vec<Event> = Beacons::from_json(&raw).expect("from_json");
assert_eq!(event.len(), 1);

}
}
67 changes: 67 additions & 0 deletions lib/event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//! # A "one-event-to-rule-them-all" kind of way for RuuviTags
//!
//! Event contains information from single broadcast of single beacon. Different
//! software send different data and this `Event` tries it's best to contain the
//! common information.
//!
//! For climate monitoring the most important data is already present: air
//! pressure, humidity and temperature. Beacon address can be used to identify
//! the beacon in question.

use std::result;

use chrono::{self, DateTime, Utc};
use serde_json;

#[derive(Debug, Serialize, Deserialize)]
pub struct Event {
pub beacon_address: String,
/// in hPA
pub air_pressure: f32,
/// in relative humidity
pub humidity: f32,
/// in °C
pub temperature: f32,
pub rssi: isize,
/// Time of event in UTC
pub timestamp: DateTime<Utc>,
}

#[derive(Debug)]
pub enum Error {
/// Missing information to produce Event
EmptyEvent,
/// Parser error
ParseError,
/// JSON error
JSONError(serde_json::Error),
/// Error from Chrono
ChronoError(chrono::ParseError),
}

pub type Result<T> = result::Result<T, Error>;

/// A trait for converting received information into one or more Events
///
/// One HTTP POST JSON can contain information from one or more Beacons in same
/// message. This information is then dissected into one or more Events. One
/// Event per one broadcast from one beacon.
pub trait ToRuuvariEvent {
/// Convert a JSON into vector of events.
fn from_json(input: &str) -> Result<Vec<Event>>;

/// Convert Self into vector of events
fn to_events(&self) -> Result<Vec<Event>>;
}

impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Error::JSONError(err)
}
}

impl From<chrono::ParseError> for Error {
fn from(err: chrono::ParseError) -> Self {
Error::ChronoError(err)
}
}
15 changes: 15 additions & 0 deletions lib/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#[macro_use]
extern crate serde_derive;
extern crate serde_json;

extern crate chrono;

/// A common event for everything
pub mod event;
pub use event::{Event, ToRuuvariEvent};

/// Support for Ruuvi Station
pub mod ruuvistation;

/// Support for Beacon Scanner
pub mod beaconscanner;
145 changes: 145 additions & 0 deletions lib/ruuvistation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//! Events sent by Ruuvi Station Android software
//!
//! https://github.com/ruuvi/com.ruuvi.station

use chrono::{DateTime, Utc, NaiveDateTime, TimeZone, Local};
use serde_json;

use event::{self, Event, ToRuuvariEvent};

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tags {
device_id: String,
event_id: String,
tag: Option<Tag>,
tags: Option<Vec<Tag>>,
time: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tag {
accel_x: f32,
accel_y: f32,
accel_z: f32,
default_background: u32,
favorite: bool,
/// in relative humidity
humidity: f32,
id: String,
name: Option<String>,
/// in hPA
pressure: f32,
raw_data_blob: Blob,
rssi: isize,
/// in °C
temperature: f32,
update_at: String,
voltage: f32,
}

#[derive(Debug, Deserialize)]
pub struct Blob {
blob: Vec<i32>,
}

impl ToRuuvariEvent for Tags {
fn from_json(input: &str) -> event::Result<Vec<Event>> {
let value: Self = serde_json::from_str(input)?;
value.to_events()
}

fn to_events(&self) -> event::Result<Vec<Event>> {
if let Some(ref tag) = self.tag {
return Ok(vec![to_event(tag)?]);
}

if let Some(ref tags) = self.tags {
let mut res = Vec::with_capacity(tags.len());
for tag in tags {
res.push(to_event(tag)?);
}
return Ok(res)
}

Err(event::Error::EmptyEvent)
}
}

fn to_event(tag: &Tag) -> event::Result<Event> {
Ok(Event {
beacon_address: tag.id.clone(),
air_pressure: tag.pressure,
humidity: tag.humidity,
temperature: tag.temperature,
rssi: tag.rssi,
timestamp: time_parser(&tag.update_at)?,
})
}

fn time_parser(input: &str) -> event::Result<DateTime<Utc>> {
// We have identified two different time stamp formats:
// Apr 14, 2018 12:22:27 AM
// Apr 17, 2018 09:32:00
for fmt in &["%b %d, %Y %r", "%b %d, %Y %T"] {
match NaiveDateTime::parse_from_str(input, fmt) {
Ok(naive) => {
let local: DateTime<Local> = Local.timestamp(naive.timestamp(), 0);
return Ok(local.with_timezone(&Utc))
}
Err(err) => eprintln!("parse_from_str error: {}", err),
}
}
Err(event::Error::ParseError)
}


#[cfg(test)]
mod tests {
use super::*;
use serde_json;

#[test]
fn test_json_with_tag() {
let raw = r##"{"deviceId":"854af65f-13db-4082-b07e-89129690d275","eventId":"9e6329dd-06eb-474c-9d1d-9b4373704a6d","tag":{"accelX":0.0,"accelY":0.0,"accelZ":0.0,"defaultBackground":1,"favorite":true,"gatewayUrl":"http://192.168.1.4:1337/","humidity":22.0,"id":"D7:58:D2:87:08:F8","name":"Devitagi","pressure":996.0,"rawDataBlob":{"blob":[4,44,20,0,-63,-64]},"rssi":-57,"temperature":20.0,"updateAt":"Apr 14, 2018 12:22:27 AM","url":"https://ruu.vi/#BCwUAMHAr","voltage":0.0},"time":"Apr 14, 2018 12:22:27 AM"}"##;
let event: Tags = serde_json::from_str(raw).expect("serde_json::from_str");
assert_eq!(event.time, "Apr 14, 2018 12:22:27 AM");
}

#[test]
fn test_json_with_tags() {
let raw = r##"{"deviceId":"854af65f-13db-4082-b07e-89129690d275","eventId":"8bdb7814-21fd-4bbe-b6aa-2be5f552c14a","tags":[{"accelX":0.0,"accelY":0.0,"accelZ":0.0,"defaultBackground":1,"favorite":true,"humidity":22.0,"id":"D7:58:D2:87:08:F8","name":"Devitagi","pressure":996.0,"rawDataBlob":{"blob":[4,44,19,0,-63,-64]},"rssi":-63,"temperature":19.0,"updateAt":"Apr 14, 2018 12:12:38 AM","url":"https://ruu.vi/#BCwTAMHAr","voltage":0.0}],"time":"Apr 14, 2018 12:12:38 AM"}"##;
let event: Tags = serde_json::from_str(raw).expect("serde_json::from_str");
assert_eq!(event.time, "Apr 14, 2018 12:12:38 AM");
}

#[test]
fn test_json_with_multiple_tags() {
let raw = r##"{"deviceId":"854af65f-13db-4082-b07e-89129690d275","eventId":"520a341b-49e7-49e6-b908-05a27da7d6ac","tags":[{"accelX":0.936,"accelY":0.26,"accelZ":-0.204,"defaultBackground":6,"favorite":true,"gatewayUrl":"","humidity":68.0,"id":"D1:D8:2A:09:D6:C1","name":"Humidori","pressure":1017.0,"rawDataBlob":{"blob":[4,-120,22,0,-55,-12]},"rssi":-74,"temperature":22.0,"updateAt":"Apr 15, 2018 21:16:17","url":"https://ruu.vi/#BIgWAMn0T","voltage":3.193},{"accelX":0.0,"accelY":0.0,"accelZ":0.0,"defaultBackground":1,"favorite":true,"gatewayUrl":"http://192.168.1.4:1337/","humidity":38.0,"id":"D7:58:D2:87:08:F8","name":"Devitagi","pressure":1017.0,"rawDataBlob":{"blob":[4,76,24,0,-55,-12]},"rssi":-56,"temperature":24.0,"updateAt":"Apr 15, 2018 21:16:17","url":"https://ruu.vi/#BEwYAMn0r","voltage":0.0}],"time":"Apr 15, 2018 21:16:17"}"##;
let event: Tags = serde_json::from_str(raw).expect("serde_json::from_str");
assert_eq!(event.tags.map(|l| l.len()), Some(2));
}

#[test]
fn test_json_with_tag_raw_mode() {
let raw = r##"{"deviceId":"854af65f-13db-4082-b07e-89129690d275","eventId":"449b03cc-171a-46b2-b367-310f1af81d21","tags":[{"accelX":-0.004,"accelY":0.112,"accelZ":1.004,"defaultBackground":1,"favorite":true,"gatewayUrl":"","humidity":31.5,"id":"D7:58:D2:87:08:F8","name":"Devitagi","pressure":1013.14,"rawDataBlob":{"blob":[4,52,23,0,-56,100]},"rssi":-32,"temperature":24.01,"updateAt":"Apr 16, 2018 21:11:27","voltage":3.097},{"accelX":0.936,"accelY":0.26,"accelZ":-0.204,"defaultBackground":6,"favorite":true,"gatewayUrl":"","humidity":70.0,"id":"D1:D8:2A:09:D6:C1","name":"Humidori","pressure":1013.0,"rawDataBlob":{"blob":[4,-116,22,0,-56,100]},"rssi":-68,"temperature":22.0,"updateAt":"Apr 16, 2018 21:11:27","url":"https://ruu.vi/#BIwWAMhkT","voltage":3.193}],"time":"Apr 16, 2018 21:11:27"}"##;
let event: Tags = serde_json::from_str(raw).expect("serde_json::from_str");
assert_eq!(event.time, "Apr 16, 2018 21:11:27");
}

#[test]
fn test_json_to_event1() {
let raw = r##"{"deviceId":"854af65f-13db-4082-b07e-89129690d275","eventId":"520a341b-49e7-49e6-b908-05a27da7d6ac","tags":[{"accelX":0.936,"accelY":0.26,"accelZ":-0.204,"defaultBackground":6,"favorite":true,"gatewayUrl":"","humidity":68.0,"id":"D1:D8:2A:09:D6:C1","name":"Humidori","pressure":1017.0,"rawDataBlob":{"blob":[4,-120,22,0,-55,-12]},"rssi":-74,"temperature":22.0,"updateAt":"Apr 15, 2018 21:16:17","url":"https://ruu.vi/#BIgWAMn0T","voltage":3.193},{"accelX":0.0,"accelY":0.0,"accelZ":0.0,"defaultBackground":1,"favorite":true,"gatewayUrl":"http://192.168.1.4:1337/","humidity":38.0,"id":"D7:58:D2:87:08:F8","name":"Devitagi","pressure":1017.0,"rawDataBlob":{"blob":[4,76,24,0,-55,-12]},"rssi":-56,"temperature":24.0,"updateAt":"Apr 15, 2018 21:16:17","url":"https://ruu.vi/#BEwYAMn0r","voltage":0.0}],"time":"Apr 15, 2018 21:16:17"}"##;
let event: Vec<Event> = Tags::from_json(&raw).expect("from_json");
assert_eq!(event.len(), 2);

}

#[test]
fn test_json_to_event2() {
let raw = r##"{"deviceId":"854af65f-13db-4082-b07e-89129690d275","eventId":"9e6329dd-06eb-474c-9d1d-9b4373704a6d","tag":{"accelX":0.0,"accelY":0.0,"accelZ":0.0,"defaultBackground":1,"favorite":true,"gatewayUrl":"http://192.168.1.4:1337/","humidity":22.0,"id":"D7:58:D2:87:08:F8","name":"Devitagi","pressure":996.0,"rawDataBlob":{"blob":[4,44,20,0,-63,-64]},"rssi":-57,"temperature":20.0,"updateAt":"Apr 14, 2018 12:22:27 AM","url":"https://ruu.vi/#BCwUAMHAr","voltage":0.0},"time":"Apr 14, 2018 12:22:27 AM"}"##;
let event: Vec<Event> = Tags::from_json(&raw).expect("from_json");
assert_eq!(event.len(), 1);
}
}
Loading