homebridge-melcloud-control
Version:
Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.
459 lines (385 loc) • 18.5 kB
HTML
<div class="container mt-3">
<div class="text-center">
<img src="homebridge-melcloud-control.png" alt="Image" height="120" />
</div>
<div id="accountManager" class="card card-body mt-2">
<form id="configForm">
<div class="text-center">
<label id="accountName" class="fw-bold" style="font-size: 23px;">Account</label><br>
<label id="info" class="d-block" style="font-size: 15px;"></label>
<label id="info1" class="d-block" style="font-size: 13px;"></label>
<label id="info2" class="d-block" style="font-size: 13px;"></label>
</div>
<div class="mb-2">
<label for="name" class="form-label">Name</label>
<input id="name" type="text" class="form-control" required>
</div>
<div class="mb-2">
<label for="user" class="form-label">User Name</label>
<input id="user" type="text" class="form-control" required>
</div>
<div class="mb-2 position-relative">
<label for="passwd" class="form-label">Password</label>
<div class="input-group">
<input id="passwd" type="password" class="form-control" autocomplete="off" required>
<button type="button" id="togglePasswd" class="btn btn-outline-secondary">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="mb-2">
<label for="language" class="form-label">Language</label>
<select id="language" class="form-control">
<option value="0">English</option>
<option value="1">Български</option>
<option value="2">Čeština</option>
<option value="3">Dansk</option>
<option value="4">Deutsch</option>
<option value="5">Eesti</option>
<option value="6">Español</option>
<option value="7">Français</option>
<option value="8">Հայերեն</option>
<option value="9">Latviešu</option>
<option value="10">Lietuvių</option>
<option value="11">Magyar</option>
<option value="12">Nederlands</option>
<option value="13">Norwegian</option>
<option value="14">Polski</option>
<option value="15">Português</option>
<option value="16">Русский</option>
<option value="17">Suomi</option>
<option value="18">Svenska</option>
<option value="19">Українська</option>
<option value="20">Türkçe</option>
<option value="21">Ελληνικά</option>
<option value="22">Hrvatski</option>
<option value="23">Română</option>
<option value="24">Slovenščina</option>
</select>
</div>
<div class="mb-2">
<label for="accountType" class="form-label">Account Type</label>
<select id="accountType" class="form-control">
<option value="disabled">Disabled</option>
<option value="melcloud">MELCloud</option>
<option value="melcloudhome">MELCloud Home</option>
</select>
</div>
<div class="text-center">
<button id="logIn" type="button" class="btn btn-primary">Connect to MELCloud</button>
<button id="configButton" type="button" class="btn btn-secondary">
<i class="fas fa-gear"></i>
</button>
</div>
</form>
<div id="accountButtons" class="d-flex flex-wrap justify-content-center gap-1 mt-3"></div>
</div>
</div>
<script>
(async () => {
const pluginConfig = await homebridge.getPluginConfig();
if (!pluginConfig.length) {
pluginConfig.push({});
await homebridge.updatePluginConfig(pluginConfig);
homebridge.showSchemaForm();
return;
}
const accounts = pluginConfig[0].accounts || [];
const accountsCount = accounts.length;
const $ = id => document.getElementById(id);
// Helpers
const setButtonClass = (activeIndex) => {
for (let i = 0; i < accountsCount; i++) {
$(`button${i}`).className = i === activeIndex
? "btn btn-primary"
: "btn btn-secondary";
}
};
const updateForm = (account) => {
$('accountName').innerText = account.name || '';
$('name').value = account.name || '';
$('user').value = account.user || '';
$('passwd').value = account.passwd || '';
$('language').value = account.language || '0';
$('accountType').value = account.type || 'disabled';
const valid = account.name && account.user && account.passwd && account.type !== 'disabled';
$('logIn').disabled = !valid;
const hasDevices =
(account.ataDevices?.length ?? 0) > 0 ||
(account.atwDevices?.length ?? 0) > 0 ||
(account.ervDevices?.length ?? 0) > 0;
$('configButton').disabled = !hasDevices;
};
// Buttons
const container = $('accountButtons');
accounts.forEach((account, i) => {
const button = document.createElement("button");
button.type = "button";
button.id = `button${i}`;
button.className = "btn btn-primary";
button.style.textTransform = "none";
button.innerText = account.name || `Account ${i + 1}`;
container.appendChild(button);
button.addEventListener("click", () => {
setButtonClass(i);
updateForm(accounts[i]);
this.account = accounts[i];
});
if (i === accountsCount - 1) {
$("button0").click();
}
});
$('accountManager').style.display = 'block';
// Config toggle
let configState = false;
$('configButton').addEventListener('click', () => {
configState = !configState;
homebridge[configState ? 'showSchemaForm' : 'hideSchemaForm']();
$('configButton').className = configState
? 'btn btn-primary'
: 'btn btn-secondary';
});
// Password toggle
$('togglePasswd').addEventListener('click', () => {
const input = $('passwd');
const icon = document.querySelector('#togglePasswd i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('fa-eye', 'fa-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('fa-eye-slash', 'fa-eye');
}
});
// Form update
$('configForm').addEventListener('input', async () => {
const account = this.account;
account.name = $('name').value;
account.user = $('user').value;
account.passwd = $('passwd').value;
account.language = $('language').value;
account.type = $('accountType').value;
updateForm(account);
await homebridge.updatePluginConfig(pluginConfig);
await homebridge.savePluginConfig(pluginConfig);
});
// Update info on page
function updateInfo(id, text, color) {
const el = $(id);
if (el) {
el.innerText = text;
el.style.color = color;
}
}
// Generic remove function
function removeStaleEntities(configEntities, melcloudEntities, getConfigId, getMelcloudId) {
const serverIds = new Set((melcloudEntities ?? []).map(item => String(getMelcloudId(item))));
const removedEntities = [];
for (let i = configEntities.length - 1; i >= 0; i--) {
const entity = configEntities[i];
const entityId = String(getConfigId(entity));
if (!serverIds.has(entityId)) {
removedEntities.push(entity);
configEntities.splice(i, 1);
}
}
return removedEntities;
}
// Map UnitId → Scenes for quick lookup
function mapUnitIdToScenes(scenesInMelCloud) {
const map = new Map();
scenesInMelCloud.forEach(scene => {
const allSceneSettings = [...(scene.AtaSceneSettings ?? []), ...(scene.AtwSceneSettings ?? []), ...(scene.ErvSceneSettings ?? [])];
allSceneSettings.forEach(setting => {
const unitId = String(setting.UnitId);
if (!map.has(unitId)) map.set(unitId, []);
map.get(unitId).push(scene);
});
});
return map;
}
// Helper to generate summary string
function summarizeChanges(type, ata, atw, erv) {
const parts = [];
if (ata.length) parts.push(`ATA: ${ata.length}`);
if (atw.length) parts.push(`ATW: ${atw.length}`);
if (erv.length) parts.push(`ERV: ${erv.length}`);
return parts.length ? `${type}: ${parts.join(', ')}` : '';
}
// Login & Sync Logic
$('logIn').addEventListener('click', async () => {
$('logIn').disabled = true;
const lightingMode = homebridge.userCurrentLightingMode();
const fontColor = lightingMode === 'dark' ? 'black' : 'white';
updateInfo('info', 'Connecting to MELCloud', fontColor);
updateInfo('info1', '', fontColor);
updateInfo('info2', '', fontColor);
homebridge.showSpinner();
try {
const account = this.account;
const accountTypeMelcloud = account.type === 'melcloud';
const melCloudDevicesData = await homebridge.request('/connect', account);
if (!melCloudDevicesData.State) {
updateInfo('info', melCloudDevicesData.Status, 'red');
$('logIn').disabled = false;
homebridge.hideSpinner();
return;
}
// Ensure device arrays exist before handling
account.ataDevices = account.ataDevices ?? [];
account.atwDevices = account.atwDevices ?? [];
account.ervDevices = account.ervDevices ?? [];
// Prepare MELCloud data
const newInMelCloud = { ata: [], ataPresets: [], ataSchedules: [], ataScenes: [], atw: [], atwPresets: [], atwSchedules: [], atwScenes: [], erv: [], ervPresets: [], ervSchedules: [], ervScenes: [] };
const devicesInMelCloudByType = { ata: [], atw: [], erv: [] };
const scenesInMelCloud = melCloudDevicesData.Scenes ?? [];
// Split devices by type
const devices = melCloudDevicesData.Devices;
for (const device of devices) {
if (device.Type === 0) devicesInMelCloudByType.ata.push(device);
if (device.Type === 1) devicesInMelCloudByType.atw.push(device);
if (device.Type === 3) devicesInMelCloudByType.erv.push(device);
};
// Clean up local config
const removedFromConfig = { ata: [], atw: [], erv: [], presets: [], schedules: [], scenes: [] };
removedFromConfig.ata = removeStaleEntities(account.ataDevices, devicesInMelCloudByType.ata, d => d.id, d => d.DeviceID);
removedFromConfig.atw = removeStaleEntities(account.atwDevices, devicesInMelCloudByType.atw, d => d.id, d => d.DeviceID);
removedFromConfig.erv = removeStaleEntities(account.ervDevices, devicesInMelCloudByType.erv, d => d.id, d => d.DeviceID);
// Map UnitId → Scenes
const unitIdToScenes = mapUnitIdToScenes(scenesInMelCloud);
// Generic device handler (handles devices, presets, schedules, and scenes)
const handleDevices = (devicesInMelCloud, devicesInConfig, newDevices, newPresets, newSchedules, newScenes) => {
const configDevicesMap = new Map(devicesInConfig.map(dev => [String(dev.id), dev]));
devicesInMelCloud.forEach(device => {
const deviceId = String(device.DeviceID);
let deviceInConfig = configDevicesMap.get(deviceId);
if (!deviceInConfig) {
deviceInConfig = {
id: deviceId,
type: device.Type,
name: device.DeviceName
};
devicesInConfig.push(deviceInConfig);
newDevices.push(deviceInConfig);
configDevicesMap.set(deviceId, deviceInConfig);
}
// PRESETS (melcloud)
if (accountTypeMelcloud) {
deviceInConfig.presets = (deviceInConfig.presets ?? []).filter(p => String(p.id) !== '0' && String(p.id) !== null);
const presetsInMelCloud = device.Presets ?? [];
removedFromConfig.presets.push(...removeStaleEntities(deviceInConfig.presets, presetsInMelCloud, p => p.id, p => p.ID));
const presetIds = new Set(deviceInConfig.presets.map(p => String(p.id)));
presetsInMelCloud.forEach((preset, index) => {
const presetId = String(preset.ID);
if (!presetIds.has(presetId)) {
const presetObj = {
id: presetId,
name: preset.NumberDescription || `Preset ${index}`
};
deviceInConfig.presets.push(presetObj);
newPresets.push(presetObj);
}
});
deviceInConfig.schedules = [];
deviceInConfig.scenes = [];
}
// SCHEDULES & SCENES (melcloudhome)
if (!accountTypeMelcloud) {
// SCHEDULES
deviceInConfig.schedules = (deviceInConfig.schedules ?? []).filter(s => String(s.id) !== '0' && String(s.id) !== null);
const schedulesInMelCloud = device.Schedule ?? [];
removedFromConfig.schedules.push(...removeStaleEntities(deviceInConfig.schedules, schedulesInMelCloud, s => s.id, s => s.Id));
const scheduleIds = new Set(deviceInConfig.schedules.map(s => String(s.id)));
schedulesInMelCloud.forEach((schedule, index) => {
const scheduleId = String(schedule.Id);
if (!scheduleIds.has(scheduleId)) {
const scheduleObj = {
id: scheduleId,
name: `Schedule ${index}`,
};
deviceInConfig.schedules.push(scheduleObj);
newSchedules.push(scheduleObj);
}
});
// SCENES
deviceInConfig.scenes = (deviceInConfig.scenes ?? []).filter(s => String(s.id) !== '0' && String(s.id) !== null);
const scenesForDevice = unitIdToScenes.get(deviceId) ?? [];
removedFromConfig.scenes.push(...removeStaleEntities(deviceInConfig.scenes, scenesForDevice, s => s.id, s => s.Id));
const sceneIds = new Set(deviceInConfig.scenes.map(s => String(s.id)));
scenesForDevice.forEach((scene, index) => {
const sceneId = String(scene.Id);
if (!sceneIds.has(sceneId)) {
const sceneObj = {
id: sceneId,
name: scene.Name || `Scene ${index}`
};
deviceInConfig.scenes.push(sceneObj);
newScenes.push(sceneObj);
}
});
deviceInConfig.presets = [];
}
});
return devicesInConfig;
};
// Execute device handlers with safe initialization
account.ataDevices = handleDevices(
devicesInMelCloudByType.ata,
account.ataDevices,
newInMelCloud.ata,
newInMelCloud.ataPresets,
newInMelCloud.ataSchedules,
newInMelCloud.ataScenes
);
account.atwDevices = handleDevices(
devicesInMelCloudByType.atw,
account.atwDevices,
newInMelCloud.atw,
newInMelCloud.atwPresets,
newInMelCloud.atwSchedules,
newInMelCloud.atwScenes
);
account.ervDevices = handleDevices(
devicesInMelCloudByType.erv,
account.ervDevices,
newInMelCloud.erv,
newInMelCloud.ervPresets,
newInMelCloud.ervSchedules,
newInMelCloud.ervScenes
);
// Summary counts
const newDevicesCount = newInMelCloud.ata.length + newInMelCloud.atw.length + newInMelCloud.erv.length;
const newPresetsCount = newInMelCloud.ataPresets.length + newInMelCloud.atwPresets.length + newInMelCloud.ervPresets.length;
const newSchedulesCount = newInMelCloud.ataSchedules.length + newInMelCloud.atwSchedules.length + newInMelCloud.ervSchedules.length;
const newScenesCount = newInMelCloud.ataScenes.length + newInMelCloud.atwScenes.length + newInMelCloud.ervScenes.length;
const removedDevicesCount = removedFromConfig.ata.length + removedFromConfig.atw.length + removedFromConfig.erv.length;
const removedPresetsCount = removedFromConfig.presets.length;
const removedSchedulesCount = removedFromConfig.schedules.length;
const removedScenesCount = removedFromConfig.scenes.length;
if (!newDevicesCount && !newPresetsCount && !newSchedulesCount && !newScenesCount && !removedDevicesCount && !removedPresetsCount && !removedSchedulesCount && !removedScenesCount) updateInfo('info', 'No changes detected.', fontColor);
// New items
const newParts = [];
if (newDevicesCount) newParts.push(summarizeChanges('Devices', newInMelCloud.ata, newInMelCloud.atw, newInMelCloud.erv));
if (accountTypeMelcloud && newPresetsCount) newParts.push(summarizeChanges('Presets', newInMelCloud.ataPresets, newInMelCloud.atwPresets, newInMelCloud.ervPresets));
if (!accountTypeMelcloud && newSchedulesCount) newParts.push(summarizeChanges('Schedules', newInMelCloud.ataSchedules, newInMelCloud.atwSchedules, newInMelCloud.ervSchedules));
if (!accountTypeMelcloud && newScenesCount) newParts.push(summarizeChanges('Scenes', newInMelCloud.ataScenes, newInMelCloud.atwScenes, newInMelCloud.ervScenes));
if (newParts.length) updateInfo('info', `Found new: ${newParts.join('; ')}`, 'green');
// Removed items
const removedParts = [];
if (removedDevicesCount) removedParts.push(summarizeChanges('Devices', removedFromConfig.ata, removedFromConfig.atw, removedFromConfig.erv));
if (removedPresetsCount) removedParts.push(`Presets: ${removedPresetsCount}`);
if (removedSchedulesCount) removedParts.push(`Schedules: ${removedSchedulesCount}`);
if (removedScenesCount) removedParts.push(`Scenes: ${removedScenesCount}`);
if (removedParts.length) updateInfo('info1', `Removed old: ${removedParts.join('; ')}`, 'orange');
await homebridge.updatePluginConfig(pluginConfig);
await homebridge.savePluginConfig(pluginConfig);
} catch (error) {
updateInfo('info', `Prepare config error`, "red");
updateInfo('info1', `Error: ${JSON.stringify(error)}`, "red");
} finally {
homebridge.hideSpinner();
$('logIn').disabled = false;
}
});
})();
</script>