[APP][PRO] Eplucon Ecoforest warmtepomp

I cannot do custom Icons, but try to build & publish this in the weekend:
Version 1.3.3 Changes:

  1. Fix total_active_power unit - Change from W to kW in capability definition
  2. Add operation_mode_text capability - Human-readable mode (Heating/Cooling/Off/Standby)
  3. Add flow card trigger - “When operation mode changes” with filter for specific modes

Hi Wout,

Do you think re-writing the app in Python would make the option available to not inly read but also write values to the HP?

Hi @Kenny_S It is not yet on my list to rebuild it in Python :slight_smile: And for now did not consider or researched if the API from Eplucon allows it… If that is allowed it should be possible, regardless the programming…

Being able to influence the set temperature and eg. The ability to enable or disable heating, cooling and HW would indeed be very welcome!

I am now in contact with Eplucon, to get a bit more support… That will happen and therefore -without promises- would be great to get your view on what you want to be able to change via Homey… @JaccoStraaijer and @Kenny_S you already gave some input, but would be great to get some more feedback, ideally in a use case, so what problem do you want to solve via Homey in relation to Eplucon…

Note: My contact person is on paternity leave and I am on holiday in the summer… so let’s use the summer to gather your input…

Hi Wout,

My main use case is that I currently already control the Ecoforest heat pump from Homey, but indirectly via Home Assistant. The reason I would like this functionality directly in the Homey app is to avoid depending on multiple platforms/integrations for the same device.

Concrete examples of what I currently do:

  1. When there is PV surplus, I increase the domestic hot water setpoint so the heat pump can use the excess solar energy to store energy as hot water.
  2. I change the heat pump mode between heating and cooling depending on the situation/season.
  3. I change the room/thermostat setpoint.

For me, the most important writable values would be:

  • domestic hot water setpoint;
  • room/thermostat setpoint;
  • operation mode: off / heating / cooling.

The problem I want to solve is central control. Like many Homey users, I do not want to control every device from its own separate app. I want Homey to be the central automation layer for energy optimisation, comfort and seasonal control.

One important requirement for me is that this should work locally, preferably directly over the local network/LAN and not through the Eplucon cloud. Cloud/network-based control is often too slow and not reliable enough for automations. For read-only monitoring that is less critical, but for control actions such as changing setpoints or switching heating/cooling mode, local and reliable control is very important.

In the mean time I did manage to rewrite the Home Assistant YAML I use to control the Heat Pump to a Homey script.

/*
 * HomeyScript - Eplucon Heatpump Control
 *
 * Directe bediening van de Eplucon warmtepomp
 *
 * Argument voorbeelden:
 *   22
 *   indoor_temperature=22
 *   warm_water_active=1
 *   heating_active=0
 *   heatpump_operation_mode=2
 *
 * Als je alleen een getal meegeeft, gebruikt het script standaard:
 *   indoor_temperature=<waarde>
 */

const URL_BASE = 'https://portaal.eplucon.nl';

const EPLUCON_USERNAME = 'VUL_HIER_JE_EPLUCON_GEBRUIKERSNAAM_IN';
const EPLUCON_PASSWORD = 'VUL_HIER_JE_EPLUCON_WACHTWOORD_IN';
const MODULE_INDEX = 'VUL_HIER_JE_MODULE_INDEX_IN';

const DEFAULT_COMMAND = 'indoor_temperature';
const DEFAULT_VALUE = null;

// Lager = sneller. Als login niet betrouwbaar is, zet terug naar 2500.
const LOGIN_DELAY_MS = 1200;

const COMMANDS = {
  indoor_temperature: {
    code: '5701',
    type: 'float',
    min: 10.0,
    max: 30.0,
    blockType: 'module',
  },
  boiler_temperature: {
    code: '5700',
    type: 'integer',
    min: 0,
    max: 65,
    blockType: 'menu',
  },
  boiler_temperature_delta: {
    code: '5804',
    type: 'float',
    min: 5.0,
    max: 65.0,
    blockType: 'menu',
  },
  warm_water_active: {
    code: '5711',
    type: 'boolean',
    min: 0,
    max: 1,
    blockType: 'module',
  },
  heatpump_active: {
    code: '5715',
    type: 'enum',
    min: 0,
    max: 3,
    blockType: 'menu',
  },
  heatpump_operation_mode: {
    code: '5712',
    type: 'enum',
    min: 1,
    max: 5,
    blockType: 'module',
  },
  heating_active: {
    code: '5813',
    type: 'boolean',
    min: 0,
    max: 1,
    blockType: 'menu',
  },
  stop_heating_above: {
    code: '5814',
    type: 'float',
    min: 0.0,
    max: 30.0,
    blockType: 'menu',
  },
  heating_curve_correction: {
    code: '5886',
    type: 'enum',
    min: 0,
    max: 4,
    blockType: 'menu',
  },
  cooling_active: {
    code: '5817',
    type: 'boolean',
    min: 0,
    max: 1,
    blockType: 'menu',
  },
  stop_passive_cooling_below: {
    code: '5901',
    type: 'float',
    min: 0.0,
    max: 35.0,
    blockType: 'menu',
  },
  stop_active_cooling_below: {
    code: '5818',
    type: 'float',
    min: 0.0,
    max: 35.0,
    blockType: 'menu',
  },
};

const cookieJar = {};

async function sleep(ms) {
  if (ms > 0) {
    await wait(ms);
  }
}

function assertConfig() {
  if (!EPLUCON_USERNAME || EPLUCON_USERNAME === 'VUL_HIER_JE_EPLUCON_GEBRUIKERSNAAM_IN') {
    throw new Error('Vul EPLUCON_USERNAME in.');
  }

  if (!EPLUCON_PASSWORD || EPLUCON_PASSWORD === 'VUL_HIER_JE_EPLUCON_WACHTWOORD_IN') {
    throw new Error('Vul EPLUCON_PASSWORD in.');
  }

  if (!MODULE_INDEX || MODULE_INDEX === 'VUL_HIER_JE_MODULE_INDEX_IN') {
    throw new Error('Vul MODULE_INDEX in.');
  }
}

function getSetCookieHeaders(headers) {
  if (!headers) return [];

  if (typeof headers.getSetCookie === 'function') {
    return headers.getSetCookie();
  }

  if (typeof headers.raw === 'function') {
    const raw = headers.raw();
    if (raw && raw['set-cookie']) return raw['set-cookie'];
  }

  const header = headers.get ? headers.get('set-cookie') : null;

  if (!header) return [];

  return header.split(/,(?=\s*[^;,]+=)/g).map(value => value.trim());
}

function storeCookies(response) {
  const cookieHeaders = getSetCookieHeaders(response.headers);

  for (const cookieHeader of cookieHeaders) {
    const firstPart = cookieHeader.split(';')[0];
    const separatorIndex = firstPart.indexOf('=');

    if (separatorIndex <= 0) continue;

    const name = firstPart.slice(0, separatorIndex).trim();
    const value = firstPart.slice(separatorIndex + 1).trim();

    if (name) {
      cookieJar[name] = value;
    }
  }
}

function getCookieHeader() {
  return Object.entries(cookieJar)
    .map(([name, value]) => `${name}=${value}`)
    .join('; ');
}

function getHeader(headers, name) {
  if (!headers || typeof headers.get !== 'function') return null;
  return headers.get(name);
}

function absoluteUrl(location) {
  if (!location) return null;
  if (/^https?:\/\//i.test(location)) return location;
  if (location.startsWith('/')) return `${URL_BASE}${location}`;
  return `${URL_BASE}/${location}`;
}

function decodeHtmlEntities(value) {
  return String(value || '')
    .replace(/&quot;/g, '"')
    .replace(/&#039;/g, "'")
    .replace(/&apos;/g, "'")
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>');
}

function unescapePortalHtml(html) {
  return String(html || '')
    .replace(/\\"/g, '"')
    .replace(/\\\//g, '/')
    .replace(/\\n/g, '\n')
    .replace(/\\t/g, '\t');
}

function getAttributeFromTag(tagText, attributeName) {
  const normalized = unescapePortalHtml(tagText);

  const patterns = [
    new RegExp(`${attributeName}\\s*=\\s*"([^"]*)"`, 'i'),
    new RegExp(`${attributeName}\\s*=\\s*'([^']*)'`, 'i'),
    new RegExp(`${attributeName}\\s*=\\s*([^\\s>]+)`, 'i'),
  ];

  for (const pattern of patterns) {
    const match = normalized.match(pattern);

    if (match && match[1] !== undefined) {
      return decodeHtmlEntities(match[1]);
    }
  }

  return null;
}

function parseInputFields(html) {
  const cleanHtml = unescapePortalHtml(html);
  const inputTags = cleanHtml.match(/<input\b[^>]*>/gi) || [];
  const formData = {};

  for (const inputTag of inputTags) {
    const name = getAttributeFromTag(inputTag, 'name');

    if (!name) continue;

    const value = getAttributeFromTag(inputTag, 'value');
    formData[name] = value !== null ? value : '';
  }

  return formData;
}

function formEncode(data) {
  const params = new URLSearchParams();

  for (const [key, value] of Object.entries(data)) {
    params.append(key, value === null || value === undefined ? '' : String(value));
  }

  return params.toString();
}

function looksLikeLoginPage(html) {
  const text = String(html || '');

  return (
    /<h3[^>]*>\s*Login\s*<\/h3>/i.test(text) ||
    /name=["']username["']/i.test(text) ||
    /name=["']password["']/i.test(text) ||
    /Gebruikersnaam\s*\/\s*E-mailadres/i.test(text)
  );
}

function shortText(text, length = 300) {
  const value = String(text || '').replace(/\s+/g, ' ').trim();

  if (value.length <= length) return value;

  return `${value.slice(0, length)}...`;
}

async function request(method, url, options = {}) {
  const headers = {
    ...(options.headers || {}),
  };

  const cookieHeader = getCookieHeader();

  if (cookieHeader) {
    headers.Cookie = cookieHeader;
  }

  const response = await fetch(url, {
    method,
    headers,
    body: options.body,
    redirect: options.redirect || 'follow',
  });

  storeCookies(response);

  const text = await response.text();

  return {
    status: response.status,
    ok: response.ok,
    url: response.url,
    headers: response.headers,
    text,
  };
}

async function getPage(url, referer = null) {
  const headers = {
    Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'User-Agent': 'HomeyScript Eplucon',
  };

  if (referer) {
    headers.Referer = referer;
  }

  const response = await request('GET', url, {
    headers,
  });

  if (!response.ok) {
    throw new Error(`GET mislukt: ${url} gaf HTTP ${response.status}`);
  }

  return response.text;
}

async function postLoginForm(loginUrl, formData) {
  const response = await request('POST', loginUrl, {
    redirect: 'manual',
    headers: {
      Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
      'Content-Type': 'application/x-www-form-urlencoded',
      Origin: URL_BASE,
      Referer: `${URL_BASE}/login`,
      'User-Agent': 'HomeyScript Eplucon',
    },
    body: formEncode(formData),
  });

  if (response.status >= 300 && response.status < 400) {
    const location = getHeader(response.headers, 'location');
    const redirectUrl = absoluteUrl(location);

    if (!redirectUrl) {
      throw new Error('Login gaf een redirect zonder Location-header.');
    }

    return await getPage(redirectUrl, loginUrl);
  }

  if (!response.ok) {
    throw new Error(`Login mislukt: HTTP ${response.status}. ${shortText(response.text)}`);
  }

  return response.text;
}

async function postAjaxForm(url, formData, referer) {
  const response = await request('POST', url, {
    redirect: 'manual',
    headers: {
      Accept: 'application/json, text/javascript, */*; q=0.01',
      'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
      'X-Requested-With': 'XMLHttpRequest',
      Origin: URL_BASE,
      Referer: referer || `${URL_BASE}/e-control`,
      'User-Agent': 'HomeyScript Eplucon',
    },
    body: formEncode(formData),
  });

  if (response.status >= 300 && response.status < 400) {
    const location = getHeader(response.headers, 'location');
    const redirectUrl = absoluteUrl(location);

    if (!redirectUrl) {
      throw new Error('Eplucon gaf een redirect zonder Location-header.');
    }

    return await getPage(redirectUrl, referer || `${URL_BASE}/e-control`);
  }

  if (!response.ok) {
    throw new Error(`Commando mislukt: HTTP ${response.status}. ${shortText(response.text)}`);
  }

  return response.text;
}

function parseScriptArguments() {
  let command = DEFAULT_COMMAND;
  let value = DEFAULT_VALUE;

  if (typeof args !== 'undefined' && Array.isArray(args) && args.length > 0) {
    const firstArg = String(args[0]).trim();

    if (args.length >= 2) {
      command = String(args[0]).trim();
      value = String(args[1]).trim();
    } else if (firstArg.includes('=')) {
      const separatorIndex = firstArg.indexOf('=');
      command = firstArg.slice(0, separatorIndex).trim();
      value = firstArg.slice(separatorIndex + 1).trim();
    } else {
      value = firstArg;
    }
  }

  if (!command) {
    throw new Error('Geen command opgegeven.');
  }

  if (value === null || value === undefined || value === '') {
    throw new Error('Geen waarde opgegeven. Gebruik bijvoorbeeld 22 of indoor_temperature=22.');
  }

  return {
    command,
    value,
  };
}

function normalizeNumberText(value) {
  return String(value).trim().replace(',', '.');
}

function validateValue(command, rawValue) {
  const commandConfig = COMMANDS[command];

  if (!commandConfig) {
    throw new Error(`Onbekend command: ${command}`);
  }

  const valueText = normalizeNumberText(rawValue);
  let value;

  if (commandConfig.type === 'float') {
    value = Number(valueText);

    if (!Number.isFinite(value)) {
      throw new Error(`Waarde voor ${command} moet een getal zijn.`);
    }

    if (value < commandConfig.min || value > commandConfig.max) {
      throw new Error(
        `Waarde ${value} valt buiten bereik voor ${command}: ${commandConfig.min} t/m ${commandConfig.max}.`
      );
    }

    return String(value);
  }

  if (commandConfig.type === 'integer' || commandConfig.type === 'enum') {
    value = Number(valueText);

    if (!Number.isInteger(value)) {
      throw new Error(`Waarde voor ${command} moet een geheel getal zijn.`);
    }

    if (value < commandConfig.min || value > commandConfig.max) {
      throw new Error(
        `Waarde ${value} valt buiten bereik voor ${command}: ${commandConfig.min} t/m ${commandConfig.max}.`
      );
    }

    return String(value);
  }

  if (commandConfig.type === 'boolean') {
    const lower = String(rawValue).trim().toLowerCase();

    if (['true', 'on', 'aan', 'yes', 'ja'].includes(lower)) {
      value = 1;
    } else if (['false', 'off', 'uit', 'no', 'nee'].includes(lower)) {
      value = 0;
    } else {
      value = Number(valueText);
    }

    if (!Number.isInteger(value) || value < 0 || value > 1) {
      throw new Error(`Waarde voor ${command} moet 0 of 1 zijn.`);
    }

    return String(value);
  }

  throw new Error(`Niet ondersteund command-type: ${commandConfig.type}`);
}

function buildControlPayload(commandConfig, value) {
  const typeMap = {
    float: {
      format: '2',
      type: '2',
    },
    integer: {
      format: '1',
      type: '1',
    },
    boolean: {
      format: '0',
      type: '10',
    },
    enum: {
      format: '0',
      type: '11',
    },
  };

  const mapped = typeMap[commandConfig.type];

  if (!mapped) {
    throw new Error(`Niet ondersteund command-type: ${commandConfig.type}`);
  }

  return JSON.stringify([
    {
      name: 'format',
      value: mapped.format,
    },
    {
      name: 'type',
      value: mapped.type,
    },
    {
      name: 'menutype',
      value: 'MU',
    },
    {
      name: 'blockType',
      value: commandConfig.blockType,
    },
    {
      name: 'tile_value',
      value,
    },
    {
      name: 'ido',
      value: commandConfig.code,
    },
  ]);
}

async function login() {
  const loginUrl = `${URL_BASE}/login`;

  const loginPageHtml = await getPage(loginUrl);
  const formData = parseInputFields(loginPageHtml);

  if (!formData._token) {
    throw new Error('Geen CSRF-token gevonden op de loginpagina.');
  }

  formData.username = EPLUCON_USERNAME;
  formData.password = EPLUCON_PASSWORD;

  await sleep(LOGIN_DELAY_MS);

  const loginResponseHtml = await postLoginForm(loginUrl, formData);

  if (/inloggegevens is niet/i.test(loginResponseHtml)) {
    throw new Error('Inloggen mislukt. Controleer gebruikersnaam en wachtwoord.');
  }

  if (looksLikeLoginPage(loginResponseHtml)) {
    throw new Error(`Login niet geaccepteerd. Response: ${shortText(loginResponseHtml)}`);
  }
}

async function getControlForm(commandConfig) {
  const controlUrl =
    `${URL_BASE}/e-control/ajax/modal/${commandConfig.code}` +
    `?blockType=module&account_module_index=${encodeURIComponent(MODULE_INDEX)}`;

  const response = await request('GET', controlUrl, {
    headers: {
      Accept: 'text/html,application/json,*/*',
      'X-Requested-With': 'XMLHttpRequest',
      Referer: `${URL_BASE}/e-control`,
      'User-Agent': 'HomeyScript Eplucon',
    },
  });

  if (!response.ok) {
    throw new Error(`Control-formulier ophalen mislukt: HTTP ${response.status}. Controleer MODULE_INDEX.`);
  }

  if (looksLikeLoginPage(response.text)) {
    throw new Error('Eplucon-sessie is niet actief; control-formulier kwam terug op de loginpagina.');
  }

  if (!response.text || response.text.trim() === '') {
    throw new Error('Control-formulier is leeg. Controleer MODULE_INDEX.');
  }

  return parseInputFields(response.text);
}

async function sendControlData(commandConfig, value, formData) {
  const sendControlUrl =
    `${URL_BASE}/e-control/ajax/send_control_data` +
    `?account_module_index=${encodeURIComponent(MODULE_INDEX)}`;

  formData.data = buildControlPayload(commandConfig, value);

  const responseText = await postAjaxForm(sendControlUrl, formData, `${URL_BASE}/e-control`);

  if (looksLikeLoginPage(responseText)) {
    throw new Error('Eplucon stuurde na het commando opnieuw de loginpagina terug. Commando is niet uitgevoerd.');
  }

  return responseText;
}

async function main() {
  assertConfig();

  const startedAt = Date.now();

  const { command, value: rawValue } = parseScriptArguments();
  const commandConfig = COMMANDS[command];

  if (!commandConfig) {
    throw new Error(`Ongeldig command: ${command}. Geldige commands: ${Object.keys(COMMANDS).join(', ')}`);
  }

  const value = validateValue(command, rawValue);

  await login();

  const formData = await getControlForm(commandConfig);
  const responseText = await sendControlData(commandConfig, value, formData);

  const accepted = String(responseText).trim() === '1';

  if (!accepted) {
    throw new Error(`Eplucon gaf geen bevestiging "1" terug. Response: ${shortText(responseText)}`);
  }

  await tag('Eplucon Laatste Commando', command);
  await tag('Eplucon Laatste Waarde', value);
  await tag('Eplucon Laatste Bedieningsactie', `${command}=${value}`);
  await tag('Eplucon Laatste Bedieningsactie Tijd', new Date().toISOString());

  return {
    status: 'success',
    command,
    value,
    accepted,
    durationMs: Date.now() - startedAt,
    updated: new Date().toISOString(),
  };
}

return await main();

Hi Wout,

Agreed with Kenny on the types of control for the heatpump. For me the reason is that the Eplucon automation is far to limited in its functionality. At the end I would like to have the ability to control all my Eplucon devices (Solax already included, Ecoforrest is in this topic, Wanas WTW to be done) to be able to get to advanced en self learning algorithms that are very personalised and specific to my home.

imagine things like:

Using pricing setpoints to determine where excess power goes to (not ontlucht having the option to always set a higher water temperature every day at the lowest price as the Eplucon app provides, but only to do that when prices are lower than x for example)

the liberty of being able to control all Eplucon devices through their api gives us the freedom to script enhanced self learning features integrating with other existing sensors and home systems.

changed:
one more thing I would like to do: Eplucon does not provide a sensor to stop cooling when humidity gets to high (strange but true). I would like to use the homey integration to actively stop cooling when humidity runs too high.