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
Backend
Infrastructure
Database / Messaging
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.
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:
- Normal to Alert -- First threshold breach: send notification, start cooldown timer
- Alert to Alert (within cooldown) -- Subsequent breaches: log only, suppress notification
- Alert to Recovery -- All metrics return to normal: send recovery notification
- 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.
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)