A typed Node.js client for the MELCloud and MELCloud Home APIs, providing access to Mitsubishi Electric air-to-air (Ata), air-to-water (Atw) and energy recovery ventilation (Erv) devices.
/report chart contract) answer one neutral vocabulary whichever API backs the target, each pinned by a contract kernel.ValidationError instead of a deep undefined crash. The Classic bulk listing is the one deliberate exception: one unusable entry must not take a whole account down, so it is dropped and reported on the cycle's log line — with its id and whether its Type is unmodelled or its header malformed — instead of failing the payload; the Home /context salvage names the unit it prunes the same way, with the refused paths relative to its entry.Result<T> so callers branch on network / unauthorized / rate-limited / validation / server / not-found instead of catching generic exceptions.getEnergySeries on the Home device facades projects the energy history onto epoch-ms UTC instants and kilowatt-hours whatever the device type; the wire's timestamp dialect, interval grammar and per-type units stay inside the SDK, and a garbled sample degrades its fields to null (never NaN, never a throw) while keeping its entry. The wire-verbatim getEnergy remains for consumers that need the raw envelope.sideEffects: false plus namespace-style subpath exports (/classic, /home) and one flat subpath per browser-safe contract module. The flat subpaths never reach the HTTP stack, so a browser bundle can import their values; the root barrel does and is Node-only.read:packages scopeThis package is published to GitHub Packages, not the public npm registry.
Configure your project so npm fetches the @olivierzal scope from GitHub:
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
@olivierzal:registry=https://npm.pkg.github.com
NODE_AUTH_TOKEN must be a GitHub personal access token with the read:packages scope (export it in your shell or set it in your CI environment). Then:
npm install @olivierzal/melcloud-api
import { ClassicAPI, ClassicFacadeManager } from '@olivierzal/melcloud-api'
const api = await ClassicAPI.create({
username: 'user@example.com',
password: 'password',
})
const manager = new ClassicFacadeManager(api)
// Browse the device hierarchy
for (const zone of manager.getZones()) {
console.log(zone.name)
}
// Interact with a device through its facade
const [device] = api.registry.getDevices()
if (device !== undefined) {
const facade = manager.get(device)
await facade.updateValues({ Power: true })
}
import {
HomeAPI,
HomeDeviceType,
HomeFacadeManager,
} from '@olivierzal/melcloud-api'
const api = await HomeAPI.create({
username: 'user@example.com',
password: 'password',
})
await api.list()
const manager = new HomeFacadeManager(api)
// Interact with a device through its type-narrowed facade
const [device] = api.registry.getDevicesByType(HomeDeviceType.Ata)
if (device !== undefined) {
const facade = manager.get(device)
console.log(facade.name, facade.operationMode, facade.setTemperature)
await facade.updateValues({ setTemperature: 21 })
}
Best-effort getters (telemetry, reports, error logs, settings reads) return a Result<T> so callers can branch on the failure class — network, unauthorized, rate-limited, validation, server, or not-found. Mutations (update*, updatePower) keep their throw-on-failure contract.
const result = await facade.getEnergy({ from: '2024-01-01', to: '2024-12-31' })
if (!result.ok) {
switch (result.error.kind) {
case 'rate-limited':
// result.error.retryAfterMs
break
case 'unauthorized':
// re-authenticate before retrying
break
default:
// network / server / validation — log and skip
break
}
return
}
const energy = result.value
A failure a host catches is logged the way the SDK logs its own: logFailure(logger, label, error) writes ONE line — the label, then the error's cause chain (Write failed: TypeError: fetch failed — caused by ConnectTimeoutError: Connect Timeout Error (UND_ERR_CONNECT_TIMEOUT)) — for a failure the SDK has a class for (HttpError, the APIError family, a transport failure, anywhere in the cause chain), whose stack would only name the transport; and the label followed by the error object for anything else, so a programming error keeps the stack that locates it. isKnownFailure and failureCauseChain are the two halves, for a host that writes the line itself. The three are @olivierzal/api-core's, re-exported under their own names like the error classes, so a host pins this SDK alone; logger is any Logger (error and log, the console shape).
import { logFailure } from '@olivierzal/melcloud-api'
try {
await facade.updateValues({ power: false })
} catch (error) {
logFailure(console, 'Write failed:', error)
}
Both clients keep their session alive without caller involvement:
settingManager (a simple get/set string store) to persist tokens and credentials across restarts; without one, everything stays in memory and a new instance signs in from scratch.
The SettingManager receives the access/refresh tokens and the account password as plain strings. You are responsible for backing it with secure storage (encrypted settings store, OS keychain, …) — do not write it to a world-readable file. The SDK redacts credentials from everything it emits: its own log output, and the request/response snapshot carried by a thrown HttpError — bearer token, X-MitsContextKey, session cookie, sign-in body and query alike — so an error object handed to a host logger, or pasted into a diagnostic report, carries no secret. The same engine blanks the personal data the two wires carry — device display names, the account holder's name, the building's postal address — so a pasted dump names a unit by type and id; building names and Home setting names ride the same name key and stay, so the dump remains readable.
const settings = new Map<string, string>()
const api = await HomeAPI.create({
settingManager: {
get: (key) => settings.get(key) ?? null,
set: (key, value) => settings.set(key, value),
},
})
The request pipeline applies three policies, outermost first:
Retry-After window (delta-seconds and HTTP-date forms are both honored), or 2 hours when the header is absent. While paused, calls fail fast with RateLimitError (retryAfter, unblockAt) instead of hammering the server; check api.isRateLimited before issuing bursts.POST/PUT) are never retried automatically: a write that may have landed server-side must not be silently duplicated. Each retry is observable through the onRequestRetry lifecycle event.The transport itself is configurable through transport on both clients: pass a pre-built HttpClient (the seam the test suites use to script responses), or { timeoutMs } to change the 30 s per-request timeout (0 disables it — not recommended). Omit it for the fetch-backed default.
Exports follow a Classic* / Home* prefix convention so the target API is obvious at the call site. The ./classic and ./home subpaths re-export everything with the prefix stripped — a modern alternative to TypeScript's deprecated namespace keyword.
// From the root — prefixed names, works in mixed Classic/Home contexts
import { ClassicAPI, HomeAPI, type ClassicGetDeviceData } from '@olivierzal/melcloud-api'
// From the Classic-scoped subpath — short names, no ambiguity
import { API, FanSpeed, type GetDeviceData } from '@olivierzal/melcloud-api/classic'
// From the Home-scoped subpath — short names
import { API, type AtaValues, type DeviceCapabilities } from '@olivierzal/melcloud-api/home'
// Namespace-like usage (recommended for mixed Classic + Home code)
import type * as Classic from '@olivierzal/melcloud-api/classic'
import type * as Home from '@olivierzal/melcloud-api/home'
const a: Classic.GetDeviceData<0> = ...
const b: Home.AtaValues = ...
// Shared enum-like constants (e.g. CLASSIC_FLAG_UNCHANGED, HomeDeviceType)
import { ClassicDeviceType, HomeDeviceType } from '@olivierzal/melcloud-api/constants'
// Protection bounds and clamping (frost, overheat)
import { FROST_PROTECTION_RANGE } from '@olivierzal/melcloud-api/protection'
Available subpaths: /classic, /home, /atw-state, /constants,
/enum-mappings, /error-log, /holiday-mode, /protection,
/report, /temperature-range, /temporal.
The flat subpaths (everything but /classic and /home) stay inside the
browser-safe closure — none of them reaches the Node-only HTTP stack the
root barrel pulls in — so a browser bundler resolves them cleanly: the
path to take when a webview needs a published value rather than a copy.
Full API reference at https://olivierzal.github.io/melcloud-api/.
See CONTRIBUTING.md.
For vulnerability reports, see SECURITY.md.
See CHANGELOG.md.
This API is not endorsed, verified or approved by Mitsubishi Electric Corporation. Mitsubishi cannot be held liable for any claims or damages that may occur when using this client to control MELCloud devices.
MIT © Olivier Zalmanski