Terma Blue Line (bluetooth radiators and heating elements)

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?

Yes, you have to pair it.

try bluetoothctl (interactive mode, put the heating unit in pairing mode) and then type:

default-agent
scan on
pair AA:BB:CC:DD:EE:FF
char-read-hnd 19
char-read-hnd 17
disconnect
exit

Where AA:BB:CC:DD:EE:FF is the MAC address of the unit.

char-read-hnd 19 will get you the current mode (08 in my case when it’s on schedule, heating element temp)

char-read-hnd 17 will get you something like AABBCCDD where AABB is current temp and CCDD is target temp; call temperatureTermaToCelsius('AA', 'BB') or temperatureTermaToCelsius('CC','DD') to convert to celsius.

To change the mode:
char-write-req 19 00 will put in mode “0” which means off.

1 Like

I can’t for the life of me get it to pair.
Hold down the icon until it flashes blue/green

Then when I pair I get:

[bluetooth]# pair AA:BB:CC:DD:EE:FF
Attempting to pair with AA:BB:CC:DD:EE:FF
[CHG] Device AA:BB:CC:DD:EE:FF Connected: yes
Failed to pair: org.bluez.Error.ConnectionAttemptFailed
[CHG] Device AA:BB:CC:DD:EE:FF Connected: no

(obviously with my MAC not AA:BB:CC:DD:EE:FF)
Maybe there’s something up with the bluetooth on my RPi :man_shrugging:

I’ve managed to get it to work on an old Buster as well as on the latest Bullseye with the standard RPI bluetooth.

Make sure you do scan on first. Check the list, does it sees you heating unit? It might be a matter of being too far / signal not being great.

Yeah, it usually sees it straight away on the scan, and then the pairing connect/fail happens really fast :man_shrugging:
Will have a play around with it when I get more time.
Cheers :+1:

It can be tricky at time to pair. Try turning the unit off (by unplugging it / turning the mains switch off) and on again. Do scan first, check the unit is shown, then put it in pairing mode and pair it on RPI (it will ask you for the pin code which is 123456). Once that’s done, the rest is easy :blush:

I just cannot get it to pair :sob:
Tried so many times, but it fails every time.
Think I’m going to give up for now, but I may come back to it in a few months when I’ve dredged up the enthusiasm to try again :rofl:

Have anyone managed to create an Esphome project / component for this?

I have started, but paused attempts to connect from an ESPHome board. It was initially successful: I could pair and retrieve data as the first step, with the intention of adding control later.

However, the problem I encountered was that the connection/pairing (? not sure which) would come undone after a day or two. As far as I could tell at the time the only way to get it to reconnect was to put the radiator back into pairing mode.

I’ve not had time to dig deeper but it seemed as though the problem was possibly on the ESPHome+Bluetooth stack side, forgetting something about the pairing. A paired iPhone+Terma app, by contrast, has no trouble re-establishing a connection to the radiator after any amount of time.

As a general note for anyone trying to reverse engineer the protocol: the Android Terma BlueLine Next app APK is a Phonegap/Cordova-based app, so contains a wealth of interesting and quite readable JS code :wink:

I’m connecting to the Terma unit by having NodeJS running gatttools in interactive mode, so I can poll from time to time.

I initially had it opening a connection, poll, close a connection but that proved to be problematic since it uses a lot more Bluetooth traffic and it is more likely to fail in busy radio environments. I’ve switched to persistent Bluetooth connection with reconnect logic in case of failure.

It works well. So I guess you have an issue with the hardware. Either on esp or terma side.

Re: the app, I didn’t noticed it is Cordova. But android apps can be decompiled anyhow…

Did you get any further with this in ESPHome @rnorth?

I’m about to get a couple of these and I really am hoping that ESPHome Bluetooth proxy devices could be used to let me move all the control of the radiators (other than local dumb button control) into Home Assistant.

I see Terma have launched a WiFi controlled unit (Grzałka VEO) - but without any open API or local control, and I bet that will be harder to reverse engineer, especially as we already have the Bluetooth control commands courtesy of ptuk (thanks again @ptuk!)

You’re very welcome! :blush: One day I’ll get to create a Terma platform for HA but at the moment I have no clue on where to start from

Did you get any further with this in ESPHome @rnorth?

I’m afraid not - the benefit:effort ratio wasn’t high enough for me this time.

I do think that ESPHome bluetooth proxies, and a proper integration in HA that connects through them, would be the way to go!