← Back to Sensor Ticker and Virtual MIDI Port

Source Code

Sensor Ticker and Virtual MIDI Port · javascript

'use strict';
require('dotenv').config();
let droneCycleCounter = 0; // Counts how many times the drone engine has run
const express = require('express');
const http = require('http');
const { Server } = require("socket.io");
const { ArduinoIoTCloud } = require('arduino-iot-js');
const fs = require('fs').promises;
const path = require('path');
const easymidi = require('easymidi');

// =================================================================
// --- Configuration ---
// =================================================================
const port = Number(process.env.PORT) || 3000;
const DRONE_INTERVAL_MS = 10000; // Check to see if the drone notes need to change every 10 seconds.
const INITIAL_RECONNECT_DELAY_MS = 2000;
const MAX_RECONNECT_DELAY_MS = 30000;

const thingsToMonitor = [
    {
        thingId: process.env.THING_ID_1,
        variableNames: ['waterTempF', 'airLvl', 'humLvl', 'tdsPPM', 'waterLvl','ldrPinLvl']
    },
    {
        thingId: process.env.THING_ID_2,
        variableNames: ['foodStatus', 'serverStatus']
    }
];

const options = {
  clientId: process.env.CLIENT_ID,
  clientSecret: process.env.CLIENT_SECRET,
    onDisconnect: () => {
        isCloudConnected = false;
        console.warn('Disconnected from Arduino Cloud. Will attempt reconnect.');
        if (!isShuttingDown && !reconnectInProgress) {
            connectCloudWithRetry().catch(error => {
                console.error('Reconnect loop failed:', error);
            });
        }
    },
};

const LOG_FILE_PATH = path.join(__dirname, 'sensor_log.json');
const MAX_LOG_ENTRIES = 50;
const MIDI_DEBUG = process.env.MIDI_DEBUG === 'true';

// =================================================================
// --- Global State Management ---
// =================================================================
let sensorReadings = {};
let midiOutput;
let midiOutputName = null;
try {
    const configuredOutputName = process.env.MIDI_OUTPUT_NAME;
    const availableOutputs = easymidi.getOutputs();
    const isWindows = process.platform === 'win32';
    const useVirtualOutput = !isWindows && process.env.MIDI_USE_VIRTUAL !== 'false';

    if (useVirtualOutput) {
        midiOutputName = 'Aquarium Symphony';
        midiOutput = new easymidi.Output(midiOutputName, true);
        console.log(`Virtual MIDI port '${midiOutputName}' created with easymidi.`);
    } else {
        const selectedOutput = configuredOutputName && availableOutputs.includes(configuredOutputName)
            ? configuredOutputName
            : availableOutputs[0];

        if (!selectedOutput) {
            throw new Error(
                'No MIDI output ports are available. On Windows, create one with loopMIDI and set MIDI_OUTPUT_NAME.'
            );
        }

        midiOutputName = selectedOutput;
        midiOutput = new easymidi.Output(selectedOutput);
        console.log(`Using existing MIDI output port: ${selectedOutput}`);
    }

    if (availableOutputs.length > 0) {
        console.log(`Detected MIDI outputs: ${availableOutputs.join(', ')}`);
    } else {
        console.warn('No existing MIDI outputs detected.');
    }
} catch (error) {
    console.error('Failed to create MIDI output port:', error);
    process.exit(1);
}

// A pleasant C-major 7 scale for our notes
const musicalScale = [48, 52, 55, 59, 60, 64, 67, 71]; // C3, E3, G3, B3, C4, E4, G4, B4

let latestSensorValues = {}; // The most recent value for each sensor.
let activeDrones = {       // The MIDI note number of the currently playing drone notes.
    airLvlNote: null,
    humLvlNote: null,
    waterTempFNote: null,
    tdsPPMNote: null,
    ldrPinLvlNote: null,
};
let lastWaterLvlState = -1; // State for the water level alert to prevent spam.

// State for file writing queue
let isWritingFile = false;
let writeIsPending = false;
let droneIntervalId = null;
let isShuttingDown = false;
let isCloudConnected = false;
let reconnectInProgress = false;
let callbacksRegistered = false;


// =================================================================
// --- Helper Functions ---
// =================================================================

function isFiniteNumber(value) {
    return typeof value === 'number' && Number.isFinite(value);
}

function toFiniteNumber(value) {
    const numericValue = Number(value);
    return Number.isFinite(numericValue) ? numericValue : null;
}

function positiveModulo(value, divisor) {
    return ((value % divisor) + divisor) % divisor;
}

function clampMidiNote(note) {
    return Math.max(0, Math.min(127, Math.floor(note)));
}

function safeMidiSend(messageType, payload) {
    try {
        midiOutput.send(messageType, payload);
        if (MIDI_DEBUG) {
            console.log(`MIDI -> ${messageType} on ${midiOutputName}:`, payload);
        }
    } catch (error) {
        console.error(`MIDI send failed for ${messageType}:`, error);
    }
}

function ensureSensorBuckets() {
    thingsToMonitor.forEach(thing => {
        thing.variableNames.forEach(name => {
            if (!Array.isArray(sensorReadings[name])) {
                sensorReadings[name] = [];
            }
        });
    });
}

// This new helper function handles the logic for a single drone voice.
function updateDroneNote(sensorName, currentValue, activeNote, velocity, octaveOffset = 0) {
    const parsedValue = toFiniteNumber(currentValue);
    if (parsedValue !== null) {
        // Use a different calculation for TDS because its range is small
        const intValue = (sensorName === 'tdsPPM') ? Math.floor(parsedValue) : Math.floor(parsedValue * 10);
        
        const scaleIndex = positiveModulo(intValue, musicalScale.length);
        let newNote = clampMidiNote(musicalScale[scaleIndex] + octaveOffset);
        
        if (newNote !== activeNote) {
            if (activeNote !== null) {
                safeMidiSend('noteoff', { note: activeNote, velocity: 0, channel: 0 });
            }
            safeMidiSend('noteon', { note: newNote, velocity: velocity, channel: 0 });
            console.log(`DRONE ENGINE -> Note for ${sensorName} changed to: ${newNote}`);
            return newNote; // Return the new note so we can update the state
        }
    }
    return activeNote; // Return the old note if no change
}


// =================================================================
// --- THE DRONE ENGINE ---
// This function runs on a timer to sustain the atmospheric notes.
// =================================================================
function updateDrones() {
    // Increment the counter each time the function is called
    droneCycleCounter++;
/*
    // --- The new logic: On every 4th cycle, send a command to the website ---
    if (droneCycleCounter % 4 === 0) {
        console.log('DRONE ENGINE -> Cycle 4 reached. Sending command to randomize LFO.');
        io.emit('trigger-function', { action: 'randomize-lfo' });
    }
*/
    // The rest of the note-playing logic remains the same
    activeDrones.airLvlNote = updateDroneNote('airLvl', latestSensorValues.airLvl, activeDrones.airLvlNote, 70, 0); // Bass note
    activeDrones.humLvlNote = updateDroneNote('humLvl', latestSensorValues.humLvl, activeDrones.humLvlNote, 60, 12); // Harmony 1 (one octave up)
    activeDrones.waterTempFNote = updateDroneNote('waterTempF', latestSensorValues.waterTempF, activeDrones.waterTempFNote, 65, 7); // Harmony 2 (a fifth up)
    activeDrones.tdsPPMNote = updateDroneNote('tdsPPM', latestSensorValues.tdsPPM, activeDrones.tdsPPMNote, 55, -12); // Sub-bass (one octave down)
    activeDrones.ldrPinLvlNote = updateDroneNote('ldrPinLvl', latestSensorValues.ldrPinLvl, activeDrones.ldrPinLvl, 44, -6); // I dunno just guessing here
}
// =================================================================
// --- Main Application Setup ---
// =================================================================

// Web Server and Socket.IO
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static('public'));

app.get('/midi-test', (req, res) => {
    const note = 72;
    const velocity = 100;
    safeMidiSend('noteon', { note, velocity, channel: 0 });
    setTimeout(() => {
        safeMidiSend('noteoff', { note, velocity: 0, channel: 0 });
    }, 250);

    res.json({
        ok: true,
        message: 'Sent MIDI test pulse (C5).',
        output: midiOutputName,
        channel: 0,
        note,
    });
});

io.on('connection', (socket) => {
  console.log('A user connected to the webpage.');
});

function registerThingCallbacks() {
    if (callbacksRegistered) {
        return;
    }

    thingsToMonitor.forEach(thing => {
        if (!thing.thingId) {
            console.warn('Skipping thing with missing thingId in environment variables.');
            return;
        }

        thing.variableNames.forEach(variableName => {
            ArduinoIoTCloud.onPropertyValue(
                thing.thingId,
                variableName,
                (value) => {
                    console.log(`Received value for ${variableName}:`, value);
                    latestSensorValues[variableName] = value;

                    // The main callback now ONLY handles the water level alert.
                    // All other musical logic is in the Drone Engine.
                    if (variableName === 'waterLvl') {
                        const numericValue = toFiniteNumber(value);
                        if (numericValue !== null) {
                            const currentState = Math.floor(numericValue);
                            if (currentState !== lastWaterLvlState) {
                                if (currentState < 1) { // Water is LOW
                                    safeMidiSend('noteon', { note: 36, velocity: 127, channel: 0 }); // Low C alert
                                } else { // Water is OK
                                    safeMidiSend('noteoff', { note: 36, velocity: 0, channel: 0 });
                                }
                                lastWaterLvlState = currentState;
                            }
                        }
                    }

                    // Handle logging and web dashboard updates (unchanged).
                    if (!sensorReadings[variableName]) sensorReadings[variableName] = [];
                    const timestamp = new Date().toISOString();
                    sensorReadings[variableName].unshift({ value, timestamp });
                    if (sensorReadings[variableName].length > MAX_LOG_ENTRIES) sensorReadings[variableName].pop();
                    writeIsPending = true;
                    processWriteQueue();
                    io.emit('variable-update', { name: variableName, value: value, timestamp });
                }
            );
            console.log(`Callback registered for Variable: ${variableName} on Thing: ${thing.thingId}`);
        });
    });

    callbacksRegistered = true;
}

async function connectCloudWithRetry() {
    if (reconnectInProgress) return;
    reconnectInProgress = true;

    let reconnectDelay = INITIAL_RECONNECT_DELAY_MS;

    try {
        while (!isShuttingDown) {
            try {
                await ArduinoIoTCloud.connect(options);
                isCloudConnected = true;
                console.log('Connected to Arduino IoT Cloud');
                registerThingCallbacks();
                return;
            } catch (error) {
                console.error(`Cloud connection failed. Retrying in ${reconnectDelay / 1000}s.`, error);
                await new Promise(resolve => setTimeout(resolve, reconnectDelay));
                reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
            }
        }
    } finally {
        reconnectInProgress = false;
    }
}

function stopAllActiveNotes() {
    const notes = [
        activeDrones.airLvlNote,
        activeDrones.humLvlNote,
        activeDrones.waterTempFNote,
        activeDrones.tdsPPMNote,
        activeDrones.ldrPinLvl,
        36,
    ];

    notes.forEach(note => {
        if (isFiniteNumber(note)) {
            safeMidiSend('noteoff', { note, velocity: 0, channel: 0 });
        }
    });
}

async function shutdown(signal) {
    if (isShuttingDown) return;
    isShuttingDown = true;
    console.log(`Received ${signal}. Shutting down gracefully...`);

    if (droneIntervalId) {
        clearInterval(droneIntervalId);
        droneIntervalId = null;
    }

    stopAllActiveNotes();

    if (writeIsPending) {
        await processWriteQueue();
    }

    await new Promise(resolve => {
        server.close(() => resolve());
    });

    try {
        midiOutput.close();
    } catch (error) {
        console.error('Error closing MIDI output:', error);
    }

    process.exit(0);
}

process.on('SIGINT', () => {
    shutdown('SIGINT').catch(error => {
        console.error('Shutdown failure:', error);
        process.exit(1);
    });
});

process.on('SIGTERM', () => {
    shutdown('SIGTERM').catch(error => {
        console.error('Shutdown failure:', error);
        process.exit(1);
    });
});

process.on('uncaughtException', error => {
    console.error('Uncaught exception:', error);
});

process.on('unhandledRejection', reason => {
    console.error('Unhandled rejection:', reason);
});

// Main execution function
async function main() {
  await loadInitialData();

  droneIntervalId = setInterval(updateDrones, DRONE_INTERVAL_MS);
  console.log(`Drone Engine started. Will check notes every ${DRONE_INTERVAL_MS / 1000} seconds.`);

  await connectCloudWithRetry();
}

// Start the server
server.listen(port, () => {
  console.log(`Server is running at http://localhost:${port}`);
    main().catch(error => {
        console.error('Fatal startup error:', error);
        process.exit(1);
    });
});


// --- File I/O Helper Functions (Unchanged) ---
async function processWriteQueue() {
    if (isWritingFile || !writeIsPending) return;
    isWritingFile = true;
    writeIsPending = false;
    try {
        const jsonString = JSON.stringify(sensorReadings, null, 2);
        await fs.writeFile(LOG_FILE_PATH, jsonString, 'utf8');
    } catch (err) {
        console.error("Error writing to log file:", err);
    } finally {
        isWritingFile = false;
        if (writeIsPending) processWriteQueue();
    }
}
async function loadInitialData() {
    try {
        await fs.access(LOG_FILE_PATH);
        const data = await fs.readFile(LOG_FILE_PATH, 'utf8');
        sensorReadings = JSON.parse(data);
        ensureSensorBuckets();
    } catch (err) {
        console.warn('No valid existing sensor log found. Initializing empty readings.');
        sensorReadings = {};
        ensureSensorBuckets();
    }
}