8 Commits

Author SHA1 Message Date
Yurii
67adb3b9cf Merge branch 'async' into passive_ble 2026-01-26 02:23:19 +03:00
Yurii
ced0385d5b Merge branch 'master' into async 2026-01-26 02:19:54 +03:00
Yurii
7fcca3c4aa chore: fix ha blueprints to support all source sensors 2026-01-26 02:09:57 +03:00
Yurii
2f6bd237c7 chore: updated ha blueprints for new releases 2026-01-26 01:59:03 +03:00
Yurii
e4d1ba7d7b refactor: added different timeouts for wired and wireless sensors 2026-01-26 01:03:26 +03:00
Yurii
aae53d605a fix: move OpenThermTask to 0 core 2026-01-25 22:44:58 +03:00
Yurii
de9276d04e refactor: cosmetic changes 2026-01-25 22:42:48 +03:00
Yurii
cb8cd7c26e feat: added support BTHome v2 format for BLE sensors #215 2026-01-25 22:25:58 +03:00
6 changed files with 261 additions and 95 deletions

View File

@@ -1,5 +1,5 @@
# Blueprint for reporting indoor/outdoor temperature to OpenTherm Gateway from any home assistant sensor # Blueprint for reporting indoor/outdoor temperature to OpenTherm Gateway from any home assistant sensor
# Updated: 03.09.2024 # Updated: 26.01.2026
blueprint: blueprint:
name: Report temp to OpenTherm Gateway name: Report temp to OpenTherm Gateway
@@ -15,7 +15,6 @@ blueprint:
multiple: false multiple: false
filter: filter:
- domain: sensor - domain: sensor
device_class: temperature
target_entity: target_entity:
name: Target entity name: Target entity
description: "Usually ``number.opentherm_indoor_temp`` or ``number.opentherm_outdoor_temp``" description: "Usually ``number.opentherm_indoor_temp`` or ``number.opentherm_outdoor_temp``"
@@ -38,8 +37,12 @@ condition:
value_template: "{{ states(source_entity) != 'unavailable' and states(target_entity) != 'unavailable' }}" value_template: "{{ states(source_entity) != 'unavailable' and states(target_entity) != 'unavailable' }}"
action: action:
- if: - if:
- condition: template - condition: or
value_template: "{{ (states(source_entity)|float(0) - states(target_entity)|float(0)) | abs | round(2) >= 0.01 }}" conditions:
- condition: template
value_template: "{{ (states(source_entity)|float(0) - states(target_entity)|float(0)) | abs | round(2) >= 0.01 }}"
- condition: template
value_template: "{{ (as_timestamp(now()) - as_timestamp(states[target_entity].last_updated)) | int(0) > 300 }}"
then: then:
- service: number.set_value - service: number.set_value
data: data:

View File

@@ -1,5 +1,5 @@
# Blueprint for reporting temperature to OpenTherm Gateway from home assistant weather integration # Blueprint for reporting temperature to OpenTherm Gateway from home assistant weather integration
# Updated: 03.09.2024 # Updated: 26.01.2026
blueprint: blueprint:
name: Report temp to OpenTherm Gateway from Weather name: Report temp to OpenTherm Gateway from Weather
@@ -37,8 +37,12 @@ condition:
value_template: "{{ states(source_entity) != 'unavailable' and states(target_entity) != 'unavailable' }}" value_template: "{{ states(source_entity) != 'unavailable' and states(target_entity) != 'unavailable' }}"
action: action:
- if: - if:
- condition: template - condition: or
value_template: "{{ (state_attr(source_entity, 'temperature')|float(0) - states(target_entity)|float(0)) | abs | round(2) >= 0.1 }}" conditions:
- condition: template
value_template: "{{ (state_attr(source_entity, 'temperature')|float(0) - states(target_entity)|float(0)) | abs | round(2) >= 0.1 }}"
- condition: template
value_template: "{{ (as_timestamp(now()) - as_timestamp(states[target_entity].last_updated)) | int(0) > 300 }}"
then: then:
- service: number.set_value - service: number.set_value
data: data:

View File

@@ -132,8 +132,6 @@ protected:
tMqtt->disable(); tMqtt->disable();
} }
Sensors::setConnectionStatusByType(Sensors::Type::MANUAL, !settings.mqtt.enabled || vars.mqtt.connected, false);
} else { } else {
if (this->ntpStarted) { if (this->ntpStarted) {
this->ntpStarted = false; this->ntpStarted = false;

View File

@@ -43,7 +43,7 @@ protected:
} }
BaseType_t getTaskCore() override { BaseType_t getTaskCore() override {
return 1; return 0;
} }
int getTaskPriority() override { int getTaskPriority() override {

View File

@@ -40,15 +40,15 @@ public:
auto deviceName = device->getName(); auto deviceName = device->getName();
auto deviceRssi = device->getRSSI(); auto deviceRssi = device->getRSSI();
Log.straceln( Log.sinfoln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu '%s': discovered device %s, name: %s, RSSI: %hhd"), FPSTR(L_SENSORS_BLE), F("Sensor #%hhu '%s': discovered device %s, name: %s, RSSI: %hhd"),
sensorId, sSensor.name, sensorId, sSensor.name,
deviceAddress.toString().c_str(), deviceName.c_str(), deviceRssi deviceAddress.toString().c_str(), deviceName.c_str(), deviceRssi
); );
if (!device->haveServiceData()) { if (!device->haveServiceData()) {
Log.straceln( Log.swarningln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu '%s': not found service data"), FPSTR(L_SENSORS_BLE), F("Sensor #%hhu '%s': not found service data!"),
sensorId, sSensor.name sensorId, sSensor.name
); );
return; return;
@@ -60,84 +60,243 @@ public:
sensorId, sSensor.name, serviceDataCount sensorId, sSensor.name, serviceDataCount
); );
if (parseAtcData(device, sensorId) || parsePvvxData(device, sensorId) || parseBTHomeData(device, sensorId)) {
// update rssi
Sensors::setValueById(sensorId, deviceRssi, Sensors::ValueType::RSSI, false, false);
} else {
Log.swarningln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu '%s': unsupported data format!"),
sensorId, sSensor.name
);
}
}
static bool parseAtcData(const NimBLEAdvertisedDevice* device, uint8_t sensorId) {
NimBLEUUID serviceUuid((uint16_t) 0x181A); NimBLEUUID serviceUuid((uint16_t) 0x181A);
auto serviceData = device->getServiceData(serviceUuid); auto serviceData = device->getServiceData(serviceUuid);
if (!serviceData.size()) { if (!serviceData.size()) {
Log.straceln( Log.straceln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu '%s': NOT found %s env service data"), FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: not found ATC1441 data"),
sensorId, sSensor.name, serviceUuid.toString().c_str() sensorId, serviceUuid.toString().c_str()
); );
return; return false;
} else if (serviceData.size() != 13) {
Log.straceln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: not in ATC1441 format"),
sensorId, serviceUuid.toString().c_str()
);
return false;
} }
Log.straceln( Log.snoticeln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu '%s': found %s env service data"), FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: found ATC1441 format"),
sensorId, sSensor.name, serviceUuid.toString().c_str() sensorId, serviceUuid.toString().c_str()
); );
float temperature, humidity; // Temperature (2 bytes, big-endian)
uint16_t batteryMv; float temperature = (
uint8_t batteryLevel; (static_cast<uint8_t>(serviceData[6]) << 8) | static_cast<uint8_t>(serviceData[7])
) * 0.1f;
if (serviceData.size() == 13) {
// atc1441 format
// Temperature (2 bytes, big-endian)
temperature = (
(static_cast<uint8_t>(serviceData[6]) << 8) | static_cast<uint8_t>(serviceData[7])
) * 0.1f;
// Humidity (1 byte)
humidity = static_cast<uint8_t>(serviceData[8]);
// Battery mV (2 bytes, big-endian)
batteryMv = (static_cast<uint8_t>(serviceData[10]) << 8) | static_cast<uint8_t>(serviceData[11]);
// Battery level (1 byte)
batteryLevel = static_cast<uint8_t>(serviceData[9]);
} else if (serviceData.size() == 15) {
// custom pvvx format
// Temperature (2 bytes, little-endian)
temperature = (
(static_cast<uint8_t>(serviceData[7]) << 8) | static_cast<uint8_t>(serviceData[6])
) * 0.01f;
// Humidity (2 bytes, little-endian)
humidity = (
(static_cast<uint8_t>(serviceData[9]) << 8) | static_cast<uint8_t>(serviceData[8])
) * 0.01f;
// Battery mV (2 bytes, little-endian)
batteryMv = (static_cast<uint8_t>(serviceData[11]) << 8) | static_cast<uint8_t>(serviceData[10]);
// Battery level (1 byte)
batteryLevel = static_cast<uint8_t>(serviceData[12]);
} else {
// unknown format
Log.straceln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu '%s': unknown data format (size: %i)"),
sensorId, sSensor.name, serviceData.size()
);
return;
}
Log.straceln(
FPSTR(L_SENSORS_BLE),
F("Sensor #%hhu '%s', received temp: %.2f; humidity: %.2f, battery voltage: %hu, battery level: %hhu"),
sensorId, sSensor.name,
temperature, humidity, batteryMv, batteryLevel
);
// update data
Sensors::setValueById(sensorId, temperature, Sensors::ValueType::TEMPERATURE, true, true); Sensors::setValueById(sensorId, temperature, Sensors::ValueType::TEMPERATURE, true, true);
// Humidity (1 byte)
float humidity = static_cast<uint8_t>(serviceData[8]);
Sensors::setValueById(sensorId, humidity, Sensors::ValueType::HUMIDITY, true, true); Sensors::setValueById(sensorId, humidity, Sensors::ValueType::HUMIDITY, true, true);
// Battery level (1 byte)
uint8_t batteryLevel = static_cast<uint8_t>(serviceData[9]);
Sensors::setValueById(sensorId, batteryLevel, Sensors::ValueType::BATTERY, true, true); Sensors::setValueById(sensorId, batteryLevel, Sensors::ValueType::BATTERY, true, true);
// update rssi // Battery mV (2 bytes, big-endian)
Sensors::setValueById(sensorId, deviceRssi, Sensors::ValueType::RSSI, false, false); uint16_t batteryMv = (static_cast<uint8_t>(serviceData[10]) << 8) | static_cast<uint8_t>(serviceData[11]);
// Log
Log.snoticeln(
FPSTR(L_SENSORS_BLE),
F("Sensor #%hhu, received temp: %.2f; humidity: %.2f, battery voltage: %hu, battery level: %hhu"),
sensorId, temperature, humidity, batteryMv, batteryLevel
);
return true;
}
static bool parsePvvxData(const NimBLEAdvertisedDevice* device, uint8_t sensorId) {
NimBLEUUID serviceUuid((uint16_t) 0x181A);
auto serviceData = device->getServiceData(serviceUuid);
if (!serviceData.size()) {
Log.straceln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: not found PVVX data"),
sensorId, serviceUuid.toString().c_str()
);
return false;
} else if (serviceData.size() != 15) {
Log.straceln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: not in PVVX format"),
sensorId, serviceUuid.toString().c_str()
);
return false;
}
Log.snoticeln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: found PVVX format"),
sensorId, serviceUuid.toString().c_str()
);
// Temperature (2 bytes, little-endian)
float temperature = (
(static_cast<uint8_t>(serviceData[7]) << 8) | static_cast<uint8_t>(serviceData[6])
) * 0.01f;
Sensors::setValueById(sensorId, temperature, Sensors::ValueType::TEMPERATURE, true, true);
// Humidity (2 bytes, little-endian)
float humidity = (
(static_cast<uint8_t>(serviceData[9]) << 8) | static_cast<uint8_t>(serviceData[8])
) * 0.01f;
Sensors::setValueById(sensorId, humidity, Sensors::ValueType::HUMIDITY, true, true);
// Battery level (1 byte)
uint8_t batteryLevel = static_cast<uint8_t>(serviceData[12]);
Sensors::setValueById(sensorId, batteryLevel, Sensors::ValueType::BATTERY, true, true);
// Battery mV (2 bytes, little-endian)
uint16_t batteryMv = (static_cast<uint8_t>(serviceData[11]) << 8) | static_cast<uint8_t>(serviceData[10]);
// Log
Log.snoticeln(
FPSTR(L_SENSORS_BLE),
F("Sensor #%hhu, received temp: %.2f; humidity: %.2f, battery voltage: %hu, battery level: %hhu"),
sensorId, temperature, humidity, batteryMv, batteryLevel
);
return true;
}
static bool parseBTHomeData(const NimBLEAdvertisedDevice* device, uint8_t sensorId) {
NimBLEUUID serviceUuid((uint16_t) 0xFCD2);
auto serviceData = device->getServiceData(serviceUuid);
if (!serviceData.size()) {
Log.straceln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: not found BTHome data"),
sensorId, serviceUuid.toString().c_str()
);
return false;
} else if ((serviceData[0] & 0xE0) != 0x40) {
Log.straceln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: unsupported BTHome version"),
sensorId, serviceUuid.toString().c_str()
);
return false;
} else if ((serviceData[0] & 0x01) != 0) {
Log.straceln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: unsupported BTHome encrypted data"),
sensorId, serviceUuid.toString().c_str()
);
return false;
}
Log.snoticeln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, service %s: found BTHome format"),
sensorId, serviceUuid.toString().c_str()
);
bool foundData = false;
size_t serviceDataPos = 0;
while (serviceDataPos < serviceData.size()) {
uint8_t objectId = serviceData[serviceDataPos++];
switch (objectId) {
// Packet ID (1 byte)
case 0x00:
serviceDataPos += 1;
break;
// Battery (1 byte)
case 0x01: {
if (serviceDataPos + 1 > serviceData.size()) {
break;
}
uint8_t batteryLevel = static_cast<uint8_t>(serviceData[serviceDataPos]);
Sensors::setValueById(sensorId, batteryLevel, Sensors::ValueType::BATTERY, true, true);
serviceDataPos += 1;
foundData = true;
Log.snoticeln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, received battery level: %hhu"),
sensorId, batteryLevel
);
break;
}
// Temperature (2 bytes, little-endian)
case 0x02: {
if (serviceDataPos + 2 > serviceData.size()) {
break;
}
int16_t rawTemp = (static_cast<int16_t>(serviceData[serviceDataPos + 1]) << 8)
| static_cast<uint8_t>(serviceData[serviceDataPos]);
float temperature = static_cast<float>(rawTemp) * 0.01f;
Sensors::setValueById(sensorId, temperature, Sensors::ValueType::TEMPERATURE, true, true);
serviceDataPos += 2;
foundData = true;
Log.snoticeln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, received temp: %.2f"),
sensorId, temperature
);
break;
}
// Humidity (2 bytes, little-endian)
case 0x03: {
if (serviceDataPos + 2 > serviceData.size()) {
break;
}
uint16_t rawHumidity = (static_cast<uint16_t>(serviceData[serviceDataPos + 1]) << 8)
| static_cast<uint8_t>(serviceData[serviceDataPos]);
float humidity = static_cast<float>(rawHumidity) * 0.01f;
Sensors::setValueById(sensorId, humidity, Sensors::ValueType::HUMIDITY, true, true);
serviceDataPos += 2;
foundData = true;
Log.snoticeln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, received humidity: %.2f"),
sensorId, humidity
);
break;
}
// Voltage (2 bytes, little-endian)
case 0x0C: {
if (serviceDataPos + 2 > serviceData.size()) {
break;
}
uint16_t batteryMv = (static_cast<uint16_t>(serviceData[serviceDataPos + 1]) << 8)
| static_cast<uint8_t>(serviceData[serviceDataPos]);
serviceDataPos += 2;
foundData = true;
Log.snoticeln(
FPSTR(L_SENSORS_BLE), F("Sensor #%hhu, received battery voltage: %hu"),
sensorId, batteryMv
);
break;
}
}
}
return foundData;
} }
}; };
#endif #endif
@@ -169,7 +328,8 @@ public:
} }
protected: protected:
const unsigned int disconnectedTimeout = 180000u; const unsigned int wiredDisconnectTimeout = 180000u;
const unsigned int wirelessDisconnectTimeout = 600000u;
const unsigned short dallasSearchInterval = 60000u; const unsigned short dallasSearchInterval = 60000u;
const unsigned short dallasPollingInterval = 10000u; const unsigned short dallasPollingInterval = 10000u;
const unsigned short globalPollingInterval = 15000u; const unsigned short globalPollingInterval = 15000u;
@@ -629,12 +789,16 @@ protected:
} else if (rSensor.connected && sSensor.purpose == Sensors::Purpose::NOT_CONFIGURED) { } else if (rSensor.connected && sSensor.purpose == Sensors::Purpose::NOT_CONFIGURED) {
Sensors::setConnectionStatusById(sensorId, false, false); Sensors::setConnectionStatusById(sensorId, false, false);
} else if (sSensor.type != Sensors::Type::MANUAL && rSensor.connected && (millis() - rSensor.activityTime) > this->disconnectedTimeout) { } else if (rSensor.connected) {
Sensors::setConnectionStatusById(sensorId, false, false); if (sSensor.type == Sensors::Type::MANUAL || sSensor.type == Sensors::Type::BLUETOOTH) {
if ((millis() - rSensor.activityTime) > this->wirelessDisconnectTimeout) {
Sensors::setConnectionStatusById(sensorId, false, false);
}
}/* else if (!rSensor.connected) { } else if ((millis() - rSensor.activityTime) > this->wiredDisconnectTimeout) {
rSensor.connected = true; Sensors::setConnectionStatusById(sensorId, false, false);
}*/ }
}
} }
} }

View File

@@ -2099,21 +2099,18 @@ bool jsonToSensorResult(const uint8_t sensorId, const JsonVariantConst src) {
return false; return false;
} }
auto& dst = Sensors::results[sensorId];
bool changed = false;
// value // value
if (!src[FPSTR(S_VALUE)].isNull()) { if (!src[FPSTR(S_VALUE)].isNull()) {
float value = src[FPSTR(S_VALUE)].as<float>(); return Sensors::setValueById(
sensorId,
uint8_t vType = static_cast<uint8_t>(Sensors::ValueType::PRIMARY); src[FPSTR(S_VALUE)].as<float>(),
if (fabsf(value - dst.values[vType]) > 0.0001f) { Sensors::ValueType::PRIMARY,
dst.values[vType] = roundf(value, 2); true,
changed = true; true
} );
} }
return changed; return false;
} }
void varsToJson(const Variables& src, JsonVariant dst) { void varsToJson(const Variables& src, JsonVariant dst) {