[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.
Changed 2:
in the th-touch installer section you can set the cooling temperature ‘aanvoer’. dat would be awesome for humidity. this way you can calculate the dewpoint and influence the aanvoer temperature to be above … Far better than stopping the cooling…

super @JaccoStraaijer thanks for the input, my contact at Eplucon is still with Paternity leave so don’t expect soon solutions, but will keep you all updated once I proceed the development!

Hello Wout,

I just came to see this place. I just got an Ecoforest ecoGEO C2 2-10 kW PRO+EH with th-TOUCH installed for a month. Now looking for what is possible. I expected that I would be able to gain insight into energy consumption per category via the Api: boiler, cooling, heating, etc. It seems that I only get a monthly value, in which this distinction cannot be made. I would like this in real time.

I would also like to be able to steer smartly, on epex quarterly values, on solar power availability and to be able to set when I expect hot water or a certain temperature in the room. I use Tibber app to charge my car. He is incredibly smart. I was naïve enough to think that the eplicon would also have such functionality. I can now set two temperatures for hot water and the number of hours it works at the low temperature. Tibber works the other way around: when do you need the car. Tibber make sure the car is charged at the best time. Can also be done with boiler water in my opinion: when do you need water of which temp. In order to be able to program this ourselves, we need to be able to set the trigger points. I can’t oversee it yet.

I am very curious which direction Eplicon wants to go

Omdat ik het gevoel heb dat we hier allemaal Nederlands zijn, ga ik voor het gemak maar even in het Nederlands verder. Mocht het nodig zijn, dan switch ik wel weer naar Engels.

Eplucon is helaas behoorlijk beperkt in de mogelijkheden wat betreft aansturing. Hopelijk komt daar binnen afzienbare tijd nog verandering in, maar eerlijk gezegd heb ik daar mijn twijfels over. Om er toch zoveel mogelijk uit te halen, ben ik daarom zelf wat gaan sleutelen met Homey.

Ik heb een 3-fase energiemeter geplaatst waarmee ik het volledige energieverbruik van de warmtepomp meet. Die energiestroom splits ik vervolgens op, zodat ik kan bepalen waar de warmtepomp op dat moment mee bezig is. Op basis daarvan kan ik onder andere het maken van warm tapwater apart herkennen en volgen.

Voor het warme tapwater stuur ik vervolgens op basis van het energietarief. Op gunstige momenten boost ik de warmwatertemperatuur naar 63 °C. Op de achtergrond draait een script dat bijhoudt hoeveel energie het daadwerkelijk kost om het warme water op temperatuur te brengen. Daarbij sla ik verschillende gegevens op, zoals de start- en eindtemperatuur, start- en eindtijd, brinetemperaturen en buitentemperatuur.

Met die historische gegevens maak ik vervolgens een voorspelling voor de volgende dag. Het doel daarvan is om vooraf te bepalen in welke kwartierblokken het financieel het gunstigst is om de warmwaterboost te starten. Voorlopig werkt dit nog semi-automatisch: iedere avond om 20:30 krijg ik een overzicht met het voorgestelde moment voor de volgende dag en dat moet ik nog handmatig accepteren.

Ik ben alleen bang dat het qua slimme aansturing van de warmtepomp ook ongeveer hierbij blijft. Voor warm tapwater heb je redelijk wat vrijheid om het verbruik in de tijd te verschuiven, maar voor verwarmen en koelen vind ik dat een ander verhaal.

In de winter moet het huis uiteindelijk gewoon comfortabel warm zijn. Je kunt natuurlijk zeggen: de stroom is de komende twee uur duur, dus we verwarmen even niet. Maar afhankelijk van de woning kan de temperatuur in die periode behoorlijk teruglopen. Vervolgens moet de warmtepomp daarna weer harder of langer werken om de woning op temperatuur te krijgen. Dan is het nog maar de vraag hoeveel je onder de streep daadwerkelijk bespaart. Bij koelen geldt in principe hetzelfde verhaal.

Daarnaast vind ik dat je bij een warmtepomp ook rekening moet houden met het feit dat deze juist gebaat is bij een zo rustig en constant mogelijke bedrijfsvoering. Alleen naar de elektriciteitsprijs kijken en de warmtepomp voortdurend aan de hand daarvan proberen te sturen, hoeft energetisch dus helemaal niet de beste oplossing te zijn.

Al met al denk ik daarom dat een warmtepomp maar beperkt geschikt is om echt slim op dynamische energieprijzen te sturen. Warm tapwater is naar mijn idee een van de weinige onderdelen waarbij je redelijk eenvoudig energie kunt verschuiven zonder direct comfort in te leveren. Voor verwarmen en koelen zie ik persoonlijk veel minder speelruimte.

Uiteindelijk gaat comfort bij mij toch echt boven die paar cent die je mogelijk kunt “verdienen” door de warmtepomp nog agressiever op energieprijzen te sturen.

Dank voor je uitgebreide reactie. Kan ik goed volgen. Er is ook nog een mogelijkheid met modbus dacht ik. Heb je dat ook uitgezocht? Lokaal sturen is natuurlijk het mooiste.

De data die jij via aparte meter vergaard is er gewoon. Heel flauw dat dat niet wordt gedeeld.

Modbus heb ik nog niet goed naar gekeken, uiteindelijk is dat wel de beste optie omdat het lokaal is, je wilt natuurlijk niet afhankelijk zijn van de cloud diensten van derden. De Modbus optie is wat extra hardware nodig, niks speciaals volgens mij, maar niet direct plug-and-play.

Data beschikbaar klopt gedeeltelijk, het schijnt zo te zijn dat er tijdens chiptekorten pompen zijn uitgeleverd met andere chips, de mijne is er zo een, die laat energieverbruik per 100kWh zien, dat is natuurlijk een frequentie waar niemand iets mee kan. Vandaar dat gekozen heb dit zelf te meten.

Het is een Engels topic, maar kan vragen of ze m willen om hangen… Ik heb exact dezelfde uitdaging zoals jullie die vermelden, wil mijn verbruik zien en heb daarom in de meterkast een shelly 3 bevestigd. Nog niet geïntegreerd in de app, zit er wel aan te denken.

Had ook de versie die minder chips had, maar is kosteloos later geïmplementeerd, nu zie ik aan het eind van de maand (door firmware upgrade) het verbruik.

Wil ook het liefst lokale data, dus even op tweakers gekeken naar de Modbus integratie maar dat is nog geen gelopen race…

Voor nu stuur ik via Eplucon portal op epex, als de zonnepanelen extra produceren dan gaat de boiler naar 63 graden.

Zal na mijn vakantie wat uitgebreider met de app aan de slag gaan…

Ik heb inmiddels ook een accu staan, ik heb het volgende gemaakt:

Homey-flow voor het slim plannen van een warmwaterboost

Ik gebruik Homey om de warmwaterboost van mijn Ecoforest-warmtepomp automatisch op een goedkoop moment te plannen. De warmtepomp wordt daarbij niet rechtstreeks geschakeld; Homey verhoogt tijdelijk het warmwatersetpoint van 52 naar 63 °C.

De Flow werkt als volgt.

1. Warmwatercycli registreren

Iedere 30 seconden leest een HomeyScript:

  • De actuele boilertemperatuur;
  • De status van de warmwaterproductie;
  • Het actuele vermogen;
  • De cumulatieve energiemeter van de warmtepomp.

Bij iedere geslaagde warmwatercyclus worden onder andere begin- en eindtemperatuur, looptijd en werkelijk energieverbruik opgeslagen. Alle cycli worden per maand bewaard in een JSONL-bestand in Homey Logic.

Normale warmwaterproductie en de geplande boost worden daarnaast over aparte virtuele energiemeters verdeeld.

2. Iedere avond een planning maken

Om 21:00 haalt de Flow de kwartierprijzen voor de volgende dag op. Vervolgens wordt binnen het toegestane tijdvenster het goedkoopste aaneengesloten prijsblok gezocht.

De verwachte benodigde energie wordt berekend met historische meetgegevens:

  1. Bij voorkeur worden maximaal de laatste acht geldige boostcycli gebruikt.
  2. Zijn er nog onvoldoende boostcycli, dan worden geldige normale warmwatercycli gebruikt.
  3. Per cyclus wordt het energieverbruik omgerekend naar kWh per graad temperatuurstijging.
  4. Uitschieters worden buiten beschouwing gelaten.
  5. Van de resterende waarden wordt de mediaan genomen.
  6. Deze waarde wordt vermenigvuldigd met de actuele temperatuurdelta tot 63 °C.

Bij onvoldoende betrouwbare historie blijft de bestaande vaste voorspelling als fallback actief.

3. Financieel voordeel bepalen

De kosten van de geplande boost worden vergeleken met de verwachte kosten van een normale warmwatercyclus in de avond tussen 19:00 en 24:00.

De basisberekening is:

verwacht voordeel = vermeden avondkosten − boostkosten

De boost wordt alleen ingepland wanneer het verwachte voordeel groter is dan €0,20.

Na de berekening ontvang ik een melding met:

  • De geplande datum en starttijd;
  • De gemiddelde boostprijs;
  • De gemiddelde avondprijs;
  • Het voorspelde energieverbruik;
  • De verwachte boostkosten;
  • Het verwachte financiële voordeel.

4. Eén uur voor de start opnieuw controleren

De boilertemperatuur kan tussen 21:00 en het geplande startmoment veranderen. Daarom wordt één uur voor de start een sanitycheck uitgevoerd.

De actuele temperatuurdelta wordt opnieuw vergeleken met de delta tijdens het plannen. Op basis daarvan worden het verwachte verbruik, de vermeden avondenergie en het financiële voordeel opnieuw berekend.

  • Blijft het voordeel boven €0,20, dan blijft de planning actief.
  • Daalt het voordeel naar €0,20 of lager, dan wordt de boost geannuleerd.
  • Alleen bij annulering wordt een extra melding verstuurd.

5. De boost starten

Iedere vijftien minuten controleert Homey of het geplande startmoment is bereikt.

Wanneer de planning nog geldig is:

  • Wordt het warmwatersetpoint verhoogd naar 63 °C;
  • Wordt de start van de boost geregistreerd;
  • Wordt het vermogen aan de virtuele meter voor WW Boost toegewezen;
  • Ontvang ik een melding dat de boost is gestart.

De warmtepomp bepaalt vervolgens zelf het vermogensverloop en wanneer de ingestelde temperatuur is bereikt.

6. Accubeleid in SlimLaden

De virtuele energiemeter PM - WW Boost is in SlimLaden ingesteld als kritisch apparaat. Daardoor wordt het gemeten boostvermogen buiten de normale nul-op-de-meterregeling van de thuisaccu gehouden.

Tijdens de boost geldt daardoor:

  • Direct beschikbare zonne-energie mag de warmtepomp voeden;
  • Het resterende benodigde vermogen komt uit het net;
  • De elektrische thuisaccu ontlaadt niet om de boost te voeden;
  • Eventueel zonneoverschot boven het totale huisverbruik mag wel naar de thuisaccu.

Het warmwatervat is feitelijk zelf ook een vorm van energieopslag: elektriciteit wordt via de warmtepomp omgezet in warmte en tijdelijk in het water opgeslagen. Het is daarom niet efficiënt om eerst elektriciteit in de thuisaccu op te slaan en deze daarna, met extra laad- en ontlaadverlies, opnieuw te gebruiken om warmte in het vat op te slaan.

De boost wordt daarom bij voorkeur rechtstreeks gevoed met zonne-energie of goedkope netstroom. De elektrische accu blijft beschikbaar voor momenten waarop opgeslagen elektriciteit meer waarde heeft.

7. De cyclus beëindigen

Wanneer de warmtepomp aangeeft dat de warmwatercyclus is afgelopen:

  • Controleert Homey of het daadwerkelijk om de geplande boost gaat;
  • Wordt het setpoint teruggezet naar 52 °C;
  • Worden looptijd en werkelijk energieverbruik opgeslagen.

Als extra beveiliging wordt het setpoint na maximaal 120 minuten eveneens teruggezet.

8. Voorspelling vergelijken met werkelijkheid

Na afloop kunnen de voorspelde en werkelijke waarden worden vergeleken:

  • Voorspeld en werkelijk energieverbruik;
  • Voorspelde en werkelijke looptijd;
  • Verwachte en werkelijke kosten;
  • Verwachte en werkelijke besparing.

De nieuwe meetwaarden worden weer toegevoegd aan de historie. Hierdoor kan de voorspelling na iedere geslaagde cyclus nauwkeuriger worden.

Kort samengevat: Homey gebruikt dynamische kwartierprijzen, de actuele boilertemperatuur en historische warmwatercycli om de boost te plannen. De boost wordt alleen uitgevoerd wanneer het verwachte financiële voordeel voldoende groot blijft. Directe zonne-energie en goedkope netstroom mogen de boost voeden, maar de elektrische thuisaccu wordt hiervoor niet ontladen.