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!