Integration of an MSPA Whirlpool with PV surplus control in openHAB

Today I’d like to present my integration of an MSPA Whirlpool in openHAB. The solution not only enables complete control of all functions but also contains intelligent automation based on time of day and PV surplus.

About the development: I intercepted the communication with the MSPA cloud using an HTTP catcher with installed SSL certificate while using the official MSPA app. This allowed me to analyze all API endpoints, authentication methods, and data formats. The cloud API uses MD5 signatures and token-based authentication.

Features of the integration:

  • Complete control of all whirlpool functions (heating, filter, massage, UVC, ozone)
  • Automatic day/night control (filter/UVC on during the day, off at night)
  • PV surplus control for heating (uses solar surplus and battery level)
  • Status messages and error notifications
  • “In use” status for more precise control
  • Button integration for direct control from the pool
  • Command validation with error handling

Special features: The heating is only switched on when there is sufficient PV surplus, depending on the time of day and the battery level. When PV surplus is low, the heating is automatically switched off.

The Code

// MSPA Whirlpool Integration for openHAB
// Author: Andreas Probst
// Version: 1.0
const { rules, items, time, actions, triggers } = require('openhab');

// MD5 Implementation
function md5(input) {
    const java = Java.type('java.security.MessageDigest');
    const md = java.getInstance('MD5');
    const messageBytes = String(input).getBytes('UTF-8');
    const digestBytes = md.digest(messageBytes);

    // Convert bytes to hex string
    const hexString = [];
    for (let i = 0; i < digestBytes.length; i++) {
        let hex = (digestBytes[i] & 0xff).toString(16);
        if (hex.length === 1) hex = '0' + hex;
        hexString.push(hex);
    }
    return hexString.join('').toUpperCase();
}

// MSPA Configuration - PLEASE REPLACE WITH YOUR OWN VALUES
const MSPA_CONFIG = {
    API_BASE: 'https://api.iot.the-mspa.com',
    APP_ID: 'e1c8e068f9ca11eba4dc0242ac120002',
    ACCOUNT: 'your-email@example.com',            // CHANGE: Your MSPA App Email
    PASSWORD_HASH: 'your-password-hash',           // CHANGE: MD5 Hash of your MSPA App Password
    COUNTRY: 'DE',
    PUSH_TYPE: 'ios',
    REGISTRATION_ID: 'your-registration-id',      // CHANGE: Determine from app communication
    DEVICE_ID: 'your-device-id',                  // CHANGE: Your MSPA Device ID
    PRODUCT_ID: 'your-product-id'                  // CHANGE: Your MSPA Product Code (e.g. 'O0N301')
};

// Global variables
let currentToken = null;
let tokenExpiry = null;
let lastConnectionErrorTime = null;
let solarSurplusTimer = null;
let commandInProgress = false;
let validationTimers = new Map();

// Logger function with Advanced_Logging support
const log = (message, level = 'info') => {
    const advancedLogging = items.getItem('Advanced_Logging').state === 'ON';
    if (advancedLogging || level === 'error') {
        const prefix = 'MSPA';
        switch (level) {
            case 'debug': console.debug(`[${prefix}] ${message}`); break;
            case 'warn': console.warn(`[${prefix}] ${message}`); break;
            case 'error': console.error(`[${prefix}] ${message}`); break;
            default: console.info(`[${prefix}] ${message}`);
        }
    }
};

// Nonce Generator (32 characters, A-Z and 0-9)
function generateNonce() {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    let nonce = '';
    for (let i = 0; i < 32; i++) {
        nonce += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    return nonce;
}

// Sign Generator Function
function generateSign(method, path, nonce, timestamp, token = '6710dd2e13fa5ad6ab07eb04b82a9226', body = '') {
    // Order: APP_ID + nonce + timestamp + method + path + body
    const signString = `${MSPA_CONFIG.APP_ID}${nonce}${timestamp}${method}${path}${body}`;

    // Debug output
    log(`Sign String Components:
        - APP_ID: ${MSPA_CONFIG.APP_ID}
        - Nonce: ${nonce}
        - Timestamp: ${timestamp}
        - Method: ${method}
        - Path: ${path}
        - Body: ${body}
        - Token: ${token}`, 'debug');
    log(`Complete Sign String: ${signString}`, 'debug');

    const signature = md5(signString);
    log(`Generated Sign: ${signature}`, 'debug');
    return signature;
}

// Header generator for API requests
function getMSPAHeaders(method, path, token = '', body = '') {
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const nonce = generateNonce();

    // Note: Adjust these fixed values in case of API problems
    let headers = {
        'Host': 'api.iot.the-mspa.com',
        'appid': 'e1c8e068f9ca11eba4dc0242ac120002',
        'Content-Type': 'application/json; charset=utf-8',
        'lan_code': 'de',
        'User-Agent': 'DongHui/7 CFNetwork/3826.500.111.2.2 Darwin/24.4.0',
        'Connection': 'keep-alive',
        'ts': timestamp,
        'nonce': nonce,
        'sign': generateSign(method, path, nonce, timestamp, token, body)
    };

    if (currentToken) {
        headers['Authorization'] = `token ${currentToken}`;
    }

    return headers;
}

// Login function
async function login() {
    try {
        const path = '/api/enduser/get_token/';
        const body = JSON.stringify({
            password: MSPA_CONFIG.PASSWORD_HASH,
            account: MSPA_CONFIG.ACCOUNT,
            registration_id: MSPA_CONFIG.REGISTRATION_ID,
            country: MSPA_CONFIG.COUNTRY,
            app_id: MSPA_CONFIG.APP_ID,
            push_type: MSPA_CONFIG.PUSH_TYPE
        });

        log(`Login Request Body: ${body}`, 'debug');

        const headers = getMSPAHeaders('POST', path, '', body);
        log(`Generated Headers: ${JSON.stringify(headers, null, 2)}`, 'debug');

        log('Sending Login Request...');
        const response = await actions.HTTP.sendHttpPostRequest(
            `${MSPA_CONFIG.API_BASE}${path}`,
            'application/json',
            body,
            headers,
            30000
        );
        log(`Login Response: ${response}`, 'debug');

        const data = JSON.parse(response);
        if (data.code === 0) {
            currentToken = data.data.token;
            tokenExpiry = Date.now() + (24 * 60 * 60 * 1000);
            log('Login successful.', 'info');
            return true;
        }
        log(`Login failed with code: ${data.code}, Message: ${data.message}`, 'warn');
        return false;
    } catch (error) {
        log(`MSPA Login Error: ${error}`, 'error');
        if (error.response) {
            log(`Error Response: ${error.response}`, 'error');
        }
        return false;
    }
}

// Function to send commands to the whirlpool
async function sendMSPACommand(command, value, level = null) {
    try {
        // Begin by blocking regular polling
        commandInProgress = true;
        
        const commandPath = '/api/device/command/';
        let commandBody = null;

        switch (command) {
            case 'MSPA_Heater':
                commandBody = JSON.stringify({
                    "desired": {
                        "state": {
                            "desired": {
                                "heater_state": value === 'ON' ? 1 : 0
                            }
                        }
                    },
                    "device_id": MSPA_CONFIG.DEVICE_ID,
                    "product_id": MSPA_CONFIG.PRODUCT_ID
                });
                break;
            case 'MSPA_Filter':
                commandBody = JSON.stringify({
                    "desired": {
                        "state": {
                            "desired": {
                                "filter_state": value === 'ON' ? 1 : 0
                            }
                        }
                    },
                    "device_id": MSPA_CONFIG.DEVICE_ID,
                    "product_id": MSPA_CONFIG.PRODUCT_ID
                });
                break;
            case 'MSPA_Massage':
                commandBody = JSON.stringify({
                    "desired": {
                        "state": {
                            "desired": {
                                "bubble_state": value === 'ON' ? 1 : 0,
                                "bubble_level": value === 'ON' ? (level || 1) : 1
                            }
                        }
                    },
                    "device_id": MSPA_CONFIG.DEVICE_ID,
                    "product_id": MSPA_CONFIG.PRODUCT_ID
                });
                break;
            case 'MSPA_UVC':
                commandBody = JSON.stringify({
                    "desired": {
                        "state": {
                            "desired": {
                                "uvc_state": value === 'ON' ? 1 : 0
                            }
                        }
                    },
                    "device_id": MSPA_CONFIG.DEVICE_ID,
                    "product_id": MSPA_CONFIG.PRODUCT_ID
                });
                break;
            case 'MSPA_Ozone':
                commandBody = JSON.stringify({
                    "desired": {
                        "state": {
                            "desired": {
                                "ozone_state": value === 'ON' ? 1 : 0
                            }
                        }
                    },
                    "device_id": MSPA_CONFIG.DEVICE_ID,
                    "product_id": MSPA_CONFIG.PRODUCT_ID
                });
                break;
            case 'MSPA_Lock':
                commandBody = JSON.stringify({
                    "desired": {
                        "state": {
                            "desired": {
                                "safety_lock": value === 'ON' ? 1 : 0
                            }
                        }
                    },
                    "device_id": MSPA_CONFIG.DEVICE_ID,
                    "product_id": MSPA_CONFIG.PRODUCT_ID
                });
                break;
            case 'MSPA_Target_Temperature':
                const temperatureRaw = Math.round(value * 2);
                commandBody = JSON.stringify({
                    "desired": {
                        "state": {
                            "desired": {
                                "temperature_setting": temperatureRaw
                            }
                        }
                    },
                    "device_id": MSPA_CONFIG.DEVICE_ID,
                    "product_id": MSPA_CONFIG.PRODUCT_ID
                });
                break;
            default:
                log(`Unknown command: ${command}`, 'warn');
                commandInProgress = false; // Release lock on error
                return false;
        }

        log(`Sending Command Request Body: ${commandBody}`, 'debug');
        const commandHeaders = getMSPAHeaders('POST', commandPath, '', commandBody);
        log(`Generated Command Headers: ${JSON.stringify(commandHeaders, null, 2)}`, 'debug');
        log('Sending Command Request...');

        const commandResponse = await actions.HTTP.sendHttpPostRequest(
            `${MSPA_CONFIG.API_BASE}${commandPath}`,
            'application/json',
            commandBody,
            commandHeaders,
            30000
        );

        log(`Command Response: ${commandResponse}`, 'debug');

        const commandData = JSON.parse(commandResponse);
        if (commandData.code === 0) {
            log(`Command ${command} sent to cloud. First check in 5 seconds...`, 'info');
            
            // If a timer already exists for this item, cancel it
            if (validationTimers.has(command)) {
                log(`Canceling previous validation for ${command} (new command sent)`, 'info');
                clearTimeout(validationTimers.get(command));
                validationTimers.delete(command);
            }
            
            // Two-stage status check
            const timerId = setTimeout(async () => {
                // First validation after 5 seconds
                const statusSuccess = await getDeviceStatus();
                if (statusSuccess) {
                    const statusItemMap = {
                        'MSPA_Heater': 'MSPA_Heater',
                        'MSPA_Filter': 'MSPA_Filter',
                        'MSPA_Massage': 'MSPA_Massage',
                        'MSPA_UVC': 'MSPA_UVC',
                        'MSPA_Ozone': 'MSPA_Ozone',
                        'MSPA_Lock': 'MSPA_Lock',
                        'MSPA_Target_Temperature': 'MSPA_Target_Temperature'
                    };
                    
                    const currentValue = items.getItem(statusItemMap[command]).state.toString();
                    const expectedValue = value.toString();
                    
                    if (currentValue === expectedValue) {
                        log(`Command ${command}=${value} successfully confirmed on device (first check)`, 'info');
                        
                        // Remove timer from map
                        validationTimers.delete(command);
                        
                        // Release lock if no more validations are running
                        if (validationTimers.size === 0) {
                            commandInProgress = false;
                        }
                    } else {
                        log(`Command ${command} not yet adopted by device (first check). Waiting another 5 seconds...`, 'debug');
                        
                        // Second validation after another 5 seconds
                        setTimeout(async () => {
                            const secondStatusSuccess = await getDeviceStatus();
                            if (secondStatusSuccess) {
                                const newCurrentValue = items.getItem(statusItemMap[command]).state.toString();
                                
                                if (newCurrentValue === expectedValue) {
                                    log(`Command ${command}=${value} successfully confirmed on device (second check)`, 'info');
                                } else {
                                    // Only issue an error message now, as second check also failed
                                    log(`Command ${command} not adopted by device! (Is: ${newCurrentValue}, Should: ${expectedValue})`, 'error');
                                    sendConnectionErrorMessage(`The command ${command}=${value} was not adopted by the whirlpool`);
                                }
                            }
                            
                            // Remove timer from map
                            validationTimers.delete(command);
                            
                            // Release lock if no more validations are running
                            if (validationTimers.size === 0) {
                                commandInProgress = false;
                            }
                        }, 5000);
                    }
                } else {
                    // Status query failed - second attempt
                    log(`Status query failed (first check). Trying again in 5 seconds...`, 'debug');
                    
                    setTimeout(async () => {
                        const secondStatusSuccess = await getDeviceStatus();
                        if (!secondStatusSuccess) {
                            log(`Status query failed (second check). Command confirmation not possible.`, 'error');
                            sendConnectionErrorMessage(`Status query for command ${command}=${value} failed`);
                        }
                        
                        // Remove timer from map
                        validationTimers.delete(command);
                        
                        // Release lock if no more validations are running
                        if (validationTimers.size === 0) {
                            commandInProgress = false;
                        }
                    }, 5000);
                }
            }, 5000);
            
            // Store timer in map
            validationTimers.set(command, timerId);
            
            return true; // Command successfully sent to cloud
        } else {
            const errorMessage = `Error sending command ${command}: Code=${commandData.code}, Message=${commandData.message}`;
            log(errorMessage, 'error');
            sendConnectionErrorMessage(errorMessage);
            
            // Release lock on error, if no more validations are running
            if (validationTimers.size === 0) {
                commandInProgress = false;
            }
            
            if (commandData.code === 403) {
                log('Token expired, trying to renew...');
                const loginSuccess = await login();
                if (loginSuccess) {
                    log('Token successfully renewed, sending command again.');
                    return await sendMSPACommand(command, value, level);
                } else {
                    log('Login failed even after token expiry.', 'error');
                    return false;
                }
            }
            return false;
        }
    } catch (error) {
        const errorMessage = `Error sending command: ${error}`;
        log(errorMessage, 'error');
        sendConnectionErrorMessage(errorMessage);
        
        // Also release lock on exception, if no more validations are running
        if (validationTimers.size === 0) {
            commandInProgress = false;
        }
        
        return false;
    }
}

// Function to retrieve device status
async function getDeviceStatus() {
    try {
        const path = '/api/device/thing_shadow/';
        const body = JSON.stringify({
            "device_id": MSPA_CONFIG.DEVICE_ID,
            "product_id": MSPA_CONFIG.PRODUCT_ID
        });

        const headers = getMSPAHeaders('POST', path, body);

        log('Sending Status Request...');
        log(`Status Request URL: ${MSPA_CONFIG.API_BASE}${path}`, 'debug');
        log(`Status Request Headers: ${JSON.stringify(headers, null, 2)}`, 'debug');
        log(`Status Request Body: ${body}`, 'debug');

        const response = await actions.HTTP.sendHttpPostRequest(
            `${MSPA_CONFIG.API_BASE}${path}`,
            'application/json',
            body,
            headers,
            30000
        );

        log(`Status Response: ${response}`, 'debug');

        try {
            const statusData = JSON.parse(response);

            if (statusData.code !== 0) {
                const errorMessage = `Error retrieving status: ${statusData.message}`;
                log(errorMessage, 'warn');
                sendConnectionErrorMessage(errorMessage); // Send error message
                // Try token renewal
                if (statusData.code === 403) {
                    log('Token expired, trying to renew...');
                    const loginSuccess = await login();
                    if (loginSuccess) {
                        log('Token successfully renewed, sending status request again.');
                        // Recall function
                        return await getDeviceStatus();
                    } else {
                        log('Login failed even after token expiry.', 'warn');
                        return false;
                    }
                }
                return false;
            }

            const data = statusData.data;

            // Update items with values from status, without triggering commands
            if (items.getItem('MSPA_Current_Temperature').state != data.water_temperature) {
                items.getItem('MSPA_Current_Temperature').postUpdate(data.water_temperature / 2);
            }
            if (items.getItem('MSPA_Heater').state != (data.heater_state === 1 ? 'ON' : 'OFF')) {
                items.getItem('MSPA_Heater').postUpdate(data.heater_state === 1 ? 'ON' : 'OFF');
            }
            if (items.getItem('MSPA_Filter').state != (data.filter_state === 1 ? 'ON' : 'OFF')) {
                items.getItem('MSPA_Filter').postUpdate(data.filter_state === 1 ? 'ON' : 'OFF');
            }
            if (items.getItem('MSPA_Massage').state != (data.bubble_state === 1 ? 'ON' : 'OFF')) {
                items.getItem('MSPA_Massage').postUpdate(data.bubble_state === 1 ? 'ON' : 'OFF');
            }
            if (data.bubble_level !== undefined) {
                items.getItem('MSPA_Massage_Level').postUpdate(data.bubble_level);
            }
            if (items.getItem('MSPA_UVC').state != (data.uvc_state === 1 ? 'ON' : 'OFF')) {
                items.getItem('MSPA_UVC').postUpdate(data.uvc_state === 1 ? 'ON' : 'OFF');
            }
            if (items.getItem('MSPA_Ozone').state != (data.ozone_state === 1 ? 'ON' : 'OFF')) {
                items.getItem('MSPA_Ozone').postUpdate(data.ozone_state === 1 ? 'ON' : 'OFF');
            }
            if (items.getItem('MSPA_Lock').state != (data.safety_lock === 1 ? 'ON' : 'OFF')) {
                items.getItem('MSPA_Lock').postUpdate(data.safety_lock === 1 ? 'ON' : 'OFF');
            }
            if (items.getItem('MSPA_Target_Temperature').state != data.temperature_setting) {
                items.getItem('MSPA_Target_Temperature').postUpdate(data.temperature_setting / 2);
            }

            log('Status successfully updated.', 'info');
            return true;
        } catch (error) {
            const errorMessage = `Error processing status response: ${error}`;
            log(errorMessage, 'error');
            log(`Status Response: ${response}`, 'error');
            sendConnectionErrorMessage(errorMessage); // Send error message
            return false;
        }
    } catch (error) {
        const errorMessage = `Error retrieving status: ${error}`;
        log(errorMessage, 'error');
        log(`Error details: ${error.message}, ${error.stack}`, 'error');
        sendConnectionErrorMessage(errorMessage); // Send error message
        return false;
    }
}

// Send error messages to users (please adjust email addresses)
function sendConnectionErrorMessage(message) {
    if (items.getItem('MSPA_Aktiv').state === 'OFF') {
        log('Connection check disabled.', 'debug');
        return;
    }

    const now = new Date();
    if (!lastConnectionErrorTime || (now.getTime() - lastConnectionErrorTime.getTime()) > 60 * 60 * 1000) {
        lastConnectionErrorTime = now;
        log(message, 'warn');

        // Send to recipients (please adjust)
        ['your-email@example.com', 'second-email@example.com'].forEach(userId => {
            actions.notificationBuilder(message)
                .withTitle("MSPA Connection Problem")
                .withTag("MSPA_Connection_Error")
                .withIcon("error")
                .withReferenceId(`mspa-connection-error-${Date.now()}`)
                .addUserId(userId)
                .send();
        });
    } else {
        log("Connection problem already reported, waiting.", 'debug');
    }
}

// New function to retrieve error messages from whirlpool
async function checkMSPAMessages() {
    try {
        const path = '/api/enduser/message/is_all_read';
        const queryParams = `?product_id=${MSPA_CONFIG.PRODUCT_ID}&device_id=${MSPA_CONFIG.DEVICE_ID}`;
        
        const headers = getMSPAHeaders('GET', path + queryParams);

        log('Checking MSPA messages...');
        const response = await actions.HTTP.sendHttpGetRequest(
            `${MSPA_CONFIG.API_BASE}${path}${queryParams}`,
            headers,
            30000
        );

        log(`Messages Response: ${response}`, 'debug');

        const messageData = JSON.parse(response);
        if (messageData.code === 0) {
            const data = messageData.data;
            if (data.warning > 0 || data.fault > 0 || data.notice > 0) {
                const message = `MSPA Messages:
                    ${data.warning} Warnings
                    ${data.fault} Errors
                    ${data.notice} Notices`;
                
                log(message, 'warn');
                
                // Send notification to recipients (please adjust)
                ['your-email@example.com', 'second-email@example.com'].forEach(userId => {
                    actions.notificationBuilder(message)
                        .withTitle("MSPA Error")
                        .withTag("MSPA_Error")
                        .withIcon("error")
                        .withReferenceId(`mspa-error-${Date.now()}`)
                        .addUserId(userId)
                        .send();
                });
            } else {
                log('No messages available.', 'debug');
            }
            return true;
        } else {
            const errorMessage = `Error retrieving messages: ${messageData.message}`;
            log(errorMessage, 'warn');
            return false;
        }
    } catch (error) {
        log(`Error checking MSPA messages: ${error}`, 'error');
        return false;
    }
}

// Rule for regular message checks
rules.JSRule({
    name: "MSPA_Message_Check",
    description: "Regularly checks for new MSPA messages",
    triggers: [triggers.GenericCronTrigger("0 */5 * * * ?")], // Every 5 minutes
    execute: async () => {
        if (items.getItem('MSPA_Aktiv').state === 'ON') {
            const loginSuccess = await login();
            if (loginSuccess) {
                await checkMSPAMessages();
            } else {
                log('Login failed, messages not retrieved.', 'warn');
            }
        }
    }
});

// Rule for regular status updates (every 1 minute)
rules.JSRule({
    name: "MSPA_Regular_Status_Updates",
    description: "Regularly retrieves the status of the MSPA device",
    triggers: [triggers.GenericCronTrigger("0 0/1 * * * ?")], // Every 1 minute
    execute: async () => {
        // Check if a command is being processed - if so, skip this run
        if (commandInProgress) {
            log('Skipping regular status query as command is in progress', 'debug');
            return;
        }
        
        if (items.getItem('MSPA_Aktiv').state === 'ON') {
            log('Starting regular status update...');
            // First login
            const loginSuccess = await login();
            if (loginSuccess) {
                const status = await getDeviceStatus();
                if (!status) {
                    sendConnectionErrorMessage("Status query failed!");
                }
            } else {
                log('Login failed, status not retrieved.', 'warn');
                sendConnectionErrorMessage("Login for status query failed!");
            }
        } else {
            log('Regular status update disabled.', 'debug');
        }
    }
});

// Rules for controlling MSPA items
rules.JSRule({
    name: "MSPA_Heater_Control",
    description: "Controls the MSPA heater",
    triggers: [triggers.ItemCommandTrigger("MSPA_Heater")],
    execute: async (event) => {
        if (event.receivedCommand === 'ON' || event.receivedCommand === 'OFF') {
            const loginSuccess = await login();
            if (loginSuccess) {
                await sendMSPACommand('MSPA_Heater', event.receivedCommand);
            } else {
                log('Login failed, command not sent.', 'warn');
            }
        }
    }
});

rules.JSRule({
    name: "MSPA_Filter_Control",
    description: "Controls the MSPA filter",
    triggers: [triggers.ItemCommandTrigger("MSPA_Filter")],
    execute: async (event) => {
        if (event.receivedCommand === 'ON' || event.receivedCommand === 'OFF') {
            const loginSuccess = await login();
            if (loginSuccess) {
                await sendMSPACommand('MSPA_Filter', event.receivedCommand);
            } else {
                log('Login failed, command not sent.', 'warn');
            }
        }
    }
});

rules.JSRule({
    name: "MSPA_Massage_Control",
    description: "Controls the MSPA massage",
    triggers: [
        triggers.ItemCommandTrigger("MSPA_Massage"),
        triggers.ItemCommandTrigger("MSPA_Massage_Level")
    ],
    execute: async (event) => {
        try {
            const loginSuccess = await login();
            if (!loginSuccess) {
                log('Login failed, command not sent.', 'warn');
                return;
            }

            // If massage level is changed 
            if (event.itemName === 'MSPA_Massage_Level') {
                const level = parseInt(event.receivedCommand);
                if (!isNaN(level)) {
                    if (items.MSPA_Massage.state === 'ON') {
                        await sendMSPACommand('MSPA_Massage', 'ON', level);
                    }
                }
            }
            // If massage is turned on/off
            else if (event.itemName === 'MSPA_Massage') {
                if (event.receivedCommand === 'ON') {
                    const level = parseInt(items.MSPA_Massage_Level.state) || 1;
                    await sendMSPACommand('MSPA_Massage', 'ON', level);
                } else if (event.receivedCommand === 'OFF') {
                    await sendMSPACommand('MSPA_Massage', 'OFF');
                }
            }
        } catch (e) {
            log(`Error in MSPA_Massage_Control: ${e}`, 'error');
        }
    }
});

rules.JSRule({
    name: "MSPA_UVC_Control",
    description: "Controls the MSPA UVC",
    triggers: [triggers.ItemCommandTrigger("MSPA_UVC")],
    execute: async (event) => {
        if (event.receivedCommand === 'ON' || event.receivedCommand === 'OFF') {
            const loginSuccess = await login();
            if (loginSuccess) {
                await sendMSPACommand('MSPA_UVC', event.receivedCommand);
            } else {
                log('Login failed, command not sent.', 'warn');
            }
        }
    }
});

rules.JSRule({
    name: "MSPA_Ozone_Control",
    description: "Controls the MSPA ozone",
    triggers: [triggers.ItemCommandTrigger("MSPA_Ozone")],
    execute: async (event) => {
        if (event.receivedCommand === 'ON' || event.receivedCommand === 'OFF') {
            const loginSuccess = await login();
            if (loginSuccess) {
                await sendMSPACommand('MSPA_Ozone', event.receivedCommand);
            } else {
                log('Login failed, command not sent.', 'warn');
            }
        }
    }
});

rules.JSRule({
    name: "MSPA_Lock_Control",
    description: "Controls the MSPA key lock",
    triggers: [triggers.ItemCommandTrigger("MSPA_Lock")],
    execute: async (event) => {
        if (event.receivedCommand === 'ON' || event.receivedCommand === 'OFF') {
            const loginSuccess = await login();
            if (loginSuccess) {
                await sendMSPACommand('MSPA_Lock', event.receivedCommand);
            } else {
                log('Login failed, command not sent.', 'warn');
            }
        }
    }
});

// Rule for setting target temperature
rules.JSRule({
    name: "MSPA_Target_Temperature_Control",
    description: "Controls the target temperature of the MSPA device",
    triggers: [triggers.ItemCommandTrigger("MSPA_Target_Temperature")],
    execute: async (event) => {
        const temperatureCelsius = parseFloat(event.receivedCommand);
        if (isNaN(temperatureCelsius)) {
            log(`Invalid target temperature: ${event.receivedCommand}`, 'warn');
            return;
        }
        const loginSuccess = await login();
        if (loginSuccess) {
            await sendMSPACommand('MSPA_Target_Temperature', temperatureCelsius);
        } else {
            log('Login failed, target temperature not set.', 'warn');
        }
    }
});

// Helper function to check if automation is active
function isMSPAAutomatikAktiv() {
    return items.getItem('MSPA_Aktiv').state === 'ON' && items.getItem('MSPA_Automatik').state === 'ON';
}

// Rule for day actions
rules.JSRule({
    name: "MSPA_Day_Automation",
    description: "Controls filter, UVC and ozone during the day and when in use",
    triggers: [
        triggers.ItemStateChangeTrigger('Tageszeiten', undefined, 'Tag'),
        triggers.ItemStateChangeTrigger('MSPA_inBenutzung', undefined, 'ON'),
        triggers.GenericCronTrigger('0 0 10 * * ?')  // Daily at 10:00 AM
    ],
    execute: async (event) => {
        if (!isMSPAAutomatikAktiv()) return;

        // Determine which trigger activated the rule
        const triggeredBy = event.itemName || 'cron';
        
        // Case 1: MSPA_inBenutzung was turned on
        if (triggeredBy === 'MSPA_inBenutzung') {
            log("MSPA in use: Activate filter and UVC, deactivate ozone", 'info');
            
            // Turn off ozone immediately (safety for humans)
            if (items.getItem('MSPA_Ozone').state === 'ON') {
                await sendMSPACommand('MSPA_Ozone', 'OFF');
                log("Ozone was turned off (MSPA in use)", 'info');
            }
            
            // Turn on filter if necessary
            if (items.getItem('MSPA_Filter').state !== 'ON') {
                await sendMSPACommand('MSPA_Filter', 'ON');
                log("Filter was turned on (MSPA in use)", 'info');
            }
            
            // Turn on UVC if necessary (with delay)
            if (items.getItem('MSPA_UVC').state !== 'ON') {
                setTimeout(async () => {
                    await sendMSPACommand('MSPA_UVC', 'ON');
                    log("UVC was turned on (MSPA in use)", 'info');
                }, 5000);
            }
        } 
        // Case 2: Time of day changes to day
        else if (triggeredBy === 'Tageszeiten') {
            log("MSPA Day Automation started", 'info');
            
            // Basic day actions
            await sendMSPACommand('MSPA_Filter', 'ON');
            setTimeout(async () => {
                await sendMSPACommand('MSPA_UVC', 'ON');
            }, 5000);
        } 
        // Case 3: 10:00 AM ozone check
        else if (triggeredBy === 'cron') {
            // Only turn on ozone if MSPA not in use and massage off
            if (items.getItem('MSPA_inBenutzung').state !== 'ON' && 
                items.getItem('MSPA_Massage').state !== 'ON') {
                
                log("10:00 AM ozone activation: MSPA not in use, activating ozone", 'info');
                await sendMSPACommand('MSPA_Ozone', 'ON');
            } else {
                log("10:00 AM ozone check: MSPA in use or massage active - no ozone", 'info');
            }
        }
    }
});

// Night automation for the whirlpool
rules.JSRule({
    name: "MSPA_Night_Automation",
    description: "Controls heating, UVC and filter during the night considering usage",
    triggers: [
        triggers.ItemStateChangeTrigger('Tageszeiten', undefined, 'Nacht'),
        triggers.GenericCronTrigger('0 0 19 * * ?'),  // Daily at 7:00 PM
        triggers.ItemStateChangeTrigger('MSPA_inBenutzung', 'ON', 'OFF')  // When usage ends
    ],
    execute: async (event) => {
        if (!isMSPAAutomatikAktiv()) return;

        const now = new Date();
        const currentHour = now.getHours();
        const isNightOrAfter7pm = items.Tageszeiten.state === 'Nacht' || currentHour >= 19;
        
        // Check which trigger activated the rule
        if (event.itemName === 'MSPA_inBenutzung') {
            // MSPA is no longer in use - only turn off if it's already night or after 7 PM
            if (!isNightOrAfter7pm) {
                log("MSPA Night Automation: Usage ended, but not yet night/7 PM - no action", 'info');
                return;
            }
            log("MSPA Night Automation: Usage ended, after 7 PM or night - turning off", 'info');
        } else {
            // Night started or 7 PM - check if in use
            if (items.MSPA_inBenutzung.state === 'ON') {
                log("MSPA Night Automation: Night/7 PM reached, but MSPA in use - no action", 'info');
                return;
            }
            log("MSPA Night Automation: Night/7 PM reached, MSPA not in use - turning off", 'info');
        }

        // From here the existing shutdown logic
        log("MSPA Night Automation: Starting shutdown sequence");
        
        // Check if ozone is on and turn it off
        if (items.getItem('MSPA_Ozone').state === 'ON') {
            await sendMSPACommand('MSPA_Ozone', 'OFF');
        }

        // First turn off the heating
        if (items.getItem('MSPA_Heater').state === 'ON') {
            await sendMSPACommand('MSPA_Heater', 'OFF');
            log("Heating was turned off (Night Automation)");
        }
        
        // Wait 5 seconds
        setTimeout(async () => {
            // Then UVC off
            await sendMSPACommand('MSPA_UVC', 'OFF');
            log("UVC was turned off (Night Automation)");
            
            // Wait another 5 seconds
            setTimeout(async () => {
                // Finally filter off
                await sendMSPACommand('MSPA_Filter', 'OFF');
                log("Filter was turned off (Night Automation)");
            }, 5000);
        }, 5000);
    }
});

// PV surplus heating control
rules.JSRule({
    name: "MSPA_Heating_Control",
    description: "Controls the heating based on PV surplus and battery",
    triggers: [triggers.GenericCronTrigger('0 */5 * * * ?')], // Every 5 minutes
    execute: async () => {
        if (!isMSPAAutomatikAktiv()) {
            log("Heating control: Automation not active", 'debug');
            return;
        }

        // Check if filter is active
        if (items.getItem('MSPA_Filter').state !== 'ON') {
            log("Heating control: Filter is not active", 'debug');
            return;
        }

        const now = new Date();
        const currentHour = now.getHours();
        // Convert values to numbers and check for validity
        const solarBalance = parseFloat(items.getItem('Solar_aktuelle_Bilanz').state);
        const batterySOCRaw = parseFloat(items.getItem('Powerinverter_Inverter_SOC_Panel').state);
        // Convert batterySOC from 0-1 to 0-100
        const batterySOC = batterySOCRaw * 100;
        const currentHeaterState = items.getItem('MSPA_Heater').state === 'ON';

        // Debug Logging
        log(`Heating control status:
            - Time: ${currentHour}:${now.getMinutes()}
            - Solar Balance: ${solarBalance}
            - Battery SOC (percent): ${batterySOC}%
            - Heating currently: ${currentHeaterState ? 'ON' : 'OFF'}`, 'debug');

        if (isNaN(solarBalance) || isNaN(batterySOC)) {
            log("Heating control: Invalid values for solar or battery", 'warn');
            return;
        }

        if (solarBalance > 2000) {
            // PV surplus detected (for turning on)
            if (solarSurplusTimer) {
                clearTimeout(solarSurplusTimer);
                solarSurplusTimer = null;
                log("Heating control: Timer deleted due to PV surplus", 'debug');
            }

            let canHeat = false;
            let reason = "";

            if (currentHour < 10) {
                canHeat = true;
                reason = "Before 10 AM";
            } else if (currentHour < 14 && batterySOC > 50) {
                canHeat = true;
                reason = "Before 2 PM and battery > 50%";
            } else if (currentHour < 18 && batterySOC > 80) {
                canHeat = true;
                reason = "Before 6 PM and battery > 80%";
            } else if (batterySOC > 92) {
                canHeat = true;
                reason = "Battery > 92%";
            }

            if (canHeat && !currentHeaterState) {
                log(`Heating control: Turning heating ON (${reason})`, 'info');
                await sendMSPACommand('MSPA_Heater', 'ON');
            } else {
                log(`Heating control: Heating remains ${currentHeaterState ? 'ON' : 'OFF'} (${reason})`, 'debug');
            }
        } else {
            // No PV surplus (for turning off)
            log(`Heating control: PV surplus too low (${solarBalance})`, 'debug');
            
            if (currentHeaterState && !solarSurplusTimer) {
                log("Heating control: Starting shutdown timer", 'info');
                // Start timer only if heating is on
                solarSurplusTimer = setTimeout(async () => {
                    const currentSolarBalance = parseFloat(items.getItem('Solar_aktuelle_Bilanz').state);
                    log(`Heating control: Timer check - Solar Balance: ${currentSolarBalance}`, 'debug');
                    
                    if (currentSolarBalance < 100) {
                        log("Heating control: Turning heating OFF (persistently low PV surplus)", 'info');
                        await sendMSPACommand('MSPA_Heater', 'OFF');
                    } else {
                        log("Heating control: Heating remains ON (PV surplus has recovered)", 'info');
                    }
                    solarSurplusTimer = null;
                }, 5 * 60 * 1000); // 5 minutes
            }
        }
    }
});

// Button control for MSPA_inBenutzung
rules.JSRule({
    name: "MSPA_inBenutzung_Button_Control",
    description: "Controls MSPA_inBenutzung via the pool blind button",
    triggers: [triggers.ItemStateUpdateTrigger("Taster_Pool_Rollo_Action")],
    execute: (event) => {
        // Check if basic conditions are met
        if (items.Nuki_Terrassentuer_State.state != 3 || items.MSPA_Aktiv.state != 'ON') {
            log("MSPA_inBenutzung Button: Conditions not met", 'debug');
            return;
        }

        // On short press (single) turn on
        if (event.receivedState.toString() === "single") {
            log("MSPA_inBenutzung Button: Single-Press detected, turning on", 'info');
            items.MSPA_inBenutzung.sendCommand('ON');
        }
        // On long press (long) turn off
        else if (event.receivedState.toString() === "long") {
            log("MSPA_inBenutzung Button: Long-Press detected, turning off", 'info');
            items.MSPA_inBenutzung.sendCommand('OFF');
        }
    }
});

// Auto-off for MSPA_inBenutzung
rules.JSRule({
    name: "MSPA_inBenutzung_Auto_Off",
    description: "Turns off MSPA_inBenutzung at night, when MSPA_Aktiv=OFF and when nobody is home",
    triggers: [
        triggers.GenericCronTrigger("0 30 2 * * ?"),  // Daily at 2:30 AM
        triggers.ItemStateChangeTrigger('MSPA_Aktiv', undefined, 'OFF'),
        triggers.ItemStateChangeTrigger('Alle_ausser_Haus', undefined, 'ON')
    ],
    execute: (event) => {
        // Check which trigger activated the rule
        if (event.itemName === 'MSPA_Aktiv') {
            log("MSPA_Aktiv was turned off - also deactivate inBenutzung", 'info');
        } else if (event.itemName === 'Alle_ausser_Haus') {
            log("Nobody home - deactivate MSPA_inBenutzung", 'info');
        } else {
            log("Nightly timer: Turning off MSPA_inBenutzung", 'info');
        }

        // Only turn off if it's turned on
        if (items.MSPA_inBenutzung.state === 'ON') {
            items.MSPA_inBenutzung.sendCommand('OFF');
        }
    }
});

// Button control for MSPA_Massage
rules.JSRule({
    name: "MSPA_Massage_Button_Control",
    description: "Controls MSPA_Massage and MSPA_inBenutzung via the pool massage button",
    triggers: [triggers.ItemStateUpdateTrigger("Taster_Pool_Massage_Action")],
    execute: (event) => {
        // Check if basic conditions are met
        if (items.Nuki_Terrassentuer_State.state != 3 || items.MSPA_Aktiv.state != 'ON') {
            log("MSPA_Massage Button: Conditions not met", 'debug');
            return;
        }

        // On short press (single)
        if (event.receivedState.toString() === "single") {
            log("MSPA_Massage Button: Single-Press detected", 'info');
            
            // Case 1: MSPA_Massage is already turned on - change level
            if (items.MSPA_Massage.state === 'ON') {
                // Determine current level and increase
                const currentLevel = parseInt(items.MSPA_Massage_Level.state) || 1;
                // Calculate new level (1->2, 2->3, 3->1)
                const newLevel = currentLevel >= 3 ? 1 : currentLevel + 1;
                
                log(`MSPA_Massage Button: Changing massage level from ${currentLevel} to ${newLevel}`, 'info');
                items.MSPA_Massage_Level.sendCommand(newLevel.toString());
            } 
            // Case 2: MSPA_Massage is off - turn on
            else {
                log("MSPA_Massage Button: Turning massage on", 'info');
                items.MSPA_Massage.sendCommand('ON');
                
                // Turn on MSPA_inBenutzung if off
                if (items.MSPA_inBenutzung.state !== 'ON') {
                    log("MSPA_Massage Button: Turning inBenutzung on", 'info');
                    items.MSPA_inBenutzung.sendCommand('ON');
                }
            }
        }
        // On long press (long): Only turn off massage
        else if (event.receivedState.toString() === "long") {
            log("MSPA_Massage Button: Long-Press detected", 'info');
            
            // Turn off MSPA_Massage if on
            if (items.MSPA_Massage.state === 'ON') {
                log("MSPA_Massage Button: Turning massage off", 'info');
                items.MSPA_Massage.sendCommand('OFF');
            }
        }
    }
});

// Loading message
rules.JSRule({
    name: "whirlpool Module Load",
    description: "Shows the loading of the whirlpool module",
    triggers: [
        triggers.ItemStateChangeTrigger('Openhab_Online', undefined, 'ON'),
        triggers.ItemStateChangeTrigger('Rule_Reload', undefined, 'ON')
    ],
    execute: () => {
        log("whirlpool.js loaded", 'info');
    }
});

Required Items

// MSPA Whirlpool Items

// Control items
Switch MSPA_Aktiv                "Whirlpool Active"                <switch>        {expire="1h,command=OFF"}
Switch MSPA_Automatik            "Whirlpool Automation"           <switch>
Switch MSPA_Heater               "Whirlpool Heater"               <heating>
Switch MSPA_Filter               "Whirlpool Filter"               <fan>
Switch MSPA_Massage              "Whirlpool Massage"              <fan>
Number MSPA_Massage_Level        "Whirlpool Massage Level [%d]"   <level>
Switch MSPA_UVC                  "Whirlpool UVC"                  <light>
Switch MSPA_Ozone                "Whirlpool Ozone"                <flow>
Switch MSPA_Lock                 "Whirlpool Key Lock"             <lock>
Switch MSPA_inBenutzung          "Whirlpool in Use"               <presence>      {expire="2h,command=OFF"}

// Temperature items
Number MSPA_Current_Temperature  "Whirlpool Temperature [%.1f °C]" <temperature>
Number MSPA_Target_Temperature   "Whirlpool Target Temperature [%.1f °C]" <temperature>

// Items needed for PV control
Number Solar_aktuelle_Bilanz     "Current Solar Balance [%.0f W]"  <energy>
Number Powerinverter_Inverter_SOC_Panel "Battery SOC [%.1f %%]"    <battery>

// For day/night control
String Tageszeiten               "Time of Day [%s]"               <time>

// For automatic shutdown
Switch Alle_ausser_Haus          "All Away"                       <presence>

// For physical buttons at the pool
String Taster_Pool_Rollo_Action  "Pool Blind Button Action"       <switch>
String Taster_Pool_Massage_Action "Pool Massage Button Action"    <switch>

// Terrace door status (for button safety)
Number Nuki_Terrassentuer_State  "Terrace Door Status"            <door>

// Logging control
Switch Advanced_Logging          "Advanced Logging"               <settings>

// Trigger for rule reloads
Switch Rule_Reload               "Reload Rules"                   <reload>
Switch Openhab_Online            "OpenHAB Online"                 <network>

Main UI Widget

uid: whirlpool_v2
tags: []
props:
  parameters:
    - description: web address of background image
      label: Background image
      name: image
      required: false
      type: TEXT
    - default: black
      description: HEX, rgba or name, e.g. white or black
      label: Background color
      name: bgcolor
      required: true
      type: TEXT
    - default: "0.6"
      description: decimal, e.g. 0.6
      label: Background opacity
      name: bgopacity
      required: true
      type: TEXT
    - default: white
      description: icon & text color, e.g. black or white
      label: Icon & text color
      name: color
      required: true
      type: TEXT
    - context: item
      description: Item for water temperature
      label: Water Temperature Item
      name: water_temperature_item
      required: true
      type: TEXT
    - context: item
      description: Item for heater control
      label: Heater Control Item
      name: heater_control_item
      required: true
      type: TEXT
    - context: item
      description: Item for massage control
      label: Massage Control Item
      name: massage_control_item
      required: true
      type: TEXT
    - context: item
      description: Item for filter control
      label: Filter Control Item
      name: filter_control_item
      required: true
      type: TEXT
    - context: item
      description: Item for UVC control
      label: UVC Control Item
      name: uvc_control_item
      required: true
      type: TEXT
    - context: item
      description: Item for Ozone control
      label: Ozone Control Item
      name: ozone_control_item
      required: true
      type: TEXT
    - context: item
      description: Item for Lock control
      label: Lock Control Item
      name: lock_control_item
      required: true
      type: TEXT
    - context: item
      description: Item for Target Temperature
      label: Target Temperature Item
      name: target_temperature_item
      required: true
      type: TEXT
    - context: item
      description: Item to hide the widget
      label: Hide Widget Item
      name: hide_widget_item
      required: false
      type: TEXT
    - context: item
      description: Item for Massage Level
      label: Massage Level Item
      name: massage_level_item
      required: true
      type: TEXT
    - context: item
      description: Item for Whirlpool In Use
      label: Whirlpool In Use Item
      name: in_use_item
      required: true
      type: TEXT
timestamp: Apr 20, 2025, 9:52:16 PM
component: f7-card
config:
  noBorder: true
  noShadow: true
  style:
    --f7-button-bg-color: rgba(255, 255, 255, 0.1)
    --f7-button-hover-bg-color: rgba(255, 255, 255, 0.2)
    --f7-button-pressed-bg-color: rgba(255, 255, 255, 0.3)
    --f7-card-bg-color: transparent
    background-image: "=props.image ? 'url(' + props.image + ')' : ''"
    background-position: center
    background-size: cover
    border-radius: var(--f7-card-expandable-border-radius)
    height: 300px
    margin-left: 5px
    margin-right: 5px
  visible: "=(props.hide_widget_item) ? items[props.hide_widget_item].state !==
    'OFF' : true"
slots:
  content:
    - component: f7-chip
      config:
        style:
          background: =props.bgcolor
          border-radius: 7px
          bottom: -15px
          color: =props.color
          font-size: 16px
          height: 36px
          left: 0px
          opacity: =props.bgopacity
          padding: 10px
          position: absolute
          text-align: center
          width: auto
        text: =@props.water_temperature_item
    - component: f7-block
      config:
        style:
          align-items: center
          background: =props.bgcolor
          border-radius: 7px
          bottom: -60px
          display: flex
          justify-content: center
          left: 16px
          opacity: =props.bgopacity
          padding: 3px
          position: absolute
          width: 35px
        visible: =items[props.in_use_item].state === 'ON'
      slots:
        default:
          - component: oh-button
            config:
              action: command
              actionCommand: "=items[props.in_use_item].state === 'ON' ? 'OFF' : 'ON'"
              actionItem: =props.in_use_item
              style:
                --f7-button-bg-color: "=(items[props.in_use_item].state === 'ON') ?
                  'rgba(255,100,0,0.5)' : 'rgba(255,255,255,0.1)'"
                align-items: center
                color: =props.color
                display: flex
                height: 35px
                justify-content: center
                margin: 0
                padding: 0
                width: 35px
            slots:
              default:
                - component: oh-icon
                  config:
                    color: =props.color
                    height: 24
                    icon: material:sensor_occupied
                    width: 24
    - component: f7-block
      config:
        style:
          align-items: center
          background: =props.bgcolor
          border-radius: 7px
          bottom: -105px
          display: flex
          justify-content: space-between
          left: 16px
          opacity: =props.bgopacity
          padding: 3px
          position: absolute
          width: 33%
        visible: =items[props.massage_control_item].state === 'ON'
      slots:
        default:
          - component: oh-slider
            config:
              item: =props.massage_level_item
              label: true
              max: 3
              min: 1
              releaseOnly: true
              scale: false
              step: 1
              style:
                --f7-range-bar-active-bg-color: rgba(255,100,0,0.5)
                --f7-range-bar-bg-color: rgba(255,255,255,0.2)
                --f7-range-bar-border-radius: var(--f7-card-expandable-border-radius)
                --f7-range-bar-size: 35px
                --f7-range-knob-color: white
                --f7-range-knob-size: 24px
                height: 35px
                width: 65%
          - component: f7-chip
            config:
              style:
                color: =props.color
                font-size: 16px
                text-align: center
                width: 30%
              text: =@props.massage_level_item
    - component: f7-block
      config:
        style:
          align-items: center
          background: =props.bgcolor
          border-radius: 7px
          bottom: -150px
          display: flex
          justify-content: space-around
          left: 16px
          opacity: =props.bgopacity
          padding: 3px
          position: absolute
          right: 16px
      slots:
        default:
          - component: oh-button
            config:
              action: command
              actionCommand: "=items[props.massage_control_item].state === 'ON' ? 'OFF' :
                'ON'"
              actionItem: =props.massage_control_item
              style:
                --f7-button-bg-color: "=(items[props.massage_control_item].state === 'ON') ?
                  'rgba(255,100,0,0.5)' : 'rgba(255,255,255,0.1)'"
                align-items: center
                color: =props.color
                display: flex
                height: 35px
                justify-content: center
                margin: 0
                padding: 0
                width: 35px
            slots:
              default:
                - component: oh-icon
                  config:
                    color: =props.color
                    height: 24
                    icon: material:bubble_chart
                    width: 24
          - component: oh-button
            config:
              action: command
              actionCommand: "=items[props.heater_control_item].state === 'ON' ? 'OFF' : 'ON'"
              actionItem: =props.heater_control_item
              style:
                --f7-button-bg-color: "=(items[props.heater_control_item].state === 'ON') ?
                  'rgba(255,100,0,0.5)' : 'rgba(255,255,255,0.1)'"
                align-items: center
                color: =props.color
                display: flex
                height: 35px
                justify-content: center
                margin: 0
                padding: 0
                width: 35px
            slots:
              default:
                - component: oh-icon
                  config:
                    color: =props.color
                    height: 24
                    icon: material:local_fire_department
                    width: 24
          - component: oh-button
            config:
              action: command
              actionCommand: "=items[props.uvc_control_item].state === 'ON' ? 'OFF' : 'ON'"
              actionItem: =props.uvc_control_item
              style:
                --f7-button-bg-color: "=(items[props.uvc_control_item].state === 'ON') ?
                  'rgba(255,100,0,0.5)' : 'rgba(255,255,255,0.1)'"
                align-items: center
                color: =props.color
                display: flex
                height: 35px
                justify-content: center
                margin: 0
                padding: 0
                width: 35px
            slots:
              default:
                - component: oh-icon
                  config:
                    color: =props.color
                    height: 24
                    icon: material:sunny
                    width: 24
    - component: f7-block
      config:
        style:
          align-items: center
          background: =props.bgcolor
          border-radius: 7px
          bottom: -195px
          display: flex
          justify-content: space-around
          left: 16px
          opacity: =props.bgopacity
          padding: 3px
          position: absolute
          right: 16px
      slots:
        default:
          - component: oh-button
            config:
              action: command
              actionCommand: "=items[props.ozone_control_item].state === 'ON' ? 'OFF' : 'ON'"
              actionItem: =props.ozone_control_item
              style:
                --f7-button-bg-color: "=(items[props.ozone_control_item].state === 'ON') ?
                  'rgba(255,100,0,0.5)' : 'rgba(255,255,255,0.1)'"
                align-items: center
                color: =props.color
                display: flex
                height: 35px
                justify-content: center
                margin: 0
                padding: 0
                width: 35px
            slots:
              default:
                - component: Label
                  config:
                    style:
                      color: =props.color
                      font-size: 16px
                      height: 24px
                      line-height: 24px
                      text-align: center
                      width: 24px
                    text: O₃
          - component: oh-button
            config:
              action: command
              actionCommand: "=items[props.lock_control_item].state === 'ON' ? 'OFF' : 'ON'"
              actionItem: =props.lock_control_item
              style:
                --f7-button-bg-color: "=(items[props.lock_control_item].state === 'ON') ?
                  'rgba(255,100,0,0.5)' : 'rgba(255,255,255,0.1)'"
                align-items: center
                color: =props.color
                display: flex
                height: 35px
                justify-content: center
                margin: 0
                padding: 0
                width: 35px
            slots:
              default:
                - component: oh-icon
                  config:
                    color: =props.color
                    height: 24
                    icon: material:lock
                    width: 24
          - component: oh-button
            config:
              action: command
              actionCommand: "=items[props.filter_control_item].state === 'ON' ? 'OFF' : 'ON'"
              actionItem: =props.filter_control_item
              style:
                --f7-button-bg-color: "=(items[props.filter_control_item].state === 'ON') ?
                  'rgba(255,100,0,0.5)' : 'rgba(255,255,255,0.1)'"
                align-items: center
                color: =props.color
                display: flex
                height: 35px
                justify-content: center
                margin: 0
                padding: 0
                width: 35px
              visible: =(items[props.heater_control_item].state === 'OFF' &&
                items[props.massage_control_item].state === 'OFF' &&
                items[props.uvc_control_item].state === 'OFF' &&
                items[props.ozone_control_item].state === 'OFF')
            slots:
              default:
                - component: oh-icon
                  config:
                    color: =props.color
                    height: 24
                    icon: material:water
                    width: 24
          - component: oh-button
            config:
              style:
                --f7-button-bg-color: "=(items[props.filter_control_item].state === 'ON') ?
                  'rgba(255,100,0,0.5)' : 'rgba(255,255,255,0.1)'"
                align-items: center
                color: =props.color
                display: flex
                height: 35px
                justify-content: center
                margin: 0
                padding: 0
                width: 35px
              visible: =!(items[props.heater_control_item].state === 'OFF' &&
                items[props.massage_control_item].state === 'OFF' &&
                items[props.uvc_control_item].state === 'OFF' &&
                items[props.ozone_control_item].state === 'OFF')
            slots:
              default:
                - component: oh-icon
                  config:
                    color: =props.color
                    height: 24
                    icon: material:water
                    width: 24
    - component: f7-block
      config:
        style:
          align-items: center
          background: =props.bgcolor
          border-radius: 7px
          bottom: -240px
          display: flex
          justify-content: space-between
          left: 16px
          opacity: =props.bgopacity
          padding: 3px
          position: absolute
          right: 16px
      slots:
        default:
          - component: oh-slider
            config:
              item: =props.target_temperature_item
              label: true
              max: 40
              min: 20
              releaseOnly: true
              scale: false
              step: 0.5
              style:
                --f7-range-bar-active-bg-color: rgba(255,100,0,0.5)
                --f7-range-bar-bg-color: rgba(255,255,255,0.2)
                --f7-range-bar-border-radius: var(--f7-card-expandable-border-radius)
                --f7-range-bar-size: 35px
                --f7-range-knob-color: white
                --f7-range-knob-size: 24px
                height: 35px
                width: 65%
          - component: f7-chip
            config:
              style:
                color: =props.color
                font-size: 16px
                text-align: center
                width: 30%
              text: =@props.target_temperature_item

I hope this integration is useful for other MSPA owners who want to integrate their whirlpool into openHAB. If you have any questions or customization requests, I’d be happy to help!

1 Like

Very happy to see this integration! We are looking forward to buy one and it would be great to have a solution where PV can steer e.g the pool heating.

Looking into the code there are some obvious configs like ACCOUNT and PASSWORD_HASH.
But DEVICE_ID and PRODUCT_ID maybe somewhere visible in the app?
And in your comment REGISTRATION_ID needs to be determined by sniffing the app communication?

Hi Bernd,
so, step by step – first, you download the MSPA app as usual, create an account with a password and an email address.
Then, at the bottom of the app, you can already see the DEVICE_ID and PRODUCT_ID. The account is the email, and for the PASSWORD_HASH it gets a bit trickier – I converted the entered password into a hash using AI.
For the REGISTRATION_ID, I installed an app on the iPhone called HTTP Catcher and purchased an SSL certificate within the app for €3.99.
I activated recording in the HTTP Catcher app, switched back to the MSPA app, sent a few commands, and was able to decrypt all the necessary information.
I can prepare a more detailed guide for you if you’d like – I’m just on the road right now, hence the short version.
Best regards,
Andreas

Hi Andreas,

I tried your integration of the MSPA Whirlpool with my openHab instance. I’m a big fan of such integrations :blush: Thanks for your effort :blue_heart:

I followed all the steps in your Post, including the “HTTP Catcher” / Proxy / VPN interception steps. So I got all the information I needed including password hashes, registration IDs etc.

The rules have all been added to my openHab instance and seem to work.

BUT: I always get a “code: 403, Message: Signature verification failed” response from the MSPA Api endpoint.

Here is the complete log entry from your code (I removed all my details from the logs):

2025-05-01 20:21:00.257 [INFO ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Starting regular status update...
2025-05-01 20:21:00.277 [INFO ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Login Request Body: {"password":"MY_PASSWORD_HASH","account":"MY_MAIL","registration_id":"MY_REGISTRATION_ID","country":"DE","app_id":"e1c8e068f9ca11eba4dc0242ac120002","push_type":"ios"}
2025-05-01 20:21:00.307 [INFO ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Sign String Components:
        - APP_ID: e1c8e068f9ca11eba4dc0242ac120002
        - Nonce: XM0LQOT4WJCR41UGSX7BM2MQSXPYHSZJ
        - Timestamp: 1746123660
        - Method: POST
        - Path: /api/enduser/get_token/
        - Body: {"password":"MY_PASSWORD_HASH","account":"MY_MAIL","registration_id":"MY_REGISTRATION_ID","country":"DE","app_id":"e1c8e068f9ca11eba4dc0242ac120002","push_type":"ios"}
        - Token: 
2025-05-01 20:21:00.310 [INFO ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Complete Sign String: e1c8e068f9ca11eba4dc0242ac120002XM0LQOT4WJCR41UGSX7BM2MQSXPYHSZJ1746123660POST/api/enduser/get_token/{"password":"MY_PASSWORD_HASH","account":"MY_MAIL","registration_id":"MY_REGISTRATION_ID","country":"DE","app_id":"e1c8e068f9ca11eba4dc0242ac120002","push_type":"ios"}
2025-05-01 20:21:00.335 [INFO ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Generated Sign: 6FE0EBDABAC9EDC35BF206DA2FD4E7E8
2025-05-01 20:21:00.338 [INFO ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Generated Headers: {
  "Host": "api.iot.the-mspa.com",
  "appid": "e1c8e068f9ca11eba4dc0242ac120002",
  "Content-Type": "application/json; charset=utf-8",
  "lan_code": "de",
  "User-Agent": "DongHui/7 CFNetwork/3826.500.111.2.2 Darwin/24.4.0",
  "Connection": "keep-alive",
  "ts": "1746123660",
  "nonce": "XM0LQOT4WJCR41UGSX7BM2MQSXPYHSZJ",
  "sign": "6FE0EBDABAC9EDC35BF206DA2FD4E7E8"
}
2025-05-01 20:21:00.340 [INFO ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Sending Login Request...
2025-05-01 20:21:01.151 [INFO ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Login Response: {"code":403,"message":"Signature verification failed","request_id":"090d72a026b911f088b50242ac480009"}
2025-05-01 20:21:01.154 [WARN ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Login failed with code: 403, Message: Signature verification failed
2025-05-01 20:21:01.157 [WARN ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Login failed, status not retrieved.
2025-05-01 20:21:01.170 [WARN ] [nhab.automation.script.ui.a53f31eefa] - [MSPA] Login for status query failed!

First I thought it could be an timestamp issue… (my raspberry ran for 9month+ and was 30mins off) but the error stayed after I corrected the timestamp.

Could you post some details on how the signature is setup? so I could debug it a bit more deeply

Thanks You :slight_smile:

Adrian

Hi Adrian,

Thank you for trying out the MSPA integration! I’ve looked into your 403 “Signature verification failed” error, and I think I’ve identified the problem.

After experiencing similar issues myself, I discovered that the dynamic header generation doesn’t work reliably with the MSPA API. The solution that works for me is using hardcoded values for the timestamp, nonce, and signature.

Please modify your getMSPAHeaders function as follows:

function getMSPAHeaders(method, path, token = '', body = '') {

    let headers = {
        'Host': 'api.iot.the-mspa.com',
        'appid': 'e1c8e068f9ca11eba4dc0242ac120002',
        'Content-Type': 'application/json; charset=utf-8',
        'lan_code': 'de',
        'User-Agent': 'DongHui/7 CFNetwork/3826.500.111.2.2 Darwin/24.4.0',
        'Connection': 'keep-alive',
        'ts': '1744577815',               // IMPORTANT: Hardcoded timestamp
        'nonce': 'ABC123XYZ456DEF789GHI', // IMPORTANT: Replace with your captured nonce
        'sign': '1A2B3C4D5E6F7G8H9I0J',   // IMPORTANT: Replace with your captured sign
    };

    if (currentToken) {
        headers['Authorization'] = `token ${currentToken}`;
    }

    return headers;
}

I had to figure this out through trial and error. After thoroughly analyzing the communication, I discovered that the MSPA API has some undocumented quirks in its authentication mechanism. While the dynamic generation of these values should work theoretically, in practice it appears that:

  1. The timestamp verification is inconsistent
  2. The signature algorithm might have some undocumented requirements (or I’m simply not able to rebuild it :wink: )
  3. Using hardcoded values bypasses these issues

Important note: I’ve found that this approach works flawlessly as long as you don’t exceed about 3 API requests per second. The MSPA cloud service seems to have rate limiting in place.

Let me know if this resolves your issue!

Best regards
Andreas

1 Like

Hi Adrian,

I just wanted to follow up with an important point about the App-ID.

Even though I can see in your logs that you’re using the same App-ID as me (e1c8e068f9ca11eba4dc0242ac120002), there’s a possibility that each MSPA model or region might require a different App-ID.

When you captured your HTTP requests with HTTP Catcher, I’d recommend verifying:

  1. The App-ID in your own MSPA app’s successful requests - don’t just rely on what’s in my code
  2. The model of your MSPA spa - different models might use different IDs
  3. Your app version - newer versions might use different IDs than mine
  4. Your region settings - the German region might use a different ID than other countries

Let me know if this helps!

Cheers, Andreas

1 Like

Hi Andreas,

Thank you for the help :slight_smile: it worked!

I will monitor the integration for a while, whether it will authorize with the hardcoded timestamp.

The other IDs (like the App-ID and region settings) are the same for me, since I am located in germany as well :wink:

But for now, I have rewritten the PV integration for my Fronius Inverter and it is working like a charm :wink:

Thanks and have a nice weekend.

Adrian

Hi Andreas,

Another thing, that I tried is to use the /api/enduser/visitor endpoint. With this setup, one can use the app in parallel with the openHab integration.

The visitor endpoint can also be analyzed using the HTTP Catcher setup, while logging in on another device and scanning the QR Code under Device → Share whirlpool. With the HTTP Catcher sniffing on the device that scans the QR Code. You will need the visitor_id instead of password_hash and account.

The endpoint uses a little different request body.

Here are the corresponding code snippets:

async function login() {
    try {
        const path = '/api/enduser/visitor/';
        const body = JSON.stringify({
            visitor_id: MSPA_CONFIG.VISITOR_ID,
            registration_id: MSPA_CONFIG.REGISTRATION_ID,
            app_id: MSPA_CONFIG.APP_ID,  
            push_type: MSPA_CONFIG.PUSH_TYPE,
            lan_code: 'de',
        });

        log(`Login Request Body: ${body}`, 'debug');

        // ... the rest stays the same

Now, I can use the App on my phone while the integration and PV works as well.

Thanks a lot :slight_smile:

Adrian

Hi Adrian,

Great to hear it worked – thanks for the feedback!
I’ve actually had it running like this since the beginning of the week without any issues so far.
Curious to hear what exactly you did for your Fronius PV integration – did you write something custom?

Enjoy your weekend as well!

Best regards,
Andreas

Hi Andreas,

despite the implementation using the visitor_id the Fronius PV system uses different types of channels/items. This means I needed to calculate my PV surplus differently in the script:

.
.
.
const solarYield = parseFloat(items.getItem('Fronius_Symo_Inverter_Current_Solar_Yield').state);
const consumption = parseFloat(items.getItem('Fronius_Symo_Inverter_Load_Power').state);
const solarBalance = solarYield + consumption; // yield is positive, consumption is negative
const batterySOC = parseFloat(items.getItem('Fronius_Symo_Inverter_State_of_Charge_Inverter_1').state); // already between 0 and 100
.
.
.

Other than that I left everything in the code untouched… it is working since 5 days without an issue.

thanks :slight_smile:
Adrian

1 Like

Pool is delivered, it’s build up, it’s online and I took my first bath :slight_smile:

Here my findings:

Signature
The sign header is build up with 4 comma separated values in the order app_id,app_secret,nonce,timestamp

  1. app_id: you have it already e1c8e068f9ca11eba4dc0242ac120002 - hard coded
  2. app_secret: it’s 87025c9ecd18906d27225fe79cb68349 - hard coded
  3. nonce - calculated per request
  4. timestamp ts - calculated per request

I reccored the following sequence with timestamp and nonce and you need md5 upper case ! Try to get the same sign in this recording and you should be fine!
network-query.txt (396 Bytes)

Users without VPN Decoder
For me it works that users without VPN Decoder can execute this solution.

  • PASSWORD_HASH is md5 lower case
  • REGISTRATION_ID can be left empty to receive token
  • DEVICE_ID and PRODUCT_ID can be queried wia /api/enduser/devices/ endpoint

See my binding code at openhab-addons/bundles/org.openhab.binding.mspa at mspa · weymann/openhab-addons · GitHub and you can download the jar at https://github.com/weymann/MSpa-OH-Drops/raw/refs/heads/main/drops/org.openhab.binding.mspa-4.3.6-SNAPSHOT.jar

2 Likes

Hi Bernd,

that sounds awesome. I am currently on a business trip but will test your binding directly after I return home.

Thanks
Adrian

Yes, please give me some feedback. Currently the binding is stealing the token as you mentioned in earlier posts so parallel usage of MSPA-Link App and binding isn’t possible. Let’s elaborate together if we can find a solution with your visitor approach. Would be really nice to have openHAB and Smartphone App running together without conflicts.

I’m not sure if you mean the same thing by “visitor”… but maybe this will help:

You have to initially create an MSPA account and link the pool to it — this becomes the owner. However, you can also create additional accounts (I currently have three in total), and the owner can grant pool access to these accounts via the app.

From what I’ve tested, both types of accounts — owner and user — work with the code, so you can use both in parallel: OpenHAB and the MSPA app.

Since I’ve found a way to indirectly query errors (e.g., F1) — I haven’t managed to do it directly yet — I no longer use the MSPA app at all.

Hi :slight_smile:

I habe added my smartphone as visitor (screenshot of QR → scan it from the gallery) … i logged out from my account and logged in as visitor (Besucher) on my smartphone.

With this approach you can use as many (i guess) visitors on different smartphones as you like and let openHAB handle the main account - it is working :wink:

Nevertheless, it would be a good idea to reverse this behavior, so that the main account can stay on the smartphone and openHAB uses the visitor account.

The QR Code has the following information (in my case):
MX_Dong-hui_{???}_{DEVICE_ID}

The ??? is a code I don’t know and was a lowercase ID or secret of some kind.

Thanks
Adrian

Hi, okay, then we’re not talking about the same approach. In my approach, you just need a second email address and register it as an additional account.

Adding the pool to the second account works exactly as you described.

Both accounts can then control the pool at the same time — one via OpenHAB and the other via the app — and it doesn’t matter which one you use for what.

After playing around with the visitor endpoint I really like this approach for openHAB integration. You can generate your own visitorId (16 characters md5) and simply request a token - no email, no password!
For the grantCode I scanned the QR code with a 3rd party QR scanner and the text key for granting can be copied into the bridge configuration.

Now I’ve my real account running on the phone and the visitor account running in OH without any disturbance.