mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
[remote_base] Add Nexus RF protocol support (#13577)
Co-authored-by: zry98 <dev@zry.io> Co-authored-by: Citizen <kvirbickas@gmail.com> Co-authored-by: Citizen07 <34106434+Citizen07@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org> Signed-off-by: zry98 <dev@zry.io>
This commit is contained in:
co-authored by
zry98
Citizen
Citizen07
J. Nick Koston
J. Nick Koston
parent
29db7b3f51
commit
2692e0f189
@@ -9,6 +9,7 @@ from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_BATTERY_LEVEL,
|
||||
CONF_BUTTON,
|
||||
CONF_CARRIER_FREQUENCY,
|
||||
CONF_CHANNEL,
|
||||
@@ -20,7 +21,9 @@ from esphome.const import (
|
||||
CONF_DELTA,
|
||||
CONF_DEVICE,
|
||||
CONF_FAMILY,
|
||||
CONF_FORCE_UPDATE,
|
||||
CONF_GROUP,
|
||||
CONF_HUMIDITY,
|
||||
CONF_ID,
|
||||
CONF_INDEX,
|
||||
CONF_INVERTED,
|
||||
@@ -37,6 +40,7 @@ from esphome.const import (
|
||||
CONF_SOURCE,
|
||||
CONF_STATE,
|
||||
CONF_SYNC,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TIMES,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_TYPE_ID,
|
||||
@@ -2161,6 +2165,62 @@ register_protocol_action(
|
||||
)
|
||||
|
||||
|
||||
# Nexus
|
||||
NexusData, NexusBinarySensor, NexusTrigger, _, NexusDumper = declare_protocol("Nexus")
|
||||
NEXUS_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=4),
|
||||
cv.Required(CONF_ADDRESS): cv.hex_uint8_t,
|
||||
}
|
||||
)
|
||||
NEXUS_ACTION_SCHEMA = NEXUS_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_TEMPERATURE): cv.All(
|
||||
cv.float_, cv.Range(min=-204.8, max=204.7)
|
||||
),
|
||||
cv.Required(CONF_HUMIDITY): cv.uint8_t,
|
||||
cv.Optional(CONF_BATTERY_LEVEL, default=True): cv.boolean,
|
||||
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@register_binary_sensor("nexus", NexusBinarySensor, NEXUS_SCHEMA)
|
||||
def nexus_binary_sensor(var: MockObj, config: ConfigType) -> None:
|
||||
cg.add(
|
||||
var.set_data(
|
||||
cg.StructInitializer(
|
||||
NexusData,
|
||||
("address", config[CONF_ADDRESS]),
|
||||
("channel", config[CONF_CHANNEL]),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@register_trigger("nexus", NexusTrigger, NexusData)
|
||||
def nexus_trigger(var: MockObj, config: ConfigType) -> None:
|
||||
"""The trigger takes no options beyond the automation."""
|
||||
|
||||
|
||||
@register_dumper("nexus", NexusDumper)
|
||||
def nexus_dumper(var: MockObj, config: ConfigType) -> None:
|
||||
"""The dumper takes no options."""
|
||||
|
||||
|
||||
register_protocol_action(
|
||||
"nexus",
|
||||
"Nexus",
|
||||
NEXUS_ACTION_SCHEMA,
|
||||
(CONF_CHANNEL, "channel", cg.uint8),
|
||||
(CONF_ADDRESS, "address", cg.uint8),
|
||||
(CONF_TEMPERATURE, "temperature", cg.float_),
|
||||
(CONF_HUMIDITY, "humidity", cg.uint8),
|
||||
(CONF_BATTERY_LEVEL, "battery_level", cg.bool_),
|
||||
(CONF_FORCE_UPDATE, "force_update", cg.bool_),
|
||||
)
|
||||
|
||||
|
||||
# Midea
|
||||
MideaData, MideaBinarySensor, MideaTrigger, MideaAction, MideaDumper = declare_protocol(
|
||||
"Midea"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "nexus_protocol.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::remote_base {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "remote.nexus");
|
||||
|
||||
/**
|
||||
* Example Nexus (Digoo) temperature & humidity sensor packet (36 data bits, each a mark and a space):
|
||||
* 10000101 | 1 | 0 | 01 | 000010001010 | 1111 | 01000101
|
||||
* address (sensor ID) | battery | force update | channel | temperature | - | humidity
|
||||
*
|
||||
* The channel is sent 0-based and shown 1-based, temperature is 12 bit signed scaled by 10,
|
||||
* `-`s are constant bits.
|
||||
*
|
||||
* Ref: https://manual.pilight.org/protocols/433.92/weather/nexus.html
|
||||
*/
|
||||
|
||||
static constexpr uint8_t NBITS = 36;
|
||||
|
||||
static constexpr uint32_t SYNC_HIGH_US = 500;
|
||||
static constexpr uint32_t SYNC_LOW_US = 3900;
|
||||
static constexpr uint32_t BIT_HIGH_US = 500;
|
||||
static constexpr uint32_t BIT_ZERO_LOW_US = 980;
|
||||
static constexpr uint32_t BIT_ONE_LOW_US = 1950;
|
||||
|
||||
void NexusProtocol::encode(RemoteTransmitData *dst, const NexusData &data) {
|
||||
// A templated value may be out of range or NaN (an unavailable sensor); send 0 for NaN
|
||||
const float temperature = std::isnan(data.temperature) ? 0.0f : std::clamp(data.temperature, -204.8f, 204.7f);
|
||||
const auto t = static_cast<int16_t>(lroundf(temperature * 10.0f));
|
||||
const uint64_t packet = uint64_t(data.address) << 28 | uint64_t(data.battery_level) << 27 |
|
||||
uint64_t(data.force_update) << 26 | uint64_t((data.channel - 1) & 0b11) << 24 |
|
||||
uint64_t(t & 0x0FFF) << 12 | 0b1111 << 8 | data.humidity;
|
||||
|
||||
dst->reserve(NBITS * 2 + 2);
|
||||
for (int8_t i = NBITS - 1; i >= 0; i--)
|
||||
dst->item(BIT_HIGH_US, (packet >> i) & 1 ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US);
|
||||
// The sync follows the bits, as the sensors send it: its mark ends the last space, its gap separates repeats
|
||||
dst->item(SYNC_HIGH_US, SYNC_LOW_US);
|
||||
}
|
||||
|
||||
optional<NexusData> NexusProtocol::decode(RemoteReceiveData src) {
|
||||
// A buffer may start with the previous frame's sync when the receiver did not split on it
|
||||
src.expect_item(SYNC_HIGH_US, SYNC_LOW_US);
|
||||
if (src.size() < NBITS * 2)
|
||||
return {};
|
||||
|
||||
uint64_t packet = 0;
|
||||
for (uint8_t i = 0; i < NBITS; i++) {
|
||||
packet <<= 1;
|
||||
if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) {
|
||||
packet |= 1;
|
||||
} else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (((packet >> 8) & 0xF) != 0xF)
|
||||
return {};
|
||||
ESP_LOGV(TAG, "Nexus packet: 0x%09" PRIX64, packet);
|
||||
|
||||
return NexusData{
|
||||
// The 12 bit field sits at bits 12-23; shift it to the top of an int16_t and back to sign extend.
|
||||
.temperature = (static_cast<int16_t>((packet >> 8) & 0xFFF0) >> 4) / 10.0f,
|
||||
.address = static_cast<uint8_t>(packet >> 28),
|
||||
.battery_level = static_cast<bool>((packet >> 27) & 1),
|
||||
.force_update = static_cast<bool>((packet >> 26) & 1),
|
||||
.channel = static_cast<uint8_t>(((packet >> 24) & 0b11) + 1),
|
||||
.humidity = static_cast<uint8_t>(packet),
|
||||
};
|
||||
}
|
||||
|
||||
void NexusProtocol::dump(const NexusData &data) {
|
||||
ESP_LOGI(TAG,
|
||||
"Received Nexus: address=0x%02X, channel=%u, temperature=%.1f, humidity=%u, battery_level=%d, "
|
||||
"force_update=%d",
|
||||
data.address, data.channel, data.temperature, data.humidity, data.battery_level, data.force_update);
|
||||
}
|
||||
|
||||
} // namespace esphome::remote_base
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "remote_base.h"
|
||||
|
||||
namespace esphome::remote_base {
|
||||
|
||||
struct NexusData {
|
||||
float temperature; // °C, sent as 12 bit signed tenths
|
||||
uint8_t address;
|
||||
bool battery_level;
|
||||
bool force_update;
|
||||
uint8_t channel; // sent 0-based, shown 1-based
|
||||
uint8_t humidity;
|
||||
|
||||
bool operator==(const NexusData &rhs) const {
|
||||
// The binary sensor matches the transmitter, not the reading.
|
||||
return channel == rhs.channel && address == rhs.address;
|
||||
}
|
||||
};
|
||||
|
||||
class NexusProtocol : public RemoteProtocol<NexusData> {
|
||||
public:
|
||||
void encode(RemoteTransmitData *dst, const NexusData &data);
|
||||
optional<NexusData> decode(RemoteReceiveData src);
|
||||
void dump(const NexusData &data);
|
||||
};
|
||||
|
||||
DECLARE_REMOTE_PROTOCOL(Nexus)
|
||||
|
||||
} // namespace esphome::remote_base
|
||||
@@ -7,7 +7,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
# Protocol sources are compiled only behind their define; keep the ones under test.
|
||||
for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq"):
|
||||
for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq", "nexus"):
|
||||
request_protocol(protocol)
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cmath>
|
||||
#include "esphome/components/remote_base/nexus_protocol.h"
|
||||
|
||||
namespace esphome::remote_base::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
RawTimings encode(const NexusData &data) {
|
||||
RemoteTransmitData tx;
|
||||
NexusProtocol().encode(&tx, data);
|
||||
return tx.get_data();
|
||||
}
|
||||
|
||||
optional<NexusData> decode(const RawTimings &timings) {
|
||||
return NexusProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE));
|
||||
}
|
||||
|
||||
void expect_round_trip(float temperature, bool drop_sync) {
|
||||
const NexusData sent{.temperature = temperature,
|
||||
.address = 0x85,
|
||||
.battery_level = true,
|
||||
.force_update = false,
|
||||
.channel = 2,
|
||||
.humidity = 69};
|
||||
RawTimings timings = encode(sent);
|
||||
ASSERT_EQ(timings.size(), 2u + 36u * 2u);
|
||||
if (drop_sync) {
|
||||
timings.resize(timings.size() - 2);
|
||||
} else {
|
||||
// The receiver kept the previous frame's sync in front of this one
|
||||
timings.insert(timings.begin(), timings.end() - 2, timings.end());
|
||||
}
|
||||
auto decoded = decode(timings);
|
||||
ASSERT_TRUE(decoded.has_value()) << temperature;
|
||||
if (!decoded.has_value()) {
|
||||
return;
|
||||
}
|
||||
EXPECT_FLOAT_EQ(decoded->temperature, temperature);
|
||||
EXPECT_EQ(decoded->address, 0x85);
|
||||
EXPECT_TRUE(decoded->battery_level);
|
||||
EXPECT_FALSE(decoded->force_update);
|
||||
EXPECT_EQ(decoded->channel, 2);
|
||||
EXPECT_EQ(decoded->humidity, 69);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(NexusProtocolTest, RoundTripsWithLeadingSync) {
|
||||
expect_round_trip(13.8f, false);
|
||||
expect_round_trip(-12.3f, false);
|
||||
expect_round_trip(-204.8f, false);
|
||||
expect_round_trip(204.7f, false);
|
||||
}
|
||||
|
||||
TEST(NexusProtocolTest, RoundTripsWithoutTrailingSync) {
|
||||
expect_round_trip(0.0f, true);
|
||||
expect_round_trip(-0.1f, true);
|
||||
}
|
||||
|
||||
TEST(NexusProtocolTest, ClampsOutOfRangeTemperature) {
|
||||
auto decoded = decode(encode(NexusData{.temperature = 300.0f, .channel = 1}));
|
||||
ASSERT_TRUE(decoded.has_value());
|
||||
if (!decoded.has_value()) {
|
||||
return;
|
||||
}
|
||||
EXPECT_FLOAT_EQ(decoded->temperature, 204.7f);
|
||||
decoded = decode(encode(NexusData{.temperature = NAN, .channel = 1}));
|
||||
ASSERT_TRUE(decoded.has_value());
|
||||
if (!decoded.has_value()) {
|
||||
return;
|
||||
}
|
||||
EXPECT_FLOAT_EQ(decoded->temperature, 0.0f);
|
||||
}
|
||||
|
||||
TEST(NexusProtocolTest, RejectsWrongConstantBits) {
|
||||
RawTimings timings = encode(NexusData{.temperature = 20.0f, .channel = 1});
|
||||
// Constant bits are data bits 24-27; each bit is a mark and a space, the sync comes last.
|
||||
timings[24 * 2 + 1] = -980;
|
||||
EXPECT_FALSE(decode(timings).has_value());
|
||||
}
|
||||
|
||||
} // namespace esphome::remote_base::testing
|
||||
@@ -103,6 +103,11 @@ on_nexa:
|
||||
- logger.log:
|
||||
format: "on_nexa: %lu %u %u %u %u"
|
||||
args: ["long(x.device)", "x.group", "x.state", "x.channel", "x.level"]
|
||||
on_nexus:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "on_nexus: %u %u %.1f %u %d %d"
|
||||
args: ["x.channel", "x.address", "x.temperature", "x.humidity", "x.battery_level", "x.force_update"]
|
||||
on_onkyori:
|
||||
then:
|
||||
- logger.log:
|
||||
|
||||
@@ -19,3 +19,8 @@ binary_sensor:
|
||||
name: Hob2Hood Light On
|
||||
hob2hood:
|
||||
command: light_on
|
||||
- platform: remote_receiver
|
||||
name: Nexus Sensor
|
||||
nexus:
|
||||
channel: 1
|
||||
address: 0x42
|
||||
|
||||
@@ -52,6 +52,16 @@ button:
|
||||
remote_transmitter.transmit_nec:
|
||||
address: 0x4242
|
||||
command: 0x8484
|
||||
- platform: template
|
||||
name: Nexus
|
||||
on_press:
|
||||
remote_transmitter.transmit_nexus:
|
||||
channel: 1
|
||||
address: 0x42
|
||||
temperature: -12.3
|
||||
humidity: 69
|
||||
battery_level: false
|
||||
force_update: true
|
||||
- platform: template
|
||||
name: LG
|
||||
on_press:
|
||||
|
||||
Reference in New Issue
Block a user