Terma Blue Line (bluetooth radiators and heating elements)

Hi, I just installed one heating element from Terma:
https://en.termaheat.com/blueline#blueLine_about

The element can be controlled via Bluetooth using their proprietary app:

Room temperature and radiator temperature are available in the app, on top of setting the target room (or radiator) temperature.

Is there any way to control the heating element via HA? Has anyone tried to control these unit?

Hello ReX, I just read your post. I would be interested too !!! Is there now a possibility of integration? For the official components “Terma” is not listed, but maybe there is a “custom_component” or a way about “NodeRED” or another possibility?

Apparently there is no way to integrate as they don’t want to expose the API (I’ve asked). Eventually I decided to manage the heating element with an external wifi switch (Shelly 1).

Hello ReX,
Thank you for the feedback. I do not have a heating element from Terma yet, but I probably wanted to buy one.

Apologies for reviving this very old topic. We are considering buying multiple heaters with Therma Moa blue heating elements, and I am very much interested in this solution of using a Shelley 1 to manage the heater. My question is how the power setting of the heater works. I can imagine that it always starts in the lowest setting whenever it is turned on, or does it remember the setting from when it turned off? Thanks!

Hi,

I’ve managed to reverse engineer the protocol. Believe me it wasn’t easy.

Bluetooth characteristics

  • Room temperature: D97352B1-D19E-11E2-9E96-0800200C9A66
  • Heating element temperature: D97352B2-D19E-11E2-9E96-0800200C9A66
  • Operating mode: D97352B3-D19E-11E2-9E96-0800200C9A66

Operating modes

  • 0: Off
  • 5: Manual operation, based on room temperature
  • 6: Manual operation, based on heating element temperature
  • 7: Schedule, based on room temperature
  • 8: Schedule, based on heating element temperature

Schedule
No idea, use the app or set your schedule in HA.

Timer
No idea, use the app or manually set a timer on HA.

Temperature encoding
That’s where it gets interesting. Temperatures are encoded on 4 bytes.
First and second byte are the current temp.
Third and forth byte are the target temp.

To calculate the current temp in ºC = ((firstByteToInt * 255) + secondByeToInt) / 10
To calculate the target temp in ºC = ((thirdByteToInt * 255) + fourthByteToInt) / 10

Examples (I’m representing the value in Hex, so it is readable):
30º 012d
35º 015F
40º 0191
45º 01c2
50º 01f5
55º 0232
60º 0264

Setting target temperature
You have to write the appropriate characteristic.

  • If you’re running in mode 5 (room temp) use “D97352B1-D19E-11E2-9E96-0800200C9A66” (temp range: 15-30 ºC).
  • If you’re running in mode 6 (heating element temp) use “D97352B2-D19E-11E2-9E96-0800200C9A66” (temp range: 30-60 ºC).

You’ll first have to convert your target temp to hex. I have no time right now to write the exact formula but you have to reverse the other formulas I’ve posted.

Let’s say you want to set the unit to 30ºC, that’s 012d

You have to send to set the characteristics to [current temp][target temp]; current temp can be 0000 because you don’t care about that.

Example: 0000012d will set the heating element to heat to 30º (either room temp or heating element temp, depending on the characteristic you write).

3 Likes

Hi,

Thank you for your contribution. I am trying to reverse engineer a generic unbranded radiator that has Bluetooth control but the app was discontinued a long time ago and so am struggling to reverse engineer this.

I am currently using gatttools on linux but am not sure how to decode the values. I tried to work out what you did by using one of your values for example “012d”. I’ve tried different online tools for example:

The second link provides a very similar value but it’s 301.

Is this something you can help me with please or is there a website you could link to?

Thanks

Hi Toby.
As ptuk says, you have to convert from hex to decimal as you are doing, but them you need to divide the result by 10:
To calculate the current temp in ºC = ((firstByteToInt * 255) + secondByeToInt) / 10
To calculate the target temp in ºC = ((thirdByteToInt * 255) + fourthByteToInt) / 10

That’s why the second link gives you 012d → 301. Divide that by 10 and you get the value 30.

1 Like

What do you use to send these BLE commands to the radiator?

Thinking of using the new esphome ble remote with esp32.

gatttools does it

Thanks. I’m not familiar with gattools so will get reading :slight_smile:

Thanks for sharing your work!! Would it be possible for you to share the code for this as well?

Javascript code:

const noble = require('@abandonware/noble');

const temperatureTermaToCelsius = (b1, b2) => ((parseInt(b1, 16) * 255) + parseInt(b2, 16)) / 10;

const temperatureCelsiusToTerma = (temp) => {
  const base = Math.floor(temp * 10);

  const firstByte = Math.floor(base / 255);
  const secondByte = base % 255;

  return [
    firstByte.toString(16).padStart(2, '0'),
    secondByte.toString(16).padStart(2, '0'),
  ];
};

const modeTermaToConst = (b1) => {
  switch (parseInt(b1, 16)) {
    case 6: // MANUAL_HEATER
      return 'HEAT';

    case 8: // SCHEDULE_HEATER
      return 'AUTO';

    case 0:
      return 'OFF';

    default:
      return null;
  }
};

const modeConstToTerma = (val) => {
  switch (val) {
    case 'HEAT': // MANUAL_HEATER
      return '06';

    case 'AUTO': // SCHEDULE_HEATER
      return '08';

    case 'OFF':
      return '00';

    default:
      return null;
  }
};

const init = (notify) => {
  let firstScanCompleted = false;
  let modeCharacteristic = null;
  let heatingElementCharacteristic = null;
  let poll;
  let stopPolling = false;

  console.log('⏳ [1/5] Booting...');

  const connectingTimeout = setTimeout(() => {
    console.log('❌ Connection timed out. Exiting...');
    process.exit(0);
  }, 60000);

  noble.on('stateChange', async (state) => {
    if (state === 'poweredOn' && !firstScanCompleted) {
      console.log('⏳ [2/5] Scanning...');
      firstScanCompleted = true;
      await noble.startScanningAsync([], false);
    }
  });

  noble.on('discover', async (peripheral) => {
    if (peripheral.advertisement.localName !== 'MOA Blue TERMA') {
      console.log(`        Found ${peripheral.address || '00:00:00:00'} (${peripheral.advertisement.localName}) - Ignoring...`);
      return;
    }

    console.log(`⏳ [3/5] Terma found (${peripheral.address || '00:00:00:00'} -- ${peripheral.advertisement.localName}). Connecting...`);

    await noble.stopScanningAsync();
    await peripheral.connectAsync();

    console.log('⏳ [4/5] Connected! Reading Services...');

    const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync([],
      []);

    characteristics.forEach((c) => {
      if (c.uuid.startsWith('d97352b3')) {
        modeCharacteristic = c;
      } else if (c.uuid.startsWith('d97352b2')) {
        heatingElementCharacteristic = c;
      }
    });

    clearTimeout(connectingTimeout);
    console.log('✅ [5/5] Services read. Will be polling shortly...');

    poll = async () => {
      if (stopPolling) {
        console.log('Polling skipped.');
        return;
      }

      const pollingTimeout = setTimeout(() => {
        console.log('❌ Polling timed out. Exiting...');
        process.exit(0);
      }, 30000);

      console.log('Polling...');
      console.log('  Reading mode...');
      const modeValue = (await modeCharacteristic.readAsync()).toString('hex');

      console.log('  Reading heating element...');
      const tempValue = (await heatingElementCharacteristic.readAsync()).toString('hex');

      const [b1, b2, b3, b4] = tempValue.match(/[0-9a-f]{2}/gi);

      const currentTemperature = temperatureTermaToCelsius(b1, b2);
      const targetTemperature = temperatureTermaToCelsius(b3, b4);

      notify({
        mode: modeTermaToConst(modeValue),
        currentTemperature,
        targetTemperature,
      });

      clearTimeout(pollingTimeout);
      console.log('Polling complete!');
    };

    poll();
    setInterval(poll, 60000);
  });

  const writeWithTimeout = async (requestValue, characteristic, bufferValue, label) => {
    const writeTimeout = setTimeout(() => {
      console.log('❌ Write timed out. Exiting...');
      process.exit(0);
    }, 30000);

    const request = Buffer.from(bufferValue, 'hex');
    console.log(`Writing ${label}: ${requestValue} - 0x${request.toString('hex')}`);

    const write = await characteristic.writeAsync(request, false);
    clearTimeout(writeTimeout);

    return write;
  };

  return {
    updateHeatingUnit: async ({ mode: requestedMode, targetTemperature: requestedTemp }) => {
      try {
        stopPolling = true;

        if (requestedMode) {
          await writeWithTimeout(requestedMode, modeCharacteristic, modeConstToTerma(requestedMode), 'Mode');
        }

        if (requestedTemp) {
          await writeWithTimeout(requestedTemp, heatingElementCharacteristic, `0000${temperatureCelsiusToTerma(requestedTemp).join('')}`, 'Temp');
        }

        stopPolling = false;
        try {
          poll();
        } catch (e) {
          // Do nothing
        }
      } catch (e) {
        stopPolling = false;
        throw e;
      }
    },
  };
};

init accepts a callback that will be called every time the status is polled and returns a updateHeatingUnit function to issue commands.