DEV Community

Neil Gall
Neil Gall

Posted on

Pirrigator, part 6: In for a penny...

In my last post I outlined how all the data gets gathered, stored and served over a JSON API. I turned to look at making a front end for viewing the data - the idea in my head the whole time was "draw pretty graphs!" - and had to venture into web development, an area which I'm really a novice at. I did do six years of mobile apps professionally, so I'm familiar with some kinds of front-end development, I've written a few toy things with React, I've dabbled with Elm, I can make really simple changes to the Angular + Typescript UI at my present day-job, but other than that I've shied away from web development for years.

The big question therefore was what technology could I build a front-end in. I thought again about my original goal of learning Rust and the well-known fact that Rust compiles to WebAssembly and can run in the browser, but I had no idea if any of this technology was ready for use. I googled "rust web front-end" and came across this Reddit discussion on the subject. That in turn linked to Markus Kohlhase's excellent Rust web framework comparison document.

Unsurprisingly there's a lot of activity in this area. Knowing I wanted to draw graphs I looked through the front-end frameworks for one with an SVG example, and the only one was Draco. I spent an evening going through the examples and got as far as serving the SVG clock example from the Iron webserver.

I made a possibly interesting choice here not to add a static file mount to the webserver, but to build the binary blobs of the three files I needed right into the server binary. I have two reasons for this. First, deployment is simple as there's always a single file to copy to the device. And second, while I'm not sharing the address widely as it's running on a really low-end device with a poor wifi connection 20 metres from my house, the port is open to the internet via my router. Limiting the data the server can access to only what Rust code can generate seems a good way to maximise the benefits of Rust's safety and security claims. A handy crate for this was lazy-static-include which builds binary blobs into release builds but loads them from the source files at runtime in debug builds. I do build the app on my development laptop with hardware access disabled so I can test out the database and server side of things, so this gives me the best of both worlds.

With the success of serving the SVG clock from the Iron webserver, I set about replacing this UI with my own, and quickly ran into trouble. On loading the WASM blob the web console showed a link error for some __wbg_error handler. I googled this, read more of the wasm-bindgen guide and even browsed the source but came up short. It seems Draco just hasn't reached a maturity level to address things like easy debugging yet.

Back at Markus's comparison document I looked at the popularity of the individual web frameworks, realising it was a bit of schoolboy error to ignore this in the first place. The most starred framework is Dodrio but the example code looks too low-level for my liking. I definitely want something that gives the kind of abstraction that Elm and React offer.

The next most popular framework is Seed and I have to say I'm really impressed. The documentation is great, introducing concepts in the right order (although not all of it is completely up to date), and the API is pretty nice to use. It was a really straightforward translation from the small amount of Draco-based code I'd written to use Seed instead. Quickly I had a simple UI to query blocks of historical sensor data from the server and show it in tables. Then I set about writing an SVG chart component to draw 2D line graphs. A few hours later I had rudimentary charts!

example chart showing weather history

I really like the way the UI can be decomposed with Seed. Roughly following the Elm architecture, at the root a UI is a model, a view function which turns a model into a virtual DOM tree, and an update function which updates the model from a message. To decompose into smaller units I really want those units to work in terms of their own model and messages, and Seed provides the tools to make this easy.

I separated the main UI into Weather and Sensors sub-modules. The main model becomes

struct Model {
    weather: weather::Model,
    sensors: sensors::Model
}
Enter fullscreen mode Exit fullscreen mode

The top-level event type just wraps the lower layer events that are possible:

enum Message {
    Weather(weather::Message),
    Sensors(sensors::Message),
}
Enter fullscreen mode Exit fullscreen mode

The view for this just renders the sub-modules views, and crucially, maps the sub-module's event types to the higher layer's using the Message instance constructors.

fn view(model: &Model) -> El<Message> {
    div![
        h1!["Pirrigator"],
        model.weather.render().map_message(Message::Weather),
        model.sensors.render().map_message(Message::Sensors)
    ]
}
Enter fullscreen mode Exit fullscreen mode

The update works in reverse, unwrapping the top-layer messages, passing the underlying message through the sub-modules, then wrapping the result back up in the top layer structure. Fundamentally it's an fmap:

fn update(msg: Message, model: &mut Model) -> Update<Message> {
    match msg {
        Message::Weather(msg) => model.weather.update(msg).map(Message::Weather),
        Message::Sensors(msg) => model.sensors.update(msg).map(Message::Sensors)
    }
}
Enter fullscreen mode Exit fullscreen mode

This is great, as it lets you decompose into self-contained units, and can even instantiate multiple instances of these units into the larger UI. Perfect for tables, grids and the like.

Because the data comes from the server, the weather model is itself an enum representing the states this data can be in.

pub enum Model {
    NotLoaded,
    Loading,
    Loaded(Vec<WeatherRow>),
    Failed(String)
}
Enter fullscreen mode Exit fullscreen mode

The render function shows a heading, some buttons to select the time range of data to load, and the rest depends on the data state:

pub fn render(&self) -> El<Message> {
    div![
        h2!["Weather"],
        button![simple_ev(Ev::Click, Message::Fetch(HOUR)), "Last Hour"],
        button![simple_ev(Ev::Click, Message::Fetch(DAY)), "Last Day"],
        button![simple_ev(Ev::Click, Message::Fetch(WEEK)), "Last Week"],
        button![simple_ev(Ev::Click, Message::Fetch(MONTH)), "Last Month"],
        match self {
            Model::NotLoaded =>
                p!["Select a time range"],
            Model::Loading =>
                p!["Loading..."],
            Model::Failed(e) =>
                p![e],
            Model::Loaded(data) =>
                div![
                    h3!["Temperature"],
                    chart(data, true, &|r| r.temperature).render().map_message(|_| Message::Fetch(HOUR)),
                    h3!["Humidity"],
                    chart(data, true, &|r| r.humidity).render().map_message(|_| Message::Fetch(HOUR)),
                    h3!["Barometric Pressure"],
                    chart(data, false, &|r| r.pressure).render().map_message(|_| Message::Fetch(HOUR))
                ]
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

The best bit about this code is it's hard to see where you could write bugs. You can only render data when in the state where there is data to render. You can only show an error in the error state. How many UIs get this wrong by failing to make impossible states unrepresentable?.

The chart function uses the provided lambda to pull one axis out of the weather data and build a Chart sub-model, which is then immediately rendered. As with the top-layer the Chart module's message is then mapped into a Weather module message. Right now my chart module doesn't actually produce any messages but when it does it's already plumbed all the way up to the root.

fn chart(data: &Vec<WeatherRow>, y_origin_zero: bool, f: &Fn(&WeatherRow) -> f64) -> chart::Chart {
    chart::Chart {
        width: 600,
        height: 200,
        y_origin_zero,
        data: data.iter().map(|r| chart::DataPoint { time: r.timestamp, value: f(r) }).collect()
    }
}
Enter fullscreen mode Exit fullscreen mode

I'm not going to go through the chart code. It was fun to write but is fairly self-explanatory as it just builds a bunch of SVG elements. There was a little arithmetic involved to scale the data to SVG coordinates (and also to deal with the fact that my X axis consists of SystemTime objects, but the chrono crate came in very useful here).

The weather sub-module's update function is interesting as it updates the current state. To do this it's necessary to dereference the &mut self reference:

pub fn update(&mut self, msg: Message) -> Update<Message> {
    match msg {
        Message::Fetch(t) => {
            *self = Model::Loading;
            Update::with_future_msg(self.fetch(t)).skip()
        }
        Message::Fetched(rows) => {
            *self = if rows.is_empty() { Model::NotLoaded } else { Model::Loaded(rows) };
            Render.into()
        }
        Message::Failed(e) => {
            *self = Model::Failed(e.as_string().unwrap_or("Unknown error".to_string()));
            Render.into()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The sensors module works very similarly but has the added twist that it needs to fetch the list of sensor names with one API call, and then the data for each one separately. Following the same pattern I encoded all these states into the model.

After my false start with Draco this has been a joy to work on. UIs are inherently stateful and being able to think about and explicitly encode the complete set of states your code can be in is such a massive improvement from where we came from. Apart from a couple of bugs with scaling the data to the graph, on the whole this has been a "if it compiles it works" scenario.

There are only two major components left before this is up and running. I have three sensors but only two water valves as one of the three I bought doesn't work. So I'll need some additional configuration to define irrigation zones, how they map to sensors and valves, and some parameters for how the water should be added (e.g. after 6pm every day until the moisture reads a certain percentage). Then a final back-end module to execute this logic and store irrigation events in the database, and a UI addition to show when water has been added in each zone.

The final component to install will be the tomato plants. It's mid-May, I better get a crack on!

GitHub

Top comments (3)

Collapse
 
michaeltharrington profile image
Michael Tharrington

Hey Neil, this series is seriously amazing! I just started my own garden, though it's not nearly as ambitious as yours... no tech involved. 😝

Anyway, got a quick tip for ya! I noticed that you have several other posts in this series.

You could edit all of these posts to include "series: whatever name you'd like for your series" in the front matter of each one. This'll connect your posts with a cool little dot scroll option at the top of each post that lets the reader easily flip between posts in the series.

I've no idea what this option is actually called, so we're going with "dot scroll" ... but anyway, it looks like this:

my series

... in my posts here & here. Okay wow... totally feeling the guilt for abandoning this series, haha. πŸ˜”

Anyway, it's not a must-do by any means, just a nice-to-have in case you wanna! Again, really digging this series!!

Collapse
 
neilgall profile image
Neil Gall

Oh thanks Michael, that's a great idea. Will try to link them all up.

Been collecting data from my moisture sensors and scratching my head over how to use it to decide when to add water. Also read up on the traditional advice for growing tomatoes - ha ha! Details in the next post I hope.

Collapse
 
michaeltharrington profile image
Michael Tharrington

Awesome! I'll stay tuned. I need some 'mater advice.

For what it's worth, my neighbor came by yesterday and told me to put coffee grounds and broken eggshells atop the soil of my tomatoes (they're in a raised bed along with a bunch of other veggies). I've not actually researched this yet, but I did go outta my way to boil some eggs and made a pot of coffee this morning. And now my tomatoes have some garbage for company, haha! πŸ˜€