Jamie Dowden-Duarte
Published © MIT

Tamed (Modern Java in the Wild)

Tamed: a Particle P2 reads your environment, Java 26 decides what to do about it — real-time alerts before conditions turn on you.

AdvancedWork in progressOver 7 days40
Tamed (Modern Java in the Wild)

Things used in this project

Hardware components

Photon 2
Particle Photon 2
×1
USB Cable, USB Type C Plug
USB Cable, USB Type C Plug
×1
Temp&Humi&Barometer Sensor (BME280)
Seeed Studio Temp&Humi&Barometer Sensor (BME280)
×1
Grove - I2C UV Sensor (VEML6070)
Seeed Studio Grove - I2C UV Sensor (VEML6070)
×1
0.96" OLED 64x128 Display Module
ElectroPeak 0.96" OLED 64x128 Display Module
×1
ProtoCentral Pulse Oximeter & Heart Rate Sensor based on MAX30100
ProtoCentral Electronics ProtoCentral Pulse Oximeter & Heart Rate Sensor based on MAX30100
×1

Software apps and online services

Visual Studio Code Extension for Arduino
Microsoft Visual Studio Code Extension for Arduino
Particle Build Web IDE
Particle Build Web IDE
solidworks
KiCad
KiCad
Java 26
Java 26

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Flux, Soldering
Solder Flux, Soldering
3D Printer (generic)
3D Printer (generic)
Laser cutter (generic)
Laser cutter (generic)

Story

Read more

Custom parts and enclosures

design breakdown

enclosure

Schematics

Schematic

Fritzing Circuit

Credits

Code

Pom

XML
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.jamie</groupId>
    <artifactId>tamed</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>26</maven.compiler.source>
        <maven.compiler.target>26</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <javafx.version>26</javafx.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>com.fazecast</groupId>
            <artifactId>jSerialComm</artifactId>
            <version>2.11.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.8</version>
                <configuration>
                    <mainClass>com.jamie.tamed.App</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

DashboardController

Java
package com.jamie.tamed.ui;

import com.jamie.tamed.model.ConditionScorer;
import com.jamie.tamed.model.ConditionScorer.Assessment;
import com.jamie.tamed.model.RiskLevel;
import com.jamie.tamed.model.SensorReading;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;

public class DashboardController {

    @FXML private Label connectionLabel;
    @FXML private VBox statusBanner;
    @FXML private Label statusTitle;
    @FXML private Label statusMessage;
    @FXML private Label tempLabel;
    @FXML private Label humidityLabel;
    @FXML private Label uvLabel;
    @FXML private HBox trendBars;

    private final ConditionScorer scorer = new ConditionScorer();

    @FXML
    public void initialize() {
        // Placeholder trend bars until real history is wired in
        for (int i = 0; i < 6; i++) {
            trendBars.getChildren().add(makeBar(20));
        }
    }

    public void setConnected(String portName) {
        Platform.runLater(() -> connectionLabel.setText("connected — " + portName));
    }

    // Called from the serial-reading virtual thread — always hop to the FX thread
    public void onReading(SensorReading reading) {
        Assessment assessment = scorer.score(reading);

        Platform.runLater(() -> {
            tempLabel.setText(String.format("%.1f°c", reading.tempC()));
            humidityLabel.setText(String.format("%.1f%%", reading.humidityPct()));
            uvLabel.setText(String.format("%.1f", reading.uvIndex()));

            applyRiskStyle(assessment.level());
            statusTitle.setText(assessment.level().label());
            statusMessage.setText(assessment.message());

            pushTrendBar(reading.uvIndex());
        });
    }

    private void applyRiskStyle(RiskLevel level) {
        String bg = switch (level) {
            case SAFE -> "#EAF3DE";
            case CAUTION -> "#FAECE7";
            case ALERT -> "#FCEBEB";
        };
        statusBanner.setStyle("-fx-background-color: " + bg + "; -fx-background-radius: 12; -fx-padding: 16;");
    }

    private void pushTrendBar(double uvIndex) {
        trendBars.getChildren().remove(0);
        double heightPx = Math.min(80, Math.max(8, uvIndex * 10));
        trendBars.getChildren().add(makeBar(heightPx));
    }

    private Region makeBar(double heightPx) {
        Region bar = new Region();
        bar.setPrefHeight(heightPx);
        bar.setStyle("-fx-background-color: #D85A30; -fx-background-radius: 2;");
        HBox.setHgrow(bar, javafx.scene.layout.Priority.ALWAYS);
        return bar;
    }
}

Tamedsensorpod

C/C++
photon 2 firmware: reads BME280 + VEML6075 over I2C, streams to java over serial (currently working on)
// coming soon

particle photon2 firmware

C/C++
// tamed.ino
// Particle P2 firmware for the Tamed sensor pod.
// Reads temp/humidity (BME280) and UV index (VEML6075) over I2C,
// then streams one line per reading over USB serial in the format:
//   TEMP:24.3,HUM:41.2,UV:3.1
// The Java 26 app on the other end owns all decision logic — this
// firmware's only job is to read sensors and get the numbers out.

#include "Particle.h"
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <Adafruit_VEML6075.h>

// Optional on-device readout — comment out if you're not wiring up the OLED
#define USE_OLED
#ifdef USE_OLED
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire);
#endif

SYSTEM_MODE(SEMI_AUTOMATIC);
SYSTEM_THREAD(ENABLED);

Adafruit_BME280 bme;
Adafruit_VEML6075 uv = Adafruit_VEML6075();

const unsigned long READ_INTERVAL_MS = 2000;
unsigned long lastReadMillis = 0;

void setup() {
    Serial.begin(9600);
    Wire.begin();

    if (!bme.begin(0x76)) {
        // Some breakouts default to 0x77 instead — try that if this fails
        Serial.println("ERR:BME280_NOT_FOUND");
    }

    if (!uv.begin()) {
        Serial.println("ERR:VEML6075_NOT_FOUND");
    }

#ifdef USE_OLED
    if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
        Serial.println("ERR:OLED_NOT_FOUND");
    } else {
        display.clearDisplay();
        display.setTextSize(1);
        display.setTextColor(SSD1306_WHITE);
        display.setCursor(0, 0);
        display.println("tamed booting...");
        display.display();
    }
#endif
}

void loop() {
    unsigned long now = millis();
    if (now - lastReadMillis < READ_INTERVAL_MS) {
        return;
    }
    lastReadMillis = now;

    float tempC = bme.readTemperature();
    float humidityPct = bme.readHumidity();
    float uvIndex = uv.readUVI();

    // Serial payload — this exact format is the contract the Java side parses
    Serial.printlnf("TEMP:%.1f,HUM:%.1f,UV:%.1f", tempC, humidityPct, uvIndex);

#ifdef USE_OLED
    display.clearDisplay();
    display.setCursor(0, 0);
    display.printlnf("temp: %.1f C", tempC);
    display.printlnf("hum:  %.1f %%", humidityPct);
    display.printlnf("uv:   %.1f", uvIndex);
    display.display();
#endif
}

Sensorreading

Java
package com.jamie.tamed.model;

import java.time.Instant;

// One line off the wire, e.g. "TEMP:24.3,HUM:41.2,UV:3.1"
public record SensorReading(double tempC, double humidityPct, double uvIndex, Instant timestamp) {

    public static SensorReading parse(String line) {
        double temp = 0, hum = 0, uv = 0;
        for (String part : line.trim().split(",")) {
            String[] kv = part.split(":");
            if (kv.length != 2) continue;
            double value = Double.parseDouble(kv[1]);
            switch (kv[0]) {
                case "TEMP" -> temp = value;
                case "HUM" -> hum = value;
                case "UV" -> uv = value;
                default -> { /* ignore unknown fields */ }
            }
        }
        return new SensorReading(temp, hum, uv, Instant.now());
    }
}

ConditionScorer

Java
package com.jamie.tamed.model;

import java.util.ArrayDeque;
import java.util.Deque;

// Keeps a rolling window of readings and turns the latest one into a RiskLevel + message.
// Thresholds below are placeholders — tune them once real sensor data is flowing.
public class ConditionScorer {

    private static final int WINDOW_SIZE = 10;
    private final Deque<SensorReading> recent = new ArrayDeque<>();

    public record Assessment(RiskLevel level, String message) {}

    public Assessment score(SensorReading reading) {
        recent.addLast(reading);
        if (recent.size() > WINDOW_SIZE) recent.removeFirst();

        RiskLevel level = switch (reading) {
            case SensorReading r when r.uvIndex() >= 6 -> RiskLevel.ALERT;
            case SensorReading r when r.uvIndex() >= 3 -> RiskLevel.CAUTION;
            case SensorReading r when r.tempC() >= 32 -> RiskLevel.ALERT;
            default -> RiskLevel.SAFE;
        };

        String message = switch (level) {
            case ALERT -> "UV or heat is high — seek shade and hydrate now";
            case CAUTION -> "UV rising — reapply sunscreen in about 15 min";
            case SAFE -> level.defaultMessage();
        };

        return new Assessment(level, message);
    }
}

Risklevel

Java
package com.jamie.tamed.model;

public enum RiskLevel {
    SAFE("safe", "Conditions look good"),
    CAUTION("caution", "Worth keeping an eye on"),
    ALERT("alert", "Take action now");

    private final String label;
    private final String defaultMessage;

    RiskLevel(String label, String defaultMessage) {
        this.label = label;
        this.defaultMessage = defaultMessage;
    }

    public String label() {
        return label;
    }

    public String defaultMessage() {
        return defaultMessage;
    }
}

Dashboard

XML
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.VBox?>

<BorderPane xmlns="http://javafx.com/javafx"
            xmlns:fx="http://javafx.com/fxml"
            fx:controller="com.jamie.tamed.ui.DashboardController"
            style="-fx-background-color: white; -fx-padding: 20;">

    <top>
        <HBox alignment="CENTER_LEFT" spacing="8">
            <Label text="tamed" style="-fx-font-size: 18px; -fx-font-weight: bold;"/>
            <Region HBox.hgrow="ALWAYS"/>
            <Label fx:id="connectionLabel" text="connecting..." style="-fx-text-fill: gray;"/>
        </HBox>
    </top>

    <center>
        <VBox spacing="16">
            <padding>
                <Insets top="16"/>
            </padding>

            <!-- Status banner -->
            <VBox fx:id="statusBanner" spacing="4"
                  style="-fx-background-color: #FAECE7; -fx-background-radius: 12; -fx-padding: 16;">
                <Label fx:id="statusTitle" text="safe" style="-fx-font-size: 16px; -fx-font-weight: bold;"/>
                <Label fx:id="statusMessage" text="Waiting for readings..." style="-fx-font-size: 14px;"/>
            </VBox>

            <!-- Sensor readout cards -->
            <HBox spacing="12">
                <VBox styleClass="metric-card" style="-fx-background-color: #F1EFE8; -fx-background-radius: 8; -fx-padding: 16;" HBox.hgrow="ALWAYS">
                    <Label text="temp" style="-fx-text-fill: gray; -fx-font-size: 13px;"/>
                    <Label fx:id="tempLabel" text="--°c" style="-fx-font-size: 24px; -fx-font-weight: bold;"/>
                </VBox>
                <VBox style="-fx-background-color: #F1EFE8; -fx-background-radius: 8; -fx-padding: 16;" HBox.hgrow="ALWAYS">
                    <Label text="humidity" style="-fx-text-fill: gray; -fx-font-size: 13px;"/>
                    <Label fx:id="humidityLabel" text="--%" style="-fx-font-size: 24px; -fx-font-weight: bold;"/>
                </VBox>
                <VBox style="-fx-background-color: #F1EFE8; -fx-background-radius: 8; -fx-padding: 16;" HBox.hgrow="ALWAYS">
                    <Label text="uv index" style="-fx-text-fill: gray; -fx-font-size: 13px;"/>
                    <Label fx:id="uvLabel" text="--" style="-fx-font-size: 24px; -fx-font-weight: bold;"/>
                </VBox>
            </HBox>

            <!-- Trend area — swap for a real LineChart once data is flowing -->
            <VBox style="-fx-background-color: white; -fx-border-color: #ddd; -fx-border-radius: 12; -fx-background-radius: 12; -fx-padding: 16;">
                <Label text="last 5 minutes" style="-fx-text-fill: gray; -fx-font-size: 13px;"/>
                <HBox fx:id="trendBars" spacing="6" style="-fx-padding: 12 0 0 0;"/>
            </VBox>
        </VBox>
    </center>

</BorderPane>

Serialreader

Java
package com.jamie.tamed.serial;

import com.fazecast.jSerialComm.SerialPort;
import com.jamie.tamed.model.SensorReading;
import com.jamie.tamed.ui.DashboardController;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

public class SerialReader {

    private static final int BAUD_RATE = 9600;

    private final DashboardController controller;
    private volatile boolean running = true;

    public SerialReader(DashboardController controller) {
        this.controller = controller;
    }

    // Prints available ports so you can find the right one — run this once to check.
    public static void listPorts() {
        for (SerialPort port : SerialPort.getCommPorts()) {
            System.out.println(port.getSystemPortName() + " — " + port.getDescriptivePortName());
        }
    }

    // Starts listening on a virtual thread; call once from App after the FXML loads.
    public void start(String portName) {
        Thread.ofVirtual().name("serial-reader").start(() -> readLoop(portName));
    }

    public void stop() {
        running = false;
    }

    private void readLoop(String portName) {
        SerialPort port = SerialPort.getCommPort(portName);
        port.setBaudRate(BAUD_RATE);
        port.setComPortTimeouts(SerialPort.TIMEOUT_READ_BLOCKING, 0, 0);

        if (!port.openPort()) {
            System.err.println("Could not open port: " + portName);
            return;
        }

        controller.setConnected(portName);

        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(port.getInputStream(), StandardCharsets.UTF_8))) {

            String line;
            while (running && (line = reader.readLine()) != null) {
                if (line.isBlank()) continue;
                try {
                    SensorReading reading = SensorReading.parse(line);
                    controller.onReading(reading);
                } catch (NumberFormatException e) {
                    // Malformed line off the wire — skip it rather than crashing the reader
                    System.err.println("Skipped bad line: " + line);
                }
            }
        } catch (IOException e) {
            System.err.println("Serial read error: " + e.getMessage());
        } finally {
            port.closePort();
        }
    }
}

App

Java
package com.jamie.tamed;

import com.jamie.tamed.serial.SerialReader;
import com.jamie.tamed.ui.DashboardController;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class App extends Application {

    // Change this to match your P2's port. Run SerialReader.listPorts() from main()
    // below to find it — on macOS it looks like /dev/tty.usbmodemXXXX.
    private static final String PORT_NAME = "/dev/tty.usbmodem1101";

    @Override
    public void start(Stage stage) throws Exception {
        FXMLLoader loader = new FXMLLoader(getClass().getResource("/dashboard.fxml"));
        Parent root = loader.load();
        DashboardController controller = loader.getController();

        Scene scene = new Scene(root, 720, 480);
        stage.setTitle("tamed");
        stage.setScene(scene);
        stage.show();

        SerialReader serialReader = new SerialReader(controller);
        serialReader.start(PORT_NAME);
    }

    public static void main(String[] args) {
        // Uncomment to print available ports instead of launching the UI:
        // SerialReader.listPorts();
        launch(args);
    }
}

Credits

Jamie Dowden-Duarte
13 projects • 81 followers
IOT educator | IPC SOLDER CERT | C++ | python | embedded software ENG | 3D modeling | MEP LEAN cert | SolidWorks | rapid proto | Veteran

Comments