Aquarium Temperature Monitoring Prototype with Raspberry Pi


Overview

Knowing how water temperature changes over time is essential for keeping an aquarium healthy. Sudden swings can stress or harm fish, yet an analog thermometer gives you no historical data at all. Ideally, off-the-shelf products would log water temperature and visualize it as a graph — but IoT adoption in aquarium equipment has been slow.

Fortunately, you can build one yourself using a Raspberry Pi and a digital temperature sensor. So I threw together a quick prototype to see how it goes.

System Diagram

Here’s how the system is structured.

%%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#ffffff",
    "primaryBorderColor": "#444",
    "lineColor": "#666",
    "clusterBkg": "#fafafa",
    "clusterBorder": "#bbb",
    "nodePadding": 20
  }
}}%%

flowchart LR

subgraph SENSORS[Sensors]
  DS18B20("fa:fa-thermometer-half DS18B20 #1 ... #n")
end

subgraph RP["fab:fa-raspberry-pi Raspberry Pi"]
  direction TB

  Sampler("fab:fa-node-js Sampler")
  Agent("<img src='/icons/influxdb-cubo-pink.svg' style='width:2em;height:2em;'/> Telegraf")
  DB("<img src='/icons/influxdb-cubo-navy.svg' style='width:2em;height:2em;'/> InfluxDB")
  V("<img src='/icons/grafana.svg' style='width:2em;height:2em;'/> Grafana")
end

subgraph LAN[LAN]
  direction TB

  RP
  IPHONE("fab:fa-apple iPhone")
  ANDROID("fab:fa-android Android")
  PC("fa:fa-desktop Desktop")
end

SENSORS --1-Wire--> Sampler
Sampler --> Agent
Agent --> DB
DB --> V

V --http--> IPHONE
V --http--> ANDROID
V --http--> PC

Arrows indicate data flow direction.

  • DS18B20 – A digital temperature sensor connected via the 1-Wire interface.
  • Sampler – A Node.js script that reads temperature values from the sensors.
  • Telegraf – An agent that runs the sampler at regular intervals and sends the readings to InfluxDB.
  • InfluxDB – A time-series database used to store the sensor data.
  • Grafana – A web-based tool used to visualize the data in graphs.

I went with a single Raspberry Pi 4 for this. That said, InfluxDB and Grafana can also run on a separate Pi — in that case a lower-spec model should work fine, so go ahead and try.

Setup sensors

Waterproof DS18B20 sensors with pre-attached cables are commonly available, so I used one of those. Connect a 4.7 kΩ pull-up resistor between the DS18B20 data line and the 3.3 V supply on the Raspberry Pi, then connect the data line to GPIO4 (pin 7). The nice thing about 1-Wire is that all sensors share the same three wires (power, ground, and data) — so you can connect multiple DS18B20 sensors in parallel on the same GPIO4 bus. For this prototype I just wired it up on a breadboard.

Setup Raspberry Pi

I’m running a Raspberry Pi 4 (4GB model) here.

OS version:

uname -a
Linux rpi4b4g 6.12.62+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.12.62-1+rpt1 (2025-12-18) aarch64 GNU/Linux
cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
NAME="Debian GNU/Linux"
VERSION_ID="13"
VERSION="13 (trixie)"
VERSION_CODENAME=trixie
DEBIAN_VERSION_FULL=13.3
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

Enable 1-Wire

Enable 1-Wire and reboot.

sudo raspi-config nonint do_onewire 0
sudo reboot

Once that’s done, 1-Wire devices should appear under /sys/bus/w1/devices/. Make sure each sensor shows up as a 28-* directory. Here I’ve got two sensors connected.

ls /sys/bus/w1/devices/
28-00000b1c9a3e  28-00000e8f1f2f  w1_bus_master1

You can also read a sensor value manually. The value t=22312 means 22.312 °C.

cat /sys/bus/w1/devices/28-00000b1c9a3e/w1_slave
65 01 4b 46 7f ff 0b 10 2c : crc=2c YES
65 01 4b 46 7f ff 0b 10 2c t=22312

Sampler

Node.js

You’ll need Node.js for this. If it’s not installed yet, check the official docs.

Node.js version used here:

node --version
v24.14.0

sampler.js

This script reads temperature values from DS18B20 sensors. It scans /sys/bus/w1/devices/ for all connected DS18B20 sensors (28-* directories), reads the raw w1_slave file for each, verifies the CRC, and parses the temperature value. If a sensor fails validation — say, a CRC error, a missing value, or a known bogus reading like 85 °C — the script just skips it. No point letting one flaky sensor ruin the whole batch.

Reads can occasionally fail if the device file isn’t ready yet — the script just ignores those too.

/usr/local/bin/rpi-aquarium/sampler.js

const fs = require('fs/promises');
const path = require('path');

const W1_PATH = '/sys/bus/w1/devices';

// return a list of connected DS18B20 sensors.
const listSensors = async () => {
  const dirs = await fs.readdir(W1_PATH, { withFileTypes: true });
  return dirs
    .filter(d => (d.isDirectory() || d.isSymbolicLink()) && d.name.startsWith('28-'))
    .map(d => d.name);
}

// return the temperature in degrees Celsius.
const readSensor = async (sensor) => {
  const raw = await fs.readFile(path.join(W1_PATH, sensor, 'w1_slave'), 'utf8');
  const lines = raw.split('\n');

  if (lines.length < 2) {
    throw new Error(`invalid w1_slave format`);
  }

  if (!/YES$/.test(lines[0])) {
    throw new Error(`CRC error`);
  }

  const t = (() => {
    const m = lines[1].match(/t=(-?\d+)$/);
    if (!m) {
      throw new Error(`no temperature value`);
    }
    return Number(m[1]);
  })();

  if (!Number.isFinite(t)) throw new Error('NaN');
  if (t === 85000 || t === 127000) throw new Error('invalid value');

  return t / 1000.0;
}

// Output JSON format to stdout.
// Do not handle errors.
(async () => {
  const time = new Date().toISOString();

  const sensors = await listSensors();
  const results = await Promise.allSettled(
    sensors.map(sensor =>
      readSensor(sensor).then(temperature => ({ time, id: sensor, temperature }))
    )
  );

  const output = results
    .filter(result => result.status === 'fulfilled')
    .map(result => result.value);

  console.log(JSON.stringify(output));
})();

Example output:

node /usr/local/bin/rpi-aquarium/sampler.js
[{"time":"2026-03-11T08:17:40.386Z","id":"28-00000b1c9a3e","temperature":23},{"time":"2026-03-11T08:17:40.386Z","id":"28-00000e8f1f2f","temperature":23.125}]

Each object contains three fields: time (an ISO 8601 timestamp), id (the sensor serial number, used as a Telegraf tag to distinguish sensors), and temperature (the measured value in °C).

Setup InfluxDB

Install InfluxDB — check the official docs for that.

InfluxDB is a time-series database — built specifically for storing data that comes in at regular intervals, like sensor readings. It’s a natural fit here.

During initial setup you’ll define an organization and a bucket. Think of the bucket as the database where your data lands. I set retention to 720h (30 days) — enough history to spot seasonal trends without hogging disk space. You’ll also need to generate an API token — go to API Tokens in the UI and create one with write access to your bucket. You’ll use it in both the Telegraf and Grafana configs later.

Setup example:

  • organization: my_aquarium
  • bucket: my_tank
    • retention: 720h0m0s

Setup Telegraf

Install Telegraf — check the official docs.

Telegraf is a metrics collection agent. It runs on a schedule, executes the sampler, and ships the output straight to InfluxDB — so I don’t have to write any of that plumbing myself.

You’ll also need an outputs.influxdb_v2 section — grab the token from the InfluxDB UI under API Tokens.

[[outputs.influxdb_v2]]
  urls = ["http://127.0.0.1:8086"]
  token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  organization = "my_aquarium"
  bucket = "my_tank"

Then configure Telegraf to run the Sampler and write the results to InfluxDB. Add the following to /etc/telegraf/telegraf.conf:

[[inputs.exec]]
  commands = ["node /usr/local/bin/rpi-aquarium/sampler.js"]
  timeout = "10s"
  data_format = "json"
  json_time_key = "time"
  json_time_format = "2006-01-02T15:04:05Z07:00"
  tag_keys = ["id"]

A few things worth knowing about this config:

  • commands — the script to run. Telegraf spawns it as a subprocess each interval.
  • timeout — if the script hangs longer than this, Telegraf kills it and moves on.
  • data_format = "json" — tells Telegraf to parse the output as JSON.
  • json_time_key — which field to use as the timestamp.
  • tag_keys = ["id"] — promotes id to an InfluxDB tag, so you can filter by sensor in Grafana.

The sampling interval isn’t set here — it’s controlled by the interval value in the [agent] section of /etc/telegraf/telegraf.conf. The default is 10s.

Then just restart the service.

sudo systemctl restart telegraf.service

Check its status:

systemctl status telegraf.service
● telegraf.service - Telegraf
     Loaded: loaded (/usr/lib/systemd/system/telegraf.service; enabled; preset: enabled)
     Active: active (running) since Wed 2026-03-11 19:08:08 JST; 20h ago
 Invocation: 5e8fda3bd3d0444486c9db038c9b6ac8
       Docs: https://github.com/influxdata/telegraf
   Main PID: 4566 (telegraf)
      Tasks: 14 (limit: 3918)
        CPU: 31min 44.828s
     CGroup: /system.slice/telegraf.service
             ├─4566 /usr/bin/telegraf -config /etc/telegraf/telegraf.conf -config-directory /etc/telegraf/telegraf.d
             └─4579 /usr/bin/dbus-daemon --syslog --fork --print-pid 4 --print-address 6 --session

Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z I! Starting Telegraf 1.38.0 brought to you by InfluxData the makers of>
Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z I! Available plugins: 244 inputs, 9 aggregators, 35 processors, 26 par>
Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z I! Loaded inputs: cpu disk diskio exec kernel mem processes swap system
Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z I! Loaded aggregators:
Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z I! Loaded processors:
Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z I! Loaded secretstores:
Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z I! Loaded outputs: influxdb_v2
Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z I! Tags enabled: host=rpi4b4g
Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z I! [agent] Config: Interval:10s, Quiet:false, Hostname:"rpi4b4g", Flus>
Mar 11 19:08:08 rpi4b4g telegraf[4566]: 2026-03-11T10:08:08Z W! [agent] The default value of 'skip_processors_after_aggregators' wi>
lines 1-22/22 (END)

Once Telegraf is running, you can verify the data is flowing in via InfluxDB Data Explorer.

InfluxDB Data Explorer

Setup Grafana

Install Grafana — check the official docs.

Grafana is a dashboarding tool that connects to InfluxDB and turns the raw data into charts. First, add InfluxDB as a data source: go to Connections → Data sources → InfluxDB, set the query language to Flux, and fill in the URL, organization, token, and bucket.

Dashboard example:

Grafana Data sources

Time series panel example

Time series panels plot values over time as a line chart — good for spotting trends, anomalies, or how temperature drifted across the day.

Grafana Time series

Flux query:

from(bucket: "my_tank")
  |> range(start: -12h)
  |> filter(fn: (r) => r._measurement == "exec" and r._field == "temperature" )
  |> aggregateWindow(every: 1m, fn: mean)

This pulls all temperature readings from the exec measurement over the last 12 hours. The exec measurement name comes from Telegraf’s [[inputs.exec]] plugin — that’s just what it uses by default.

aggregateWindow(every: 1m, fn: mean) downsamples the raw 10-second data into 1-minute averages — so the chart stays readable without plotting thousands of individual points.

Standard options: Set the unit to Celsius (°C).

Graph styles:

  • Gradient mode: Opacity

Override: Set Display name to ${__field.labels.id} — Grafana replaces it with the id label from InfluxDB, so each line gets the sensor’s ID as its legend label automatically.

Gauge panel example

Gauge panels show the current value on a dial — useful for a quick snapshot of “where is each sensor right now” without scrolling through history.

Grafana Gauge

Flux query:

from(bucket: "my_tank")
  |> range(start: -5m)
  |> filter(fn: (r) => r._measurement == "exec" and r._field == "temperature")
  |> last()

This one only grabs the most recent reading from the last 5 minutes — last() filters it down to a single value per sensor. Good for a “current temperature” display.

Standard options: Set the unit to Celsius (°C) and the min/max range so the gauge doesn’t look weird with default auto-scaling.

grafana gauge options

Thresholds: I set green/yellow/red thresholds based on the comfortable range for my fish — adjust to suit yours.

grafana gauge options

Override: Use ${__field.labels.id} as the Display name override value — same as the time series panel — so each gauge shows the sensor’s ID automatically.

Bar gauge panel example

Bar gauge lines up each sensor’s current value as a vertical bar — bar height maps to value, and color shifts with thresholds. So you can compare all sensors side by side and spot anything out of range at a glance.

Grafana Bar gauge

Flux query: Same as the gauge example.

Bar gauge:

  • Orientation: Vertical
  • Display mode: Basic
  • Show unfilled area: off

Standard options: Same as the gauge example.

Thresholds: Same as the gauge example.

Override: Same as the time series or gauge example.

Stat panel example

Stat panels display the latest value as a large number with a colored background — the simplest way to see all sensors at a glance and immediately know if anything is out of range.

Grafana State

Flux query: Same as the time series example.

Stat styles:

  • Orientation: Horizontal
  • Text mode: Value and name
  • Color mode: Background Gradient

Standard options: Same as the gauge example.

Thresholds: Same as the gauge example.

Override: Same as the time series or gauge example.