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).

6 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.

2 Likes

Thank you so much for your work!

In your experience, what is the Bluetooth range of these radiators? I have a hard time connecting to them unless I’m almost right next to them.

It’s not great. I get a lot of disconnections, I’m not sure if it’s a matter of signal.

It works in my apartment, 2 plaster walls away (I run the server on a mac that has a good built in Bluetooth)

Hello,
Could you let me know how to add these javascript sources to home assistant?

@ptuk I’d also love to know how you run this code. I’ve been trying to figure if it should be put into node.js or node red, but not had any success with either

I’ve managed to run the code from command line by adding an init(); line to the end and running it from node.js, however, it fails to read the services/capabilities from the device.
Not sure if mine is just a slightly different model, or if something has changed on them, but using one of the ‘noble’ examples to do a barebones connection, also seems to return empty arrays for them

"cc:22:xx:xx:xx:xx":{"address":"cc:xx:xx:xx:xx:xx","addressType":"public","connectable":true,"advertisement":{"localName":"MOA Blue TERMA","txPowerLevel":8,"serviceData":[],"serviceUuids":[],"solicitationServiceUuids":[],"serviceSolicitationUuids":[]},"rssi":-85,"count":2,"hasScanResponse":false},

Hi there. there’s no easy way to run it. Noble has proven very unreliable on this device.

What I do is having a node server that runs gatttool in interactive mode, reading the Bluetooth characteristics and publishing updates via websocket. It also accepts post requests to set the state of the unit.

I could open source it if you think that would help.

Thanks for the offer @ptuk , I’m not sure it would though.
I can’t see that I can get anything working while the device isn’t sending services or capabilities.

It’s not just this way with the noble / javascript code, I’m also not getting them when using gatttools or bluetoothctl

Did you have to pair the device to the connecting computer or did it work for you without?