Back to Projects

ITRI Smart Aquaculture

An IoT monitoring and automated feeding system for fish farms, featuring bidirectional MQTT communication, alert state machines, and LINE group notifications for real-time water quality management.

Role: Solo Developer (Freelance)
Period: 2023 - 2025

Overview

A paid freelance project for ITRI (Industrial Technology Research Institute), Taiwan's leading applied research organization. The system monitors fish farm conditions in real-time and automates feeding schedules.

The Domain: Aquaculture farms need continuous monitoring of water quality (dissolved oxygen, pH, temperature, ammonia) and automated feeding to reduce labor and prevent fish mortality from undetected environmental changes.

The System: A NestJS backend subscribes to 10 MQTT topics from IoT sensors, processes water quality and weather data, triggers alerts through LINE messaging when thresholds are breached, and controls feeders through bidirectional MQTT publish commands.

The Challenge: Fish farm operators are not engineers. The alert system needed to be smart enough to avoid notification fatigue while ensuring no critical alert is missed, and the entire system had to be operable through LINE -- the messaging app they already use daily.

My Role

As the sole developer on this freelance engagement, I owned the entire backend stack:

  • Designed and implemented the NestJS backend with 10 MQTT topic subscriptions
  • Built the alert/recovery state machine with configurable cooldown periods
  • Architected the 3-tier notification pipeline (NestJS to Go LINE Bot to LINE Platform)
  • Designed the database schema with explicit join entities for per-relationship alert configuration
  • Implemented JWT dual extraction for flexible authentication across web and mobile
  • Set up Docker Compose deployment with 4 containers
  • Built CSV export with per-user pond access control

Tech Stack

Integration

LINE Messaging API (notifications)CSV Export (reporting)

Backend

NestJS 8TypeScriptTypeORMMQTT (mosquitto)Go (LINE Bot)JWT Dual Extraction (header + cookie)

Infrastructure

Docker Compose (4 containers)Nginx (reverse proxy)

Database / Messaging

MariaDBTypeORM Migrations

Architecture

The system follows an event-driven architecture centered on MQTT as the message bus:

Sensor Layer: IoT devices publish to 10 MQTT topics covering water quality metrics, weather station data, feed consumption telemetry, and feeder control acknowledgments.

Processing Layer: NestJS subscribes to all sensor topics and processes incoming data through validation, threshold checking, and state machine transitions. Outbound feeder control commands are published back to MQTT with QoS 1 (at-least-once delivery).

Alert Layer: A per-(LineGroup x Pond) state machine manages alert lifecycle. Each combination maintains independent state, preventing one pond's alerts from affecting another's notification flow.

Notification Layer: A 3-tier architecture decouples alert generation from delivery. NestJS emits alert events, a Go-based LINE Bot service handles message formatting and LINE Platform API calls, and LINE delivers to the appropriate group.

Data Layer: MariaDB stores sensor readings, alert history, feed schedules, and user configurations. An explicit join entity (LineGroupPond) enables per-relationship alert flag configuration.

System architecture diagram

Key Challenges

1. Notification Fatigue

Fish farm sensors can trigger hundreds of threshold breaches per hour during environmental events (storms, temperature swings). Sending every alert as a LINE message would bury critical notifications in noise and cause operators to mute the group entirely.

2. Bidirectional MQTT Coordination

The system both subscribes to sensor data and publishes feeder control commands on the same MQTT broker. Feed schedules need to match time proximity, and feeder state needs normalization because different feeder hardware reports status in different formats.

3. Flexible Alert Configuration

Different ponds in the same farm have different species with different tolerance ranges. Different LINE groups (shift teams, management, maintenance) need different alert types. The alert configuration matrix is (Pond x LineGroup x AlertType), not a simple on/off flag.

4. Authentication Across Contexts

The system is accessed from both a web dashboard (JWT in Authorization header) and mobile LINE LIFF apps (JWT in cookie). Password expiry and account lockout policies are required for the institutional client.

Solutions & Design Decisions

Alert/Recovery State Machine

Each (LineGroup x Pond) pair maintains a 4-state machine:

  • Alert to Alert (within cooldown): log the event but suppress the notification
  • Alert to Recovery: send a recovery notification to the LINE group
  • Recovery to Alert: start a new alert cycle with full notification
  • Normal to Alert: initial alert trigger with notification

Cooldown duration is configurable per LINE group. This reduces notification volume by 80-90% during environmental events while ensuring every state transition is logged for audit. Trade-off: operators might not see the exact moment a metric re-crosses the threshold during cooldown, but they always see the initial alert and the recovery.

Explicit Join Entity for Alert Configuration

Instead of a simple many-to-many between LineGroup and Pond, the LineGroupPond entity carries per-relationship flags: isAlertEnabled and isFeedAlertEnabled. This allows the management LINE group to receive water quality alerts for all ponds but feed alerts only for high-value ponds, while the maintenance group receives feed alerts only.

Feed Schedule Time Proximity Matching

The pickScheduleEntry function finds the closest scheduled feeding slot to the current time rather than requiring an exact match. This handles clock drift between the server and feeder hardware, and accommodates delayed feeder acknowledgments gracefully.

JWT Dual Extraction

A custom NestJS guard checks the Authorization header first, then falls back to a cookie-based token. Both paths feed into the same validation pipeline with account lockout and password expiry checks. This lets the web dashboard and LINE LIFF app share the same authentication backend.

Results & Impact

System Reliability

  • 10 MQTT topics processed continuously across multiple fish farms
  • Bidirectional feeder control with QoS 1 delivery guarantee
  • Alert notification volume reduced 80-90% during environmental events with zero missed state transitions

Operational Impact

  • Farm operators manage the entire system through LINE, requiring no additional app or training
  • Per-relationship alert configuration eliminates irrelevant notifications for each team
  • CSV export with access control enables compliance reporting for ITRI

Deployment

  • 4-container Docker Compose deployment (NestJS, Nginx, MariaDB, LINE Bot)
  • Single-command deployment on farm-site servers
  • Maintained and operated over a 2-year engagement

Learnings

Domain Experts Know the Edge Cases

The state machine cooldown design came directly from conversations with farm operators. They knew that during a typhoon, dissolved oxygen sensors would fluctuate wildly for hours, and the previous system's constant alerts had trained them to ignore notifications entirely.

Explicit Join Entities Prevent Configuration Debt

The decision to use LineGroupPond with per-relationship flags instead of a simple many-to-many join table paid off immediately. The first configuration request from the client -- "management wants water quality alerts for all ponds but feed alerts only for the tilapia ponds" -- would have required a schema migration with the simpler approach.

Keep the Notification Layer Separate

Using a Go-based LINE Bot as an independent service means the NestJS backend never needs to know about LINE message formatting, rate limits, or API changes. When LINE updated their messaging API, only the Go service needed changes.

Deep Dive: Alert State Machine Design

The Core Problem

Fish farm sensors trigger threshold breaches constantly during storms. Without intelligent suppression, operators receive hundreds of LINE messages per hour and learn to ignore all of them -- including the ones that matter.

State Machine Per (LineGroup x Pond)

Each combination of LINE group and pond maintains its own independent state machine. This isolation ensures that a noisy sensor on Pond A does not suppress alerts for Pond B in the same LINE group.

The four transitions:

  1. Normal to Alert -- First threshold breach: send notification, start cooldown timer
  2. Alert to Alert (within cooldown) -- Subsequent breaches: log only, suppress notification
  3. Alert to Recovery -- All metrics return to normal: send recovery notification
  4. Recovery to Alert -- New breach after recovery: full notification cycle restarts

Performance at a Glance

  • MQTT Topics: 10 (Bidirectional)
  • Alert Reduction: 80-90% (During events)
  • Containers: 4 (Docker Compose)
  • Engagement: 2 Years (2023-2025)

3-Tier Notification Architecture

Technology Choices

  • Alert Generation: NestJS, State Machine, Threshold Engine
  • Message Delivery: Go LINE Bot, Message Formatting, Rate Limiting
  • Sensor Ingestion: MQTT (QoS 1), 10 Topics, Bidirectional
  • Data Storage: MariaDB, TypeORM, Explicit Join Entities

The notification pipeline is intentionally split across two services:

  • NestJS decides what to alert and who to alert (state machine logic, alert configuration lookup)
  • Go LINE Bot decides how to format and when to send (message templates, rate limiting, LINE API calls)

This separation means LINE API changes, message format updates, and rate limit adjustments never touch the core alert logic.

Explicit Join Entity: LineGroupPond

Design Decision

A simple many-to-many between LineGroup and Pond would require a separate configuration table or a flags table to handle per-relationship alert preferences. The explicit join entity carries configuration directly on the relationship, making queries simpler and the data model self-documenting.

@Entity()
export class LineGroupPond {
  @PrimaryGeneratedColumn()
  id: number;

  @ManyToOne(() => LineGroup)
  lineGroup: LineGroup;

  @ManyToOne(() => Pond)
  pond: Pond;

  @Column({ default: true })
  isAlertEnabled: boolean;      // Water quality alerts

  @Column({ default: true })
  isFeedAlertEnabled: boolean;  // Feed schedule alerts
}

This design allows configuration like:

  • Management group: water quality alerts for all ponds, feed alerts only for high-value ponds
  • Maintenance group: feed alerts for all ponds, no water quality alerts
  • Night shift group: all alerts for all ponds (no filtering)

Bidirectional MQTT with QoS 1

Why QoS 1 for Feeder Control

Feeder control commands use QoS 1 (at-least-once) rather than QoS 0 (fire-and-forget). A missed feeding command means fish do not eat. Duplicate commands are handled by the feeder hardware as idempotent operations -- feeding twice in rapid succession is better than not feeding at all.

The system subscribes to sensor topics for data ingestion and publishes to control topics for feeder commands. The pickScheduleEntry function handles the inherent timing uncertainty:

function pickScheduleEntry(
  schedule: FeedSchedule[],
  currentTime: Date
): FeedSchedule | null {
  // Find the closest scheduled slot by time proximity
  // Handles clock drift and delayed acknowledgments
  return schedule.reduce((closest, entry) => {
    const delta = Math.abs(entry.scheduledTime - currentTime);
    const closestDelta = Math.abs(closest.scheduledTime - currentTime);
    return delta < closestDelta ? entry : closest;
  }, schedule[0]);
}

Client Outcome

The system has been running continuously across multiple ITRI-managed fish farms since 2023. Farm operators manage the entire monitoring and feeding workflow through LINE without needing any technical training or additional applications.

Live Demo

Below is an interactive mock of the fish-farm operator UI. All readings, feed schedules, alert configurations, and LINE group records are fictional and generated in-browser — no backend required.

ITRI Smart Aquaculture Operator UIMock Data
Open in new tab
Menu → 環境監控 for real-time water quality charts, 投餵監控 for the feeding automation UI, 後台管理 for the admin console. All data is in-memory mock.

What You Can Try

  • 首頁 — operator menu with feature cards (環境監控 / 投餵監控 / LINE 設定 / 後台管理, etc.)
  • 環境監控 — time-series charts for water temperature, dissolved oxygen, pH, salinity, and weather
  • 投餵監控 — feed schedule timeline, auto/manual mode toggle, feeder on/off state
  • LINE 設定 — group management with per-pond alert routing
  • 後台管理 — admin console for ponds, stations, and users (admin role pre-applied)