Hydroconnect API provides both REST and Socket.IO (IO) endpoints for interacting with IoT devices and retrieving sensor data. The system is designed with versioned endpoints, independent versioning for REST and IO, and chunk-based data streaming for efficient data transfers.
- Overview
- Project Structure
- API Versioning
- Data Schemas
- REST API
- Socket.IO API
- Environment Configuration
- Testing
- Development
- Error Handling
- Logging
- Deployment
- License
The Hydroconnect API serves as a bridge between IoT devices and clients. It enables:
- IoT devices to push sensor readings in real time.
- Clients to download summarized data and request historical reports.
- Chunked and asynchronous data streaming for large downloads.
The following is the directory structure for the Hydroconnect API project. Each folder and file serves a distinct purpose in organizing REST endpoints, Socket.IO logic, schema definitions, and build output.
Hydroconnect-API/
│
├── .github/
│ └── workflows/ # GitHub Actions workflow specifications (CI/CD)
|
├── controllers/
│ ├── io.ts # Handles Socket.IO events and real-time operations
│ └── rest.ts # Handles REST API routes and HTTP logic
│
├── dist/ # Compiled output
│
├── lib/ # Utility modules or shared library code
│
├── node_modules/ # Project dependencies
│
├── public/ # Publicly served assets (if any)
│
├── schemas/ # All schema definitions
│ └── models/ # Schema definition that tied to a database
│
├── tests/ # Unit and integration test cases and sample DB data
│
├── .d.env # Example environment file for development
├── .env # Environment configuration (ignored in version control)
│
├── .gitignore # Git ignore rules
├── .prettierrc # Code formatting configuration
├── .eslintrc.ts # ESLint configuration (legacy / compatibility)
│
├── log # Log file (ignored in version control)
|
├── vercel.json # Vercel deployment and routing configuration
│
├── package.json # Project metadata and dependencies
├── package-lock.json # Dependency lock file
├── README.md # Project documentation
│
├── server.ts # Main entry point (initializes Express and Socket.IO servers)
│
├── eslint.config.ts # ESLint flat config (recommended for newer ESLint versions)
├── tsconfig.json # TypeScript configuration
└── vitest.config.ts # Vitest configuration for testing
Hydroconnect uses independent versioning for REST and IO routes.
| Category | Prefix | Current Version | Example Path |
|---|---|---|---|
| REST | /rest/{version}/ |
v1 |
/rest/v1/summary |
| IO | /io/{version}/ |
v1 |
/io/v1 (Socket.IO namespace) |
Versioning ensures backward compatibility for both REST and Socket.IO interfaces.
There are 2 types of data schemas, 1 that is tied to a database (models directory) and 1 that doesn't. The models data schema will have mongoose schema definition. All data schema have typescript interface definition, and some have Zod definition.
*The on that has Zod definition, strict schema definitions apply to all REST and Socket.IO interactions.
Schema for storing device push notification tokens.
const zDevices = z.strictObject({
token: z.string(),
});
const devicesSchema = new Schema({
token: { type: String, required: true }, // i.e. "ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]", from devices
});
devicesSchema.index({ token: 1 }, { unique: true });Fields:
| Field | Type | Description |
|---|---|---|
token |
string |
Expo push token from the device. |
Schema for storing global incremental identifiers.
const idsSchema = new Schema({
notificationId: { type: Number, required: true, default: 1 },
});Fields:
| Field | Type | Description |
|---|---|---|
notificationId |
number |
Auto-incremented notification identifier |
Schema representing IoT sensor readings.
const zReadings = z.strictObject({
turbidity: z.number(),
pH: z.number(),
tds: z.number(),
temperature: z.number(),
control: z.int().min(0).max(31),
percent: z.number(),
timestamp: z.iso.datetime(),
});
const readingsSchema = new Schema({
turbidity: { type: Number, required: true },
pH: { type: Number, required: true },
tds: { type: Number, required: true },
temperature: { type: Number, required: true }, // Degree Celcius
control: { type: Number, required: true }, // MSB → LSB (valve, sensor, distribution, reservoir, tank)
percent: { type: Number, required: true },
timestamp: { type: Date, required: true, immutable: true, default: Date.now },
});
readingsSchema.index({ timestamp: 1 }, { unique: true });Fields:
| Field | Type | Description |
|---|---|---|
turbidity |
number |
Turbidity level of the water. |
pH |
number |
pH level of the water. |
tds |
number |
Total dissolved solids (ppm). |
temperature |
number |
Temperature in Celsius. |
control |
integer |
Device control bitmask (0–31). |
percent |
number |
Sensor reading percentage. |
timestamp |
ISO datetime |
Time the reading was recorded. |
Schema representing daily uptime summaries.
const zSummaries = z.strictObject({
uptime: z.number().gte(0),
timestamp: z.iso.datetime(),
});
const summariesSchema = new Schema({
uptime: { type: Number, required: true, default: 0 }, // In seconds
timestamp: { type: Date, required: true, immutable: true }, // Is always set to midnight 00.01
});
summariesSchema.index({ timestamp: 1 }, { unique: true });Fields:
| Field | Type | Description |
|---|---|---|
uptime |
number |
System uptime in seconds. |
timestamp |
ISO datetime |
Always set to midnight (00:01). |
Schema for tracking usage notifications.
const zUsageNotification = z.strictObject({
notificationId: z.int(),
timestamp: z.iso.datetime(),
type: z.boolean(),
});
const usageNotificationsSchema = new Schema({
notificationId: { type: Number, required: true, immutable: true },
timestamp: { type: Date, required: true, immutable: true },
type: { type: Boolean, required: true, immutable: true },
});
usageNotificationsSchema.index({ notificationId: 1 }, { unique: true });Fields:
| Field | Type | Description |
|---|---|---|
notificationId |
number |
Unique notification identifier. |
timestamp |
ISO datetime |
Time the notification was generated. |
type |
boolean |
Notification type flag. |
Schema for requesting report downloads.
const zDownloadRequest = z.strictObject({
from: z.iso.datetime(),
to: z.iso.datetime(),
downloadId: z.string().max(parseInt(process.env.MAX_DOWNLOAD_ID_LENGTH!)).min(1),
});Fields:
| Field | Type | Description |
|---|---|---|
from |
ISO datetime |
Start date of the report range. |
to |
ISO datetime |
End date of the report range. |
downloadId |
string |
Unique identifier constrained by MAX_DOWNLOAD_ID_LENGTH. |
Schema for IoT device upload payload.
const zIoTPayload = z.strictObject({
key: z.hash("sha512", { enc: "base64url" }),
readings: zReadings.omit({ timestamp: true, percent: true }),
});Fields:
| Field | Type | Description |
|---|---|---|
key |
string |
SHA-512 base64url authentication key. |
readings |
Omit<Readings,"timestamp"|"percent"> |
Sensor readings without timestamp & percent. |
Schema for instructional guide content.
const zPanduanData = z.strictObject({
title: z.string(),
videoUrl: z.string(),
thumbnailUrl: z.string(),
steps: z.array(z.string()),
});Fields:
| Field | Type | Description |
|---|---|---|
title |
string |
Guide title. |
videoUrl |
string |
URL of the instructional video. |
thumbnailUrl |
string |
Thumbnail image URL. |
steps |
string[] |
Ordered list of instruction steps. |
/rest/v1/
All uncaught error in REST API will be handled by errorHandler via HttpError, if unexpected error happens the server will always return HttpError(500). All POST request body and all response are in JSON, thus header Content-Type is always application/json.
Returns uptime summaries for the last 7 days (inclusive) before the end_date or now (if end_date is not supplied), ordered from newest to oldest. This will return in timestamp descending order.
If a day has no data, uptime is returned as 0.
[
{
"timestamp": "2026-01-05T00:00:00.001Z",
"uptime": 86400
}
]Returns the latest IoT sensor reading. Uses in-memory cache if available to reduce database queries.
{
"turbidity": 1.2,
"pH": 7.1,
"tds": 120,
"temperature": 27,
"control": 3,
"percent": 100,
"timestamp": "2026-01-05T09:15:00.000Z"
}Receives IoT device data upload, compute and stores readings, updates uptime, emits readings Socket.IO events, and triggers usage notifications.
{
"key": "sha512_base64url_key",
"readings": {
"turbidity": 1.2,
"pH": 7.1,
"tds": 120,
"temperature": 27,
"control": 3
}
}true| Status | Description |
|---|---|
| 400 | Invalid payload (Zod validation) |
| 403 | Invalid IoT authentication key |
Returns usage notifications, optionally paginated using latest notification ID.
| Query | Type | Description |
|---|---|---|
latest |
number | Latest notification ID exclusive (for pagination) |
[
{
"notificationId": 15,
"timestamp": "2026-01-05T09:00:00.000Z",
"type": true
}
]Registers an Expo Push Token for receiving notifications. Can handle duplicate request.
{
"token": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]"
}true| Status | Description |
|---|---|
| 400 | Invalid body or invalid Expo token |
Removes a previously registered Expo Push Token.
true| Status | Description |
|---|---|
| 400 | Invalid body or invalid Expo token |
Returns static instructional guide data for system usage.
[
{
"title": "Cara Menyalakan Pompa",
"videoUrl": "https://youtube.com/...",
"thumbnailUrl": "https://img.youtube.com/...",
"steps": ["Lorem", "Ipsum"]
}
]Receives GitHub webhook events, validates HMAC signature, and restarts the backend service only in production server. See GitHub docs
true| Status | Description |
|---|---|
| 403 | Unauthorized webhook |
/io/v1
Description: Broadcasting latest data that is sent by IoT.
Output:
Readings
Description: For signaling error to client.
Output:
Error (JSON Object not class)
Description: Triggered by clients to request report downloads.
Input:
DownloadRequest
Description: Used to send report data in chunks to the client.
Output:
Readings[], downloadId, ack
Description: Emitted when the server completes sending all chunks of a requested report.
Output:
downloadId
There can be 2 type of server starting: start on development or start on production (see logs). On development use .d.env on production use .env if doesn't exist program will exit.
| Variable | Description | Example Value |
|---|---|---|
PORT |
Server listening port | 3000 |
DB_URL |
MongoDB connection URI | mongodb://localhost:27017/Hydroconnect |
DOWNLOAD_CHUNK_N_SIZE |
Number of records per report chunk when downloading | 2 |
MAX_DOWNLOAD_ID_LENGTH |
Maximum length of the download identifier (can't be less) | 50 |
MIN_DOWNLOAD_INTERVAL_SECONDS |
Minimum interval between download requests (seconds) | 2 |
IOT_INTERVAL_MS |
Interval at which IoT data is sent (milliseconds) | 1000 |
IOT_INTERVAL_TOLERANCE_MS |
Allowed delay tolerance for IoT data timeout (milliseconds) | 500 |
IOT_KEY |
SHA-512 base64url key for IoT authentication | _49lFI-ngS-9eTp8enaRCMG6ZwLeQQaorZ_RgAvxBP4DtYoUvVokG9whNZ9khQw3OL00xnRnko08vnKtHfAbVA |
GITHUB_WEBHOOK_SECRET |
Secret used to validate GitHub webhook HMAC | sQCWrj8Kg/gjeEuWLjIl6TqLeSidHEuHO1EzMkb952M= |
IS_LINUX |
Enable Linux-only system commands | false |
USAGE_NOTIFICATION_PAGING_LIMIT |
Maximum number of notifications per request | 5 |
INITIAL_NOTIF_BACKOFF_S |
Initial notification backoff delay (seconds) | 2 |
NOTIF_BACKOFF_MULTIPLIER |
Exponential multiplier for notification backoff | 3 |
DISABLE_NOTIFICATION |
Disable sending notifications globally (useful for testing) | true |
IF NODE_ENV!=production, then following become true:
/interactAPIHTML page for manual interaction.- Artificial timeouts to simulate real-world delay in report downloads.
- Logger also logs to console
- Local Caching of last summary entry is disabled
IFF NODE_ENV=production & IS_LINUX=true:
- Github Webhooks auto-update is enabled.
The project includes comprehensive test cases for all available endpoints.
Test workflow:
- Clears existing MongoDB collections.
- Repopulates with sample data.
- Executes automated test suites for each API (both REST and IO).
Test coverage includes:
- All REST endpoints except
/github-webhook, can be test manually from github. - IO
readings,download-request,download-data,download-finish, anderrorevents. - Doesn't test for notification system, test manually using phone and InteractAPI.html or copy ExpoPushToken to
realExpoTokenvariable onresttest file in. WARNING! don't commit the token, manual testing preferred.
Before Anything:
npm installnpm run dev # (local development w/ Hot-Reload)
npm run start # (local development w/o Hot-Reload)
npm run lint # (Lint the project w/ ESLint)
npm run test # (Run Test Case --> Run this also to set mock data in database)
npm run build # (Lint and then Build)Add a new test if a new endpoint is added and make sure all test passed. To test run the instance (npm run dev) then on another terminal run (npm run test).
There is a custom HttpError class for HTTP-related errors with a status property and IOError with IOErrorEnum for IO-related errors.
The system also includes two dedicated handlers:
RESTErrorHandlerfor REST endpoints (e.g., Http Response 400 for invalid request body, 500 for any unknown error)IOErrorHandlerfor Socket.IO events (i.e., sent aserrorevents)
Logging use Winston package for asynchronous logging. There are 2 loggers that includes Console and transport 1 that doesn't. The consoleLogger is useful for logging to journalctl on Linux production server.
Don't ever use console.log for logging in production!
There is 1 Github Actions called lintNTest. The name is self-explanatory. For deploying just push to main and the server (if on) will update on its own, if adding any .d.env make sure to change the .env on production to. Make sure pushed commit has been tested (npm run test) and builded (npm run build). See also the Github Actions verdict on github to see if there are any errors.
On linux production server, we setup a systemd that is enabled and auto-retry indefinitely called hydroconnect. Thus starting / restarting / stopping / log can be done using sudo systemctl [start / restart / stop] hydroconnect and sudo journalctl -u hydroconnect [-f]. Currently, this systemd runs a premade script that:
- Revert all changes to latest commit
- Pull latest from main branch
npm run build&npm run startusingNODE_ENV=production
The user that runs the program should have the command sudo systemctl restart hydroconnect be in SUDOERS NO PASSWD. Exact command can be seen in github webhook endpoint.
This project is proprietary and intended for internal use within the Hydroconnect ecosystem. Unauthorized distribution or modification is prohibited without prior permission.