Merge remote-tracking branch 'upstream/dev' into neutral-ble-client

This commit is contained in:
J. Nick Koston
2026-10-13 10:12:14 -10:00
1193 changed files with 54053 additions and 5552 deletions
+26 -10
View File
@@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
message +=
`Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
`this PR is on the large side `;
`Hey @${prAuthor}, thanks for the contribution. This PR is on the ` +
`large side `;
if (tooManyLabels && tooManyChanges) {
message +=
@@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
}
message += `, which makes it harder for maintainers to review.\n\n`;
let condition;
if (tooManyLabels && tooManyChanges) {
condition =
`is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` +
`at most ${MAX_LABELS} components/areas`;
} else if (tooManyLabels) {
condition = `touches at most ${MAX_LABELS} components/areas`;
} else {
condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`;
}
message +=
`Smaller, focused PRs tend to be reviewed much faster since they ` +
`fit into the short gaps between other maintainer work; large ones ` +
`often have to wait for a rare long uninterrupted block of time. ` +
`If you can break this up into smaller pieces that can be reviewed ` +
`independently, it will almost certainly land faster overall.\n\n`;
`**This PR will not be reviewed until it ${condition}.** We know ` +
`that is not what you want to hear after this much work, and we want ` +
`to be upfront so you do not wait for a review that is not coming: ` +
`please split the change. The label is removed automatically once ` +
`the PR ${condition}.\n\n`;
message +=
`Before putting more time in, it's also worth popping into ` +
`\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
`you scope things and flag anything already in flight.\n\n`;
`There is no exception you can request. Rarely, a maintainer agrees in ` +
`\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` +
`genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` +
`but that is rare enough that you should plan on splitting rather than ` +
`wait for it. Smaller, focused PRs are picked up much faster because ` +
`they fit into the short gaps between other maintainer work. For a new ` +
`component, the first PR brings the hub and a single platform (for ` +
`example \`sensor\`); each further platform is its own PR on top of it.\n\n`;
message +=
`For more details (including how to split the work up), see: ` +
`https://developers.esphome.io/contributing/submitting-your-work/` +
@@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess)
}
module.exports = {
generateReviewMessages,
handleReviews,
handleMaintainerAccessComment
};
@@ -0,0 +1,38 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { generateReviewMessages } = require('../reviews');
const MAX_LABELS = 15;
const TOO_BIG_THRESHOLD = 1000;
function tooBigMessage(labelCount, additions) {
const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }];
const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD);
assert.equal(messages.length, 1);
return messages[0];
}
describe('too-big review message', () => {
it('names the line cap when the PR is too long', () => {
const message = tooBigMessage(3, 1500);
assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./);
assert.match(message, /removed automatically once the PR is at most 1000 lines/);
assert.doesNotMatch(message, /components\/areas\./);
});
it('names the area cap when the PR touches too many areas but is short', () => {
const message = tooBigMessage(20, 200);
assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./);
assert.doesNotMatch(message, /until it is at most 1000 lines/);
});
it('names both caps when both are exceeded', () => {
const message = tooBigMessage(20, 1500);
assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./);
});
it('tells new-component authors to bring one platform first', () => {
assert.match(tooBigMessage(3, 1500), /hub and a single platform/);
assert.match(tooBigMessage(3, 1500), /mega-pr/);
});
});
+53
View File
@@ -67,6 +67,7 @@ file does, and it is the authority when they disagree. The most useful starting
- Function-local constants: `lower_snake_case`
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
- Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
@@ -143,6 +144,9 @@ file does, and it is the authority when they disagree. The most useful starting
* **C++ Preprocessor Directives:**
* **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums.
* **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope
and inside functions; `static const` is only for a value computed at run time. A function-local
`static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too.
* **Use `#define` only for:**
- Conditional compilation (`#ifdef`, `#ifndef`)
- Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`)
@@ -177,6 +181,8 @@ file does, and it is the authority when they disagree. The most useful starting
Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use
`set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms:
https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb
* **Repeating work:** use `set_interval`, never a `set_timeout` that re-arms itself; every re-arm
rebuilds the callback and re-queues a scheduler item, and the period drifts.
* **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a
`static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other
components, so number them from 0 and keep all of a component's ids together in one place.
@@ -191,6 +197,36 @@ file does, and it is the authority when they disagree. The most useful starting
// Bad - this is exactly what the base class already does
float get_setup_priority() const override { return setup_priority::DATA; }
```
* **Constant data lives in RAM on ESP8266** unless marked `PROGMEM`: `const` tables, class-scope
constant arrays, and in-class `std::array` initializers (a `.rodata` template plus a copy). Mark the
table `PROGMEM` and read it with `progmem_memcpy()` or `progmem_read_byte()` (`esphome/core/hal.h`);
for a table of structs copy the whole entry out and return the copy, never a pointer into flash.
On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with
`ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g.
`this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array
a `TEMPLATABLE_BYTES` field so a static code stays in flash.
* **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`),
logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`.
A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`.
* **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`);
pick the helper's polynomial and bit-order arguments instead of writing a CRC loop.
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
end of `setup()` when the entities need a state at boot.
* **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing
resend and give-up logic handles it; never advance on an answer that was only logged.
* **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python
tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual
listener base (a second vptr per entity).
* **A global feature define** says the code is compiled in, not that this instance uses it: keep the
per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`.
* **Model-specific code** sits behind a define the Python side emits only for configs that use that model
(`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check
stays inside the `#ifdef`.
* **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure
sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`.
* **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a
runtime check of something the build already knows.
* **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they
can be stored in flash rather than RAM.
```cpp
@@ -534,8 +570,12 @@ file does, and it is the authority when they disagree. The most useful starting
`cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples.
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`.
* **Configuration Validation:**
* **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...},
key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator
that rejects keys and fills defaults afterwards.
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
@@ -569,6 +609,18 @@ file does, and it is the authority when they disagree. The most useful starting
* **Testing:**
* **Python:** Run unit tests with `pytest`.
* **C++:** Use `clang-tidy` for static analysis.
* **C++ unit tests:** gtests in `tests/components/<component>/*.cpp`, run with
`script/cpp_unit_test.py <component>`; see `tests/components/README.md`. Set the loop clock in a test
with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test.
* **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove
timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its
`responses` match on content every time they see it; to answer a repeated request differently, use an
`on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`).
* **Keep build-test variants few:** one `test.<platform>.yaml` per platform the code actually differs
on. A variant per board for platform-identical code only adds CI time.
* **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option
that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to
`ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-<variant>` file.
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
```
tests/
@@ -615,6 +667,7 @@ file does, and it is the authority when they disagree. The most useful starting
name: My Sensor
```
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
`!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead.
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
```bash
+44 -2
View File
@@ -39,9 +39,12 @@ esphome/components/am43/sensor/* @buxtronix
esphome/components/analog_threshold/* @ianchi
esphome/components/animation/* @syndlex
esphome/components/anova/* @buxtronix
esphome/components/apc1/* @ademuri
esphome/components/apds9306/* @aodrenah
esphome/components/apds9930/* @iyesin
esphome/components/api/* @esphome/core
esphome/components/aqi/* @freekode @jasstrong @ximex
esphome/components/aranet/* @patrickod
esphome/components/as5600/* @ammmze
esphome/components/as5600/sensor/* @ammmze
esphome/components/as7341/* @mrgnr
@@ -65,6 +68,7 @@ esphome/components/bedjet/climate/* @jhansche
esphome/components/bedjet/fan/* @jhansche
esphome/components/bedjet/sensor/* @javawizard @jhansche
esphome/components/beken_spi_led_strip/* @Mat931
esphome/components/bh1745/* @latonita
esphome/components/bh1750/* @OttoWinter
esphome/components/bh1900nux/* @B48D81EFCC
esphome/components/binary_sensor/* @esphome/core
@@ -100,6 +104,9 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
esphome/components/bmp581_spi/* @danielkent-net @kahrendt
esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bq25186/* @Rapsssito
esphome/components/bq27220/* @rggammon
esphome/components/bq76972/* @limpkin
esphome/components/bridge/* @kbx81
esphome/components/bthome_mithermometer/* @nagyrobi
esphome/components/button/* @esphome/core
@@ -156,8 +163,10 @@ esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds1603l/* @JakeLC15
esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/ds3231/* @linkedupbits
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ecocomfort2/* @gledian
esphome/components/ee895/* @Stock-M
esphome/components/ektf2232/touchscreen/* @jesserockz
esphome/components/emc2101/* @ellull
@@ -183,6 +192,7 @@ esphome/components/esp32_camera_web_server/* @ayufan
esphome/components/esp32_can/* @Sympatron
esphome/components/esp32_hosted/* @swoboda1337
esphome/components/esp32_hosted/update/* @swoboda1337
esphome/components/esp32_power_management/* @rwrozelle
esphome/components/esp32_rmt/* @jesserockz
esphome/components/esp32_rmt_led_strip/* @jesserockz
esphome/components/esp8266/* @esphome/core
@@ -197,12 +207,15 @@ esphome/components/exponential_moving_average/* @clydebarrow
esphome/components/exposure_notifications/* @OttoWinter
esphome/components/ezo/* @ssieb
esphome/components/ezo_pmp/* @carlos-sarmiento
esphome/components/ezsp_proxy_tap/* @kbx81 @puddly
esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi
esphome/components/file/* @esphome/core
esphome/components/filter_lifetime/* @tuct
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
esphome/components/font/* @clydebarrow @esphome/core
esphome/components/friedrich/* @rwrozelle
esphome/components/fs3000/* @kahrendt
esphome/components/ft5x06/* @clydebarrow
esphome/components/ft63x6/* @gpambrozio
@@ -230,6 +243,9 @@ esphome/components/haier/button/* @paveldn
esphome/components/haier/sensor/* @paveldn
esphome/components/haier/switch/* @paveldn
esphome/components/haier/text_sensor/* @paveldn
esphome/components/haier_tundra/* @fauxpark
esphome/components/haier_tundra/button/* @fauxpark
esphome/components/haier_tundra/switch/* @fauxpark
esphome/components/havells_solar/* @sourabhjaiswal
esphome/components/hbridge/fan/* @WeekendWarrior
esphome/components/hbridge/light/* @DotNetDann
@@ -264,6 +280,7 @@ esphome/components/http_request/ota/* @oarcher
esphome/components/http_request/update/* @jesserockz
esphome/components/htu31d/* @betterengineering
esphome/components/hub75/* @stuartparmenter
esphome/components/husb238/* @latonita
esphome/components/hydreon_rgxx/* @functionpointer
esphome/components/hyt271/* @Philippe12
esphome/components/i2c/* @esphome/core
@@ -271,7 +288,9 @@ esphome/components/i2c_device/* @gabest11
esphome/components/i2s_audio/* @jesserockz
esphome/components/i2s_audio/microphone/* @jesserockz
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
esphome/components/i2s_clockless_led_strip/* @j9brown
esphome/components/iaqcore/* @yozik04
esphome/components/icm20689/* @lboue
esphome/components/icnt86/* @danepowell
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
esphome/components/improv_base/* @esphome/core
@@ -300,9 +319,11 @@ esphome/components/kuntze/* @ssieb
esphome/components/lc709203f/* @ilikecake
esphome/components/lcd_menu/* @numo68
esphome/components/ld2410/* @regevbr @sebcaps
esphome/components/ld2410s/* @NovakIrs
esphome/components/ld2412/* @Rihan9
esphome/components/ld2420/* @descipher
esphome/components/ld2450/* @hareeshmu
esphome/components/ld2460/* @jesusvallejo
esphome/components/ld24xx/* @kbx81
esphome/components/ld6002b/* @hepter
esphome/components/ledc/* @OttoWinter
@@ -311,6 +332,10 @@ esphome/components/libretiny_pwm/* @kuba2k2
esphome/components/light/* @esphome/core
esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lis2dw12/* @latonita
esphome/components/lis2dw12_i2c/* @latonita
esphome/components/lis2dw12_spi/* @latonita
esphome/components/lis3dh/* @zebble
esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
@@ -318,6 +343,7 @@ esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow
esphome/components/lp5562/* @DennisGaida
esphome/components/lps22/* @nagisa
esphome/components/lsm6ds/* @clydebarrow
esphome/components/ltr390/* @latonita @sjtrny
@@ -328,7 +354,9 @@ esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
esphome/components/max17043/* @blacknell
esphome/components/max17048/* @B48D81EFCC
esphome/components/max31865/* @DAVe3283
esphome/components/max31888/* @JiriPrchal
esphome/components/max44009/* @berfenger
esphome/components/max6956/* @looping40
esphome/components/max7219digit/* @rspaargaren
@@ -347,7 +375,6 @@ esphome/components/mcp4461/* @p1ngb4ck
esphome/components/mcp4728/* @berfenger
esphome/components/mcp47a1/* @jesserockz
esphome/components/mcp9600/* @mreditor97
esphome/components/mcp9808/* @k7hpn
esphome/components/md5/* @esphome/core
esphome/components/mdns/* @esphome/core
esphome/components/media_player/* @jesserockz
@@ -361,6 +388,7 @@ esphome/components/midea_ir/* @dudanov
esphome/components/mipi_dsi/* @clydebarrow
esphome/components/mipi_rgb/* @clydebarrow
esphome/components/mipi_spi/* @clydebarrow
esphome/components/mistral_ir/* @lerhum
esphome/components/mitsubishi/* @RubyBailey
esphome/components/mitsubishi_cn105/* @crnjan
esphome/components/mixer/speaker/* @kahrendt
@@ -399,6 +427,8 @@ esphome/components/noblex/* @AGalfra
esphome/components/noise/* @esphome/core
esphome/components/npi19/* @bakerkj
esphome/components/nrf52/* @tomaszduda23
esphome/components/ns2009/* @tchilov
esphome/components/ns2009/touchscreen/* @tchilov
esphome/components/number/* @esphome/core
esphome/components/one_wire/* @ssieb
esphome/components/online_image/* @clydebarrow @guillempages
@@ -413,6 +443,7 @@ esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
esphome/components/pcf85063/* @brogon
esphome/components/pcf8563/* @KoenBreeman
esphome/components/pcm5122/* @remcom
esphome/components/pd_pioneer_ir/* @martinsah
esphome/components/pi4ioe5v6408/* @jesserockz
esphome/components/pid/* @OttoWinter
esphome/components/pipsolar/* @andreashergert1984
@@ -431,6 +462,7 @@ esphome/components/pn7160/* @jesserockz @kbx81
esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/pn71xx/* @jesserockz @kbx81
esphome/components/power_management/* @rwrozelle
esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core
esphome/components/provisioning/* @esphome/core
@@ -451,6 +483,8 @@ esphome/components/rc522/* @glmnet
esphome/components/rc522_i2c/* @glmnet
esphome/components/rc522_spi/* @glmnet
esphome/components/rd03d/* @jasstrong
esphome/components/remko_ar715/* @naKruul
esphome/components/renogy_inverter_ble/* @emilioaray-dev
esphome/components/resampler/speaker/* @kahrendt
esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
@@ -470,6 +504,7 @@ esphome/components/runtime_stats/* @bdraco
esphome/components/rx8025t/* @remcom
esphome/components/rx8130/* @beormund
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti
esphome/components/samsung/* @jorofi
esphome/components/scd4x/* @martgras @sjtrny
esphome/components/script/* @esphome/core
esphome/components/sdl/* @bdm310 @clydebarrow
@@ -498,11 +533,14 @@ esphome/components/sfa30/* @ghsensdev
esphome/components/sfa40/* @NoQuarrel
esphome/components/sgp40/* @SenexCrenshaw
esphome/components/sgp4x/* @martgras @SenexCrenshaw
esphome/components/sh1122_base/* @pl4nkton
esphome/components/sh1122_spi/* @pl4nkton
esphome/components/sha256/* @esphome/core
esphome/components/shelly_dimmer/* @edge90 @rnauber
esphome/components/sht3xd/* @mrtoy-me
esphome/components/sht4x/* @sjtrny
esphome/components/shutdown/* @esphome/core @jsuanet
esphome/components/siemens_ira211/* @fonix232
esphome/components/sigma_delta_output/* @Cat-Ion
esphome/components/sim800l/* @glmnet
esphome/components/sm10bit_base/* @Cossid
@@ -565,6 +603,7 @@ esphome/components/t6615/* @tylermenezes
esphome/components/tas2780/* @remcom
esphome/components/tas58xx/* @mrtoy-me @remcom
esphome/components/tc74/* @sethgirvan
esphome/components/tca8418/* @zebble
esphome/components/tca9548a/* @andreashergert1984
esphome/components/tca9555/* @mobrembski
esphome/components/tcl112/* @glmnet
@@ -582,12 +621,15 @@ esphome/components/thermopro_ble/* @sittner
esphome/components/thermostat/* @kbx81
esphome/components/time/* @esphome/core
esphome/components/tinyusb/* @kbx81
esphome/components/tinyusb_keyboard/* @frederikbolding
esphome/components/tlc5947/* @rnauber
esphome/components/tlc5971/* @IJIJI
esphome/components/tm1621/* @Philippe12
esphome/components/tm1637/* @glmnet
esphome/components/tm1638/* @skykingjwc
esphome/components/tm1651/* @mrtoy-me
esphome/components/tmc2209/* @remcom
esphome/components/tmc22xx/* @remcom
esphome/components/tmp102/* @timsavage
esphome/components/tmp1075/* @sybrenstuvel
esphome/components/tmp117/* @Azimath
@@ -639,7 +681,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @glmnet
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
RUN \
platformio settings set enable_telemetry No \
+193 -29
View File
@@ -48,9 +48,13 @@ from esphome.const import (
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_ESP32,
KEY_TARGET_PLATFORM,
KEY_VARIANT,
PLATFORM_HOST,
SECRETS_FILES,
ErrorFormat,
Toolchain,
)
from esphome.core import CORE, EsphomeError, coroutine
@@ -69,6 +73,7 @@ from esphome.util import (
get_serial_ports,
is_picotool_usb_permission_error,
list_yaml_files,
read_secret_line,
run_external_command,
run_external_process,
safe_print,
@@ -478,12 +483,9 @@ def has_ota() -> bool:
def has_native_ota() -> bool:
"""Check if native API OTA upload is available (``platform: esphome``)."""
if CONF_OTA not in CORE.config:
return False
return any(
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
for ota_item in CORE.config[CONF_OTA]
)
from esphome.espota2 import esphome_ota_items
return bool(esphome_ota_items(CORE.config))
def has_web_server_ota() -> bool:
@@ -1231,14 +1233,28 @@ def upload_program(
config: ConfigType, args: ArgsProtocol, devices: list[str]
) -> tuple[int, str | None]:
host = devices[0]
port_type = get_port_type(host)
ota_key = getattr(args, "ota_key", None)
if ota_key and port_type in (PortType.SERIAL, PortType.BOOTSEL):
_LOGGER.warning(
"The presented OTA key only applies to network uploads; ignored for %s",
host,
)
platform_upload = platform_hooks.get_platform_hook(
CORE.target_platform, "upload_program"
)
if platform_upload is not None and platform_upload(config, args, host):
if ota_key and port_type not in (PortType.SERIAL, PortType.BOOTSEL):
# A platform's own network path (nrf52 mcumgr) has no key handshake
_LOGGER.warning(
"The presented OTA key only applies to the %s OTA platform; "
"ignored for %s",
CONF_ESPHOME,
host,
)
return 0, host
port_type = get_port_type(host)
# MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by
# _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths.
is_partition_table = getattr(args, "partition_table", False)
@@ -1281,6 +1297,8 @@ def upload_program(
network_devices = _resolve_network_devices(devices, config, args)
if chosen_platform == CONF_WEB_SERVER:
if ota_key:
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
if is_partition_table or is_bootloader:
raise EsphomeError(
f"{option_string} is only supported with the esphome OTA platform; "
@@ -1294,6 +1312,17 @@ def upload_program(
return _upload_via_native_api(config, network_devices, args)
def _ota_upload_platforms(config: ConfigType) -> dict[str, None]:
"""The network OTA platforms the config provides, one entry each, in
config order; the web_server final-validate hook merges duplicates."""
return {
ota_platform: None
for ota_item in config.get(CONF_OTA, [])
if (ota_platform := ota_item.get(CONF_PLATFORM))
in (CONF_ESPHOME, CONF_WEB_SERVER)
}
def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
"""Pick the OTA platform to use, optionally honoring ``--ota-platform``.
@@ -1306,16 +1335,7 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to
``web_server`` only when that is the only available platform.
"""
# Use a dict (insertion-ordered) instead of a list so error messages and
# membership checks see one entry per platform even if the user has
# multiple ``ota:`` items of the same platform; the web_server OTA
# platform's final-validate hook merges duplicates anyway.
available: dict[str, None] = {}
for ota_item in config.get(CONF_OTA, []):
ota_platform = ota_item.get(CONF_PLATFORM)
if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[ota_platform] = None
available = _ota_upload_platforms(config)
if not available:
raise EsphomeError(
f"Cannot upload Over the Air as the {CONF_OTA} configuration is not "
@@ -1336,14 +1356,17 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
return CONF_WEB_SERVER
def _esphome_ota_conf(config: ConfigType) -> ConfigType | None:
"""The validated esphome OTA platform block, if configured."""
from esphome.espota2 import esphome_ota_items
return next(iter(esphome_ota_items(config)), None)
def _upload_via_native_api(
config: ConfigType, network_devices: list[str], args: ArgsProtocol
) -> tuple[int, str | None]:
ota_conf: ConfigType = {}
for ota_item in config.get(CONF_OTA, []):
if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME:
ota_conf = ota_item
break
ota_conf = _esphome_ota_conf(config) or {}
from esphome import espota2
from esphome.components.noise import static_encryption_key
@@ -1353,9 +1376,15 @@ def _upload_via_native_api(
# Fail closed: an encryption block whose key did not resolve must never
# fall back to a plaintext upload
noise_psk = None
old_noise_psk = None
plaintext_fallback = False
allow_plaintext_upload = False
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
if ota_key := getattr(args, "ota_key", None):
# The user presents a key the device still runs instead of the
# configured one (a key change in progress); an explicit key never
# downgrades to plaintext
noise_psk = ota_key
elif (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
noise_psk = encryption_conf.get(CONF_KEY)
allow_plaintext_upload = bool(
encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD)
@@ -1368,6 +1397,8 @@ def _upload_via_native_api(
# Ensure the key is a string, as required by the underlying OTA implementation.
# It arrives here as a SensitiveStr which aioesphomeapi rejects.
noise_psk = str(noise_psk)
if old_key := encryption_conf.get(espota2.CONF_OLD_KEY):
old_noise_psk = str(old_key)
elif api_key := static_encryption_key(config.get(CONF_API) or {}):
# Remove before 2027.3.0: the api key is tried, falling back to plaintext
noise_psk = str(api_key)
@@ -1417,19 +1448,30 @@ def _upload_via_native_api(
noise_psk,
plaintext_fallback=plaintext_fallback,
allow_plaintext_upload=allow_plaintext_upload,
old_noise_psk=old_noise_psk,
)
def _old_key_notice(config: ConfigType) -> None:
"""After `run`, whose image was just built from this config; a plain
`upload` may send a stale image that still carries the previous key."""
from esphome import espota2
for item in espota2.esphome_ota_items(config):
if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY):
_LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE)
def _upload_via_web_server(
config: ConfigType, network_devices: list[str], binary: Path
) -> tuple[int, str | None]:
from esphome import web_server_ota
from esphome.espota2 import esphome_ota_items
from esphome.web_server_helpers import get_web_server_connection
if any(
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_item.get(CONF_ENCRYPTION) is not None
for ota_item in config.get(CONF_OTA, [])
ota_item.get(CONF_ENCRYPTION) is not None
for ota_item in esphome_ota_items(config)
):
_LOGGER.warning(
"This config has OTA encryption, but the web_server OTA path sends "
@@ -1737,7 +1779,60 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
ENV_OTA_KEY = "ESPHOME_OTA_KEY"
OTA_KEY_PLATFORM_ERROR = (
f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA "
"platform; to flash by serial instead, name the port with --device"
)
def _read_ota_key(args: ArgsProtocol, config: ConfigType) -> None:
"""Take the key to present to the device for this upload, from the
prompt or from ESPHOME_OTA_KEY, before any compile so the prompt is
not buried in build output."""
prompt = getattr(args, "prompt_ota_key", False)
env_key = getattr(args, "env_ota_key", None)
if not prompt and env_key is None:
return
if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_HOST:
_LOGGER.warning("A host program runs locally; the presented OTA key is ignored")
return
# The HTTP path has no key handshake; decide before prompting or
# compiling. A serial target is a serial flash: the key is not even
# asked for, and --ota-platform means nothing to it. Every entry must
# be serial: a missing port is skipped and the next entry may be a host
devices = getattr(args, "device", None) or []
serial_target = bool(devices) and all(
device == "SERIAL"
or get_port_type(device) in (PortType.SERIAL, PortType.BOOTSEL)
for device in devices
)
chosen = (
_choose_ota_platform(config, getattr(args, "ota_platform", None))
if not serial_target and _ota_upload_platforms(config)
else None
)
if chosen == CONF_WEB_SERVER:
raise EsphomeError(OTA_KEY_PLATFORM_ERROR)
from esphome.components.noise import validate_encryption_key
from esphome.config_validation import Invalid
if serial_target:
_LOGGER.warning(
"The presented OTA key only applies to network uploads; ignored for %s",
", ".join(devices),
)
return
key = read_secret_line("OTA encryption key: ") if prompt else env_key
try:
validate_encryption_key(key)
except Invalid as err:
raise EsphomeError(f"Invalid OTA encryption key: {err}") from err
args.ota_key = key
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
_read_ota_key(args, config)
# Get devices, resolving special identifiers like OTA
devices = choose_upload_log_host(
default=args.device,
@@ -1785,6 +1880,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
# Elsewhere the flag is ignored, so serial stays fine there.
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
return 1
_read_ota_key(args, config)
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
@@ -1810,6 +1906,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
exit_code, successful_device = upload_program(config, args, devices)
if exit_code == 0:
_LOGGER.info("Successfully uploaded program.")
_old_key_notice(config)
else:
_LOGGER.warning("Failed to upload to %s", devices)
return exit_code
@@ -2205,6 +2302,25 @@ def _add_states_args(parser: argparse.ArgumentParser) -> None:
)
def _error_format(value: str) -> ErrorFormat:
try:
return ErrorFormat(value.lower())
except ValueError:
choices = ", ".join(ErrorFormat)
raise argparse.ArgumentTypeError(
f"invalid choice: '{value}' (choose from {choices})"
) from None
ARGUMENT_HELP_PROMPT_OTA_KEY = (
"Ask for the OTA encryption key to present to the device instead of using "
"the one in the configuration, for a device that still runs a previous key. "
"The key is read without echo, or as one line from stdin when that is not "
"a terminal; it is never taken from the command line. A calling program "
f"can hand the key over in the {ENV_OTA_KEY} environment variable instead."
)
def parse_args(argv):
options_parser = argparse.ArgumentParser(add_help=False)
options_parser.add_argument(
@@ -2267,6 +2383,17 @@ def parse_args(argv):
),
)
options_parser.add_argument(
"--error-format",
type=_error_format,
metavar="{" + ",".join(f.value for f in ErrorFormat) + "}",
help=(
"How configuration errors are shown. 'yaml' shows them inside the "
"failing YAML; 'line' prints one 'file:line:column: error: message' "
"line per error. Default: $ESPHOME_ERROR_FORMAT, else 'yaml'."
),
)
parser = argparse.ArgumentParser(
description=f"ESPHome {const.__version__}", parents=[options_parser]
)
@@ -2362,6 +2489,11 @@ def parse_args(argv):
help="Upload as partition table (OTA).",
action="store_true",
)
parser_upload.add_argument(
"--prompt-ota-key",
action="store_true",
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
)
parser_upload.add_argument(
"--bootloader",
help="Upload as bootloader (OTA).",
@@ -2442,6 +2574,11 @@ def parse_args(argv):
"(HTTP Basic auth) when that is the only configured platform."
),
)
parser_run.add_argument(
"--prompt-ota-key",
action="store_true",
help=ARGUMENT_HELP_PROMPT_OTA_KEY,
)
parser_clean = subparsers.add_parser(
"clean-mqtt",
@@ -2570,9 +2707,18 @@ def parse_args(argv):
args, unknown_args = parser.parse_known_args(arguments)
if unknown_args:
_LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args)
return args
else:
args = parser.parse_args(arguments)
return parser.parse_args(arguments)
# Read here rather than as the option default, so a bad value is reported
# against the environment variable instead of --error-format
if args.error_format is None:
env_value = os.getenv("ESPHOME_ERROR_FORMAT", ErrorFormat.YAML.value)
try:
args.error_format = _error_format(env_value)
except argparse.ArgumentTypeError as err:
parser.error(f"ESPHOME_ERROR_FORMAT: {err}")
return args
def _warn_if_source_tree_mismatch() -> None:
@@ -2645,6 +2791,7 @@ def run_esphome(argv):
args = parse_args(argv)
CORE.dashboard = args.dashboard
CORE.testing_mode = args.testing_mode
CORE.error_format = args.error_format
# Create address cache from command-line arguments
CORE.address_cache = AddressCache.from_cli_args(
@@ -2657,6 +2804,23 @@ def run_esphome(argv):
args.log_level = "CRITICAL"
setup_log(log_level=args.log_level)
# Taken out of the environment before any command runs, so no build
# tool inherits a key a calling program handed over
args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None)
if args.command in ("upload", "run"):
# One key is one device's; several configs run in child processes
# that would never see it
if (args.env_ota_key is not None or args.prompt_ota_key) and len(
args.configuration
) > 1:
raise EsphomeError(
f"--prompt-ota-key and {ENV_OTA_KEY} take one configuration; "
f"{len(args.configuration)} were given"
)
elif args.env_ota_key is not None:
_LOGGER.warning(
"%s is set but the %s command does not use it", ENV_OTA_KEY, args.command
)
_warn_if_source_tree_mismatch()
_warn_if_intel_macos()
+134 -24
View File
@@ -1,4 +1,4 @@
from collections.abc import Callable, Sequence
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
import logging
import string
@@ -248,6 +248,23 @@ def flash_string(config: ConfigType, value: str) -> str:
return str(cg.progmem_string(value))
def flash_compare(config: ConfigType, value: str) -> str:
"""Default renderer for ``std::string`` constants in conditions; compares in flash on ESP8266."""
if CORE.is_esp8266:
return f"::esphome::ProgmemStringRef{{PSTR({cg.safe_exp(value)}), {len(value.encode('utf-8'))}}}"
return str(cg.safe_exp(value))
def _default_std_string(
members: list[tuple[str, Any, Any]], renderer: Callable[[ConfigType, str], str]
) -> list[tuple[str, Any, Any]]:
"""Give ``cg.std_string`` members without a renderer the given default."""
return [
(key, t, fn or (renderer if t is cg.std_string else None))
for key, t, fn in members
]
def literal_with_length(config: ConfigType, value: str) -> str:
"""Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length.
@@ -465,6 +482,22 @@ def register_apply_action(
) -> None:
"""Register an action that only forwards config values to its parent, with no C++ class.
See ``apply_action_builder``.
"""
register_action(name, ApplyAction, schema, synchronous=True)(
apply_action_builder(name, schema, *fields, call=call, id_key=id_key)
)
def apply_action_builder(
name: str,
schema: cv.Schema,
*fields: ApplyField | ApplyCall,
call: str | None = None,
id_key: str = CONF_ID,
) -> Callable[..., Awaitable[MockObj]]:
"""The builder ``register_apply_action`` registers, for a caller that wraps it.
Generates one static function with the parent (read from ``id_key``) and constants baked
in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it.
A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names.
@@ -475,10 +508,7 @@ def register_apply_action(
statements_spec = [
(
c.target,
[
(key, t, fn or (flash_string if t is cg.std_string else None))
for key, t, fn in c.members
],
_default_std_string(c.members, flash_string),
)
for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields)
]
@@ -515,7 +545,7 @@ def register_apply_action(
action_id, cg.void, template_arg, lambda_args, statements
)
register_action(name, ApplyAction, schema, synchronous=True)(builder)
return builder
def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool:
@@ -547,11 +577,12 @@ def register_apply_condition(
``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an
``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares
against config values, all of which must be present. Write ``== false`` to negate.
String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member.
A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=``
against a ``std::string`` or ``StringRef`` member; other string types are plain literals.
Generates one static predicate and an ``ApplyCondition`` templated on it.
"""
call = check if isinstance(check, ApplyCall) else ApplyCall(check)
members = call.members
members = _default_std_string(call.members, flash_compare)
_check_key_in_schema(name, schema, id_key)
for conf_key, _, _ in members:
_check_key_in_schema(name, schema, conf_key)
@@ -724,7 +755,7 @@ async def condition_group_to_code(
if len(conditions) == 1:
return conditions[0]
return cg.new_Pvariable(
condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions
)
@@ -840,11 +871,13 @@ async def if_action_to_code(
condition = await build_condition(config[cond_conf], template_arg, args)
var = cg.new_Pvariable(action_id, if_template_arg, condition)
if CONF_THEN in config:
actions = await build_action_list(config[CONF_THEN], template_arg, args)
cg.add(var.add_then(actions))
for action in await build_action_list(config[CONF_THEN], template_arg, args):
cg.add(var.add_then_action(action))
cg.add(var.finish_then())
if has_else:
actions = await build_action_list(config[CONF_ELSE], template_arg, args)
cg.add(var.add_else(actions))
for action in await build_action_list(config[CONF_ELSE], template_arg, args):
cg.add(var.add_else_action(action))
cg.add(var.finish_else())
return var
@@ -867,11 +900,27 @@ async def while_action_to_code(
) -> MockObj:
condition = await build_condition(config[CONF_CONDITION], template_arg, args)
var = cg.new_Pvariable(action_id, template_arg, condition)
actions = await build_action_list(config[CONF_THEN], template_arg, args)
cg.add(var.add_then(actions))
for action in await build_action_list(config[CONF_THEN], template_arg, args):
cg.add(var.add_then_action(action))
cg.add(var.finish_then())
return var
def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType:
"""Rename an inherited `iteration` so the inner repeat's own counter can shadow it."""
names = {name for _, name in args}
renamed: TemplateArgsType = []
for type_, name in args:
if name == "iteration":
depth = 1
while f"iteration_{depth}" in names:
depth += 1
name = f"iteration_{depth}"
names.add(name)
renamed.append((type_, name))
return renamed
@register_action(
"repeat",
RepeatAction,
@@ -895,9 +944,11 @@ async def repeat_action_to_code(
actions = await build_action_list(
config[CONF_THEN],
cg.TemplateArguments(cg.uint32, *template_arg.args),
[(cg.uint32, "iteration"), *args],
[(cg.uint32, "iteration"), *_shadow_iteration(args)],
)
cg.add(var.add_then(actions))
for action in actions:
cg.add(var.add_then_action(action))
cg.add(var.finish_then())
return var
@@ -1033,21 +1084,79 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool:
Non-synchronous actions (delay, wait_until, script.wait, etc.) store
trigger args for later execution, making non-owning types like StringRef
unsafe.
Accepts an action list, a single action, or an automation. Only the keys of
action entries are looked up as action names; the options of an action are
only searched for nested action lists (``then:``, ``else:``, ...), so an
option that shares a name with an action, such as the ``delay:`` option of
``speaker_source.set_playlist_delay``, is not mistaken for that action.
"""
if isinstance(actions, list):
return any(has_non_synchronous_actions(item) for item in actions)
if isinstance(actions, dict):
for key in actions:
for key, options in actions.items():
if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous:
return True
return any(
has_non_synchronous_actions(v)
for v in actions.values()
if isinstance(v, (list, dict))
)
if _options_have_non_synchronous_actions(options):
return True
return False
def _options_have_non_synchronous_actions(options: Any) -> bool:
"""Search an action's options for nested action lists, without treating option keys as actions."""
if isinstance(options, list):
return has_non_synchronous_actions(options)
if isinstance(options, dict):
return any(_options_have_non_synchronous_actions(v) for v in options.values())
return False
def validate_no_deferring_actions(
context: str, data: str = "the trigger data"
) -> Callable[[ConfigType], ConfigType]:
"""Return a validator that rejects deferring actions in a validated automation.
Use it for triggers whose arguments only live while the callback runs (views into a reused
buffer, a JSON document freed on return, ...): deferring actions keep the arguments for later.
:param context: Where the automation is, for the error message, e.g. ``"on_json and on_data"``.
:param data: What is only valid while the automation runs, for the error message.
"""
def validator(value: ConfigType) -> ConfigType:
if has_non_synchronous_actions(value):
raise cv.Invalid(
f"Deferring actions (delay, wait_until, script.wait, ...) are not allowed in "
f"{context}: {data} is only valid while the automation runs. Copy what you need "
"into globals first, then defer in a separate script or automation."
)
return value
return validator
def validate_synchronous_automation(
context: str,
data: str = "the trigger data",
extra_schema=None,
extra_validators=None,
single: bool = False,
) -> cv.All:
"""``validate_automation()`` that also rejects deferring actions.
See ``validate_no_deferring_actions()`` for ``context`` and ``data``; the other parameters are
passed to ``validate_automation()``.
"""
return cv.All(
validate_automation(
extra_schema=extra_schema,
extra_validators=extra_validators,
single=single,
),
validate_no_deferring_actions(context, data),
)
async def _new_automation(
args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj
) -> MockObj:
@@ -1055,7 +1164,8 @@ async def _new_automation(
templ = cg.TemplateArguments(*(arg[0] for arg in args))
obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args)
actions = await build_action_list(config[CONF_THEN], templ, args)
cg.add(obj.add_actions(actions))
for action in actions:
cg.add(obj.add_action(action))
return obj
+20 -12
View File
@@ -102,25 +102,21 @@ _KNOWN_FILE_EXTENSIONS = frozenset(
)
# Matches !secret references in YAML text. An optional surrounding
# quote pair around the key is allowed and ignored: YAML treats
# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is
# intentionally a simple regex scan rather than a YAML parse — it may
# match inside comments or multi-line strings, which is the conservative
# direction (include more secrets rather than fewer).
_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""")
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
"""Scan YAML files for ``!secret <key>`` references."""
keys: set[str] = set()
for fpath in yaml_files:
try:
text = fpath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError) as err:
_LOGGER.warning(
"Could not scan %s for !secret references (%s); the bundled "
"secret set may be incomplete",
fpath,
err,
)
continue
for match in _SECRET_RE.finditer(text):
keys.add(match.group(1))
keys |= yaml_util.find_secret_references(text)
return keys
@@ -422,6 +418,18 @@ class ConfigBundleCreator:
must ship every candidate so the remote build can pick any one.
"""
discovered = yaml_util.discover_user_yaml_files(self._config_path)
if discovered.load_errors:
_LOGGER.warning(
"Bundle may be incomplete; could not load all configuration files: %s",
"; ".join(discovered.load_errors),
)
if discovered.unresolved:
_LOGGER.warning(
"Bundle may be incomplete; %d !include path(s) use "
"substitutions and cannot be captured: %s",
len(discovered.unresolved),
", ".join(discovered.unresolved),
)
self._secrets_paths.update(discovered.secrets)
# A !secret inside a file this re-parse does not reach (for example
# a git-fetched package the builder re-fetches) still resolves
+1
View File
@@ -44,6 +44,7 @@ from esphome.cpp_generator import ( # noqa: F401
safe_exp,
set_cpp_standard,
shared_progmem_array,
shared_progmem_string,
statement,
static_const_array,
static_function,
+4 -4
View File
@@ -128,10 +128,10 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
#endif // USE_RP2
protected:
uint8_t sample_count_{1};
bool output_raw_{false};
uint8_t sample_count_{1}; // Must match DEFAULT_SAMPLES in sensor.py
bool output_raw_{false}; // Must match CONF_RAW default in sensor.py
InternalGPIOPin *pin_;
SamplingMode sampling_mode_{SamplingMode::AVG};
SamplingMode sampling_mode_{SamplingMode::AVG}; // Must match DEFAULT_SAMPLING_MODE in sensor.py
#ifdef USE_ESP32
float sample_autorange_();
@@ -139,7 +139,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
bool autorange_{false};
adc_oneshot_unit_handle_t adc_handle_{nullptr};
adc_cali_handle_t calibration_handle_{nullptr};
adc_atten_t attenuation_{ADC_ATTEN_DB_0};
adc_atten_t attenuation_{ADC_ATTEN_DB_0}; // Must match DEFAULT_ATTENUATION in sensor.py
adc_channel_t channel_{};
adc_unit_t adc_unit_{};
struct SetupFlags {
+24 -8
View File
@@ -48,6 +48,11 @@ AUTO_LOAD = ["voltage_sampler"]
CONF_SAMPLES = "samples"
CONF_SAMPLING_MODE = "sampling_mode"
# Keep in sync with the initializers in adc_sensor.h
DEFAULT_ATTENUATION = "0db"
DEFAULT_SAMPLING_MODE = "avg"
DEFAULT_SAMPLES = 1
_attenuation = cv.enum(ATTENUATION_MODES, lower=True)
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
@@ -65,7 +70,10 @@ def validate_config(config: ConfigType) -> ConfigType:
):
raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'")
if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1:
if (
config.get(CONF_ATTENUATION, None) == "auto"
and config.get(CONF_SAMPLES, DEFAULT_SAMPLES) > 1
):
raise cv.Invalid(
"Automatic attenuation cannot be used when multisampling is set"
)
@@ -113,12 +121,16 @@ CONFIG_SCHEMA = cv.All(
{
cv.Required(CONF_PIN): validate_adc_pin,
cv.Optional(CONF_RAW, default=False): cv.boolean,
cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All(
cv.SplitDefault(CONF_ATTENUATION, esp32=DEFAULT_ATTENUATION): cv.All(
cv.only_on_esp32, _attenuation
),
cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec),
cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255),
cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode,
cv.Optional(CONF_SAMPLES, default=DEFAULT_SAMPLES): cv.int_range(
min=1, max=255
),
cv.Optional(
CONF_SAMPLING_MODE, default=DEFAULT_SAMPLING_MODE
): _sampling_mode,
}
)
.extend(cv.polling_component_schema("60s")),
@@ -155,9 +167,13 @@ async def to_code(config: ConfigType) -> None:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
cg.add(var.set_pin(pin))
cg.add(var.set_output_raw(config[CONF_RAW]))
cg.add(var.set_sample_count(config[CONF_SAMPLES]))
cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE]))
# Skip the setters when the config matches the C++ initializers.
if config[CONF_RAW]:
cg.add(var.set_output_raw(True))
if (samples := config[CONF_SAMPLES]) != DEFAULT_SAMPLES:
cg.add(var.set_sample_count(samples))
if (sampling_mode := config[CONF_SAMPLING_MODE]) != DEFAULT_SAMPLING_MODE:
cg.add(var.set_sampling_mode(sampling_mode))
if CORE.is_esp32:
# Re-enable ESP-IDF's ADC driver (excluded by default to save compile time)
@@ -166,7 +182,7 @@ async def to_code(config: ConfigType) -> None:
if attenuation := config.get(CONF_ATTENUATION):
if attenuation == "auto":
cg.add(var.set_autorange(cg.global_ns.true))
else:
elif attenuation != DEFAULT_ATTENUATION:
cg.add(var.set_attenuation(attenuation))
variant = get_esp32_variant()
+3
View File
@@ -0,0 +1,3 @@
"""Support for ScioSense APC1 air quality sensor."""
DOMAIN = "apc1"
+352
View File
@@ -0,0 +1,352 @@
#include "apc1.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <cinttypes>
#include <cmath>
namespace esphome::apc1 {
ESPHOME_LOG_TAG(TAG, "apc1");
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_AQI = 58;
static constexpr uint8_t APC1_MEASUREMENT_OFFSET_ERROR_CODE = 61;
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_MODULE = 4;
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_SERIAL = 10;
static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_FW = 19;
// Give a freshly powered sensor time to boot before the first retry
static constexpr uint32_t APC1_COLD_BOOT_INIT_DELAY_MS = 2000;
// Re-send the init commands at most this often while no frames arrive
static constexpr uint32_t APC1_INIT_RETRY_INTERVAL_MS = 10000;
// Active mode streams a frame every second; this long without one means the sensor stopped
static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000;
// Drop a partial frame when the bytes stop mid-way
static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200;
// Frames and commands end with the 16-bit sum of every byte before it
static uint16_t apc1_checksum(const uint8_t *data, size_t len) {
uint16_t sum = 0;
for (size_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
void APC1Component::setup() {
if (this->set_pin_ != nullptr) {
this->set_pin_->setup();
this->set_pin_->digital_write(true);
}
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
}
// Attempt initial communication immediately for warm restarts
this->initialize_device_();
// Schedule delayed initialization to catch sensors during cold power-on bootup
this->set_timeout(APC1_COLD_BOOT_INIT_DELAY_MS, [this]() { this->initialize_device_(); });
}
void APC1Component::initialize_device_() {
this->last_init_attempt_ = App.get_loop_component_start_time();
if (this->active_mode_ && !this->idle_) {
this->set_active_mode();
}
this->send_command_(APC1Command::APC1_COMMAND_READ_SENSOR_VERSION, 0x0000);
}
void APC1Component::dump_config() {
ESP_LOGCONFIG(TAG, "APC1:");
LOG_PIN(" Set Pin: ", this->set_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_);
LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_);
LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_);
LOG_SENSOR(" ", "PM1.0 Standard", this->pm_1_0_std_sensor_);
LOG_SENSOR(" ", "PM2.5 Standard", this->pm_2_5_std_sensor_);
LOG_SENSOR(" ", "PM10.0 Standard", this->pm_10_0_std_sensor_);
LOG_SENSOR(" ", "PM >0.3um", this->pm_0_3um_sensor_);
LOG_SENSOR(" ", "PM >0.5um", this->pm_0_5um_sensor_);
LOG_SENSOR(" ", "PM >1.0um", this->pm_1_0um_sensor_);
LOG_SENSOR(" ", "PM >2.5um", this->pm_2_5um_sensor_);
LOG_SENSOR(" ", "PM >5.0um", this->pm_5_0um_sensor_);
LOG_SENSOR(" ", "PM >10.0um", this->pm_10_0um_sensor_);
LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_);
LOG_SENSOR(" ", "eCO2", this->eco2_sensor_);
LOG_SENSOR(" ", "AQI", this->aqi_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
LOG_SENSOR(" ", "Raw Temperature", this->raw_temperature_sensor_);
LOG_SENSOR(" ", "Raw Humidity", this->raw_humidity_sensor_);
LOG_SENSOR(" ", "RS0", this->rs0_sensor_);
LOG_SENSOR(" ", "RS2", this->rs2_sensor_);
LOG_SENSOR(" ", "RS3", this->rs3_sensor_);
LOG_SENSOR(" ", "Error Code", this->error_code_sensor_);
}
void APC1Component::loop() {
const uint32_t now = App.get_loop_component_start_time();
// If in active mode and not idle, retry periodically if no valid data received
if (this->active_mode_ && !this->idle_) {
if (this->last_valid_frame_ == 0) {
if (now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
ESP_LOGD(TAG, "Waiting for APC1 sensor to respond, sending init commands...");
this->initialize_device_();
}
} else if (now - this->last_valid_frame_ > APC1_NO_DATA_TIMEOUT_MS &&
now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) {
ESP_LOGW(TAG, "No data received from APC1 for %" PRIu32 " ms, re-initializing...", now - this->last_valid_frame_);
this->initialize_device_();
}
}
if (this->rx_index_ > 0 && now - this->last_transmission_ > APC1_RX_IDLE_TIMEOUT_MS) {
ESP_LOGV(TAG, "Timed out waiting for data, resetting buffer");
this->rx_index_ = 0;
}
while (this->available()) {
this->last_transmission_ = now;
uint8_t byte;
this->read_byte(&byte);
if (this->rx_index_ == 0) {
if (byte == APC1_HEADER_1) {
this->rx_buffer_[this->rx_index_++] = byte;
}
continue;
}
if (this->rx_index_ == 1) {
if (byte == APC1_HEADER_2) {
this->rx_buffer_[this->rx_index_++] = byte;
} else if (byte != APC1_HEADER_1) {
this->rx_index_ = 0;
}
continue;
}
this->rx_buffer_[this->rx_index_++] = byte;
if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) {
continue;
}
// The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a
// corrupt length from wrapping the total and walking past the buffer
const uint16_t payload_len = encode_uint16(this->rx_buffer_[2], this->rx_buffer_[3]);
if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) {
ESP_LOGW(TAG, "Invalid frame length: %u", payload_len);
this->rx_index_ = 0;
continue;
}
const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE;
if (this->rx_index_ == total_len) {
this->parse_frame_(total_len);
this->rx_index_ = 0;
}
}
}
void APC1Component::parse_frame_(uint16_t total_len) {
uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]);
uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2);
if (expected_checksum != calculated_checksum) {
ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum,
expected_checksum);
return;
}
if (total_len == APC1_FRAME_SIZE_MEASUREMENT) {
this->parse_measurement_frame_();
} else if (total_len == APC1_FRAME_SIZE_DEVICE_INFO) {
this->parse_device_info_frame_();
} else if (total_len == APC1_FRAME_SIZE_COMMAND_RESPONSE) {
ESP_LOGD(TAG, "APC1 command response: cmd=0x%02X, data=0x%02X", this->rx_buffer_[4], this->rx_buffer_[5]);
} else {
ESP_LOGD(TAG, "Received frame of unknown length %u", total_len);
}
}
// One message per error bit, bit 0 first
PROGMEM_STRING_TABLE(ErrorStrings, "Fan restart error (too many restarts)", "Fan speed low", "Photodiode fault",
"Fan stopped / startup error", "Laser fault", "VOC sensor fault", "RHT sensor fault");
void APC1Component::parse_measurement_frame_() {
const uint32_t now = App.get_loop_component_start_time();
this->last_valid_frame_ = now;
uint8_t error_code = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_ERROR_CODE];
if (error_code != this->last_error_code_) {
if (error_code != 0) {
for (uint8_t bit = 0; bit < ErrorStrings::COUNT; bit++) {
if (error_code & (1 << bit)) {
ESP_LOGW(TAG, "APC1: %s", LOG_STR_ARG(ErrorStrings::get_log_str(bit, ErrorStrings::LAST_INDEX)));
}
}
this->status_set_warning(LOG_STR("Sensor reported hardware error"));
} else {
ESP_LOGI(TAG, "APC1: Hardware error cleared");
this->status_clear_warning();
}
this->last_error_code_ = error_code;
if (this->error_code_sensor_ != nullptr) {
this->error_code_sensor_->publish_state(error_code);
}
}
uint8_t aqi = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_AQI];
if (aqi == 0) {
if (!this->gas_warming_up_) {
this->gas_warming_up_ = true;
ESP_LOGI(TAG, "APC1: Gas sensor warming up (AQI=0)");
}
} else if (this->gas_warming_up_) {
this->gas_warming_up_ = false;
ESP_LOGI(TAG, "APC1: Gas sensor warm-up complete (AQI=%u)", aqi);
}
// Measurement payload data (bytes 4-57): big-endian sensor readings.
// The values below feed the debug summary, so they are always decoded; every
// other reading is only decoded when its sensor is configured.
const auto u16 = [this](uint8_t offset) {
return encode_uint16(this->rx_buffer_[offset], this->rx_buffer_[offset + 1]);
};
const auto u32 = [this](uint8_t offset) {
return encode_uint32(this->rx_buffer_[offset], this->rx_buffer_[offset + 1], this->rx_buffer_[offset + 2],
this->rx_buffer_[offset + 3]);
};
uint16_t pm_1_0 = u16(10);
uint16_t pm_2_5 = u16(12);
uint16_t pm_10_0 = u16(14);
uint16_t tvoc = u16(28);
uint16_t eco2 = u16(30);
float temp_comp = static_cast<int16_t>(u16(34)) / 10.0f;
float hum_comp = u16(36) / 10.0f;
ESP_LOGD(TAG, "APC1: PM1.0: %u, PM2.5: %u, PM10: %u, TVOC: %u, eCO2: %u, AQI: %u, Temp: %.1f°C, Hum: %.1f%%", pm_1_0,
pm_2_5, pm_10_0, tvoc, eco2, aqi, temp_comp, hum_comp);
if (this->pm_1_0_std_sensor_ != nullptr)
this->pm_1_0_std_sensor_->publish_state(u16(4));
if (this->pm_2_5_std_sensor_ != nullptr)
this->pm_2_5_std_sensor_->publish_state(u16(6));
if (this->pm_10_0_std_sensor_ != nullptr)
this->pm_10_0_std_sensor_->publish_state(u16(8));
if (this->pm_1_0_sensor_ != nullptr)
this->pm_1_0_sensor_->publish_state(pm_1_0);
if (this->pm_2_5_sensor_ != nullptr)
this->pm_2_5_sensor_->publish_state(pm_2_5);
if (this->pm_10_0_sensor_ != nullptr)
this->pm_10_0_sensor_->publish_state(pm_10_0);
if (this->pm_0_3um_sensor_ != nullptr)
this->pm_0_3um_sensor_->publish_state(u16(16));
if (this->pm_0_5um_sensor_ != nullptr)
this->pm_0_5um_sensor_->publish_state(u16(18));
if (this->pm_1_0um_sensor_ != nullptr)
this->pm_1_0um_sensor_->publish_state(u16(20));
if (this->pm_2_5um_sensor_ != nullptr)
this->pm_2_5um_sensor_->publish_state(u16(22));
if (this->pm_5_0um_sensor_ != nullptr)
this->pm_5_0um_sensor_->publish_state(u16(24));
if (this->pm_10_0um_sensor_ != nullptr)
this->pm_10_0um_sensor_->publish_state(u16(26));
if (this->tvoc_sensor_ != nullptr)
this->tvoc_sensor_->publish_state(tvoc);
if (this->eco2_sensor_ != nullptr)
this->eco2_sensor_->publish_state(eco2);
if (this->aqi_sensor_ != nullptr)
this->aqi_sensor_->publish_state(aqi > 0 ? aqi : NAN);
if (this->temperature_sensor_ != nullptr)
this->temperature_sensor_->publish_state(temp_comp);
if (this->humidity_sensor_ != nullptr)
this->humidity_sensor_->publish_state(hum_comp);
if (this->raw_temperature_sensor_ != nullptr) {
this->raw_temperature_sensor_->publish_state(static_cast<int16_t>(u16(38)) / 10.0f);
}
if (this->raw_humidity_sensor_ != nullptr)
this->raw_humidity_sensor_->publish_state(u16(40) / 10.0f);
if (this->rs0_sensor_ != nullptr) {
this->rs0_sensor_->publish_state(u32(42));
}
if (this->rs2_sensor_ != nullptr) {
this->rs2_sensor_->publish_state(u32(50));
}
if (this->rs3_sensor_ != nullptr) {
this->rs3_sensor_->publish_state(u32(54));
}
}
void APC1Component::parse_device_info_frame_() {
const uint8_t *serial = this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_SERIAL;
uint16_t fw_version =
encode_uint16(this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW], this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW + 1]);
ESP_LOGI(TAG, "APC1: Module: %.6s, Serial: %08" PRIX32 "%08" PRIX32 ", Firmware: 0x%04X (%u)",
reinterpret_cast<const char *>(this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_MODULE),
encode_uint32(serial[0], serial[1], serial[2], serial[3]),
encode_uint32(serial[4], serial[5], serial[6], serial[7]), fw_version, fw_version);
}
void APC1Component::arm_watchdog_() {
this->last_valid_frame_ = App.get_loop_component_start_time();
this->last_init_attempt_ = this->last_valid_frame_;
}
void APC1Component::set_active_mode() {
if (!this->active_mode_) {
this->arm_watchdog_();
}
this->active_mode_ = true;
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_ACTIVE);
}
void APC1Component::set_passive_mode() {
this->active_mode_ = false;
this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_PASSIVE);
}
void APC1Component::request_measurement() {
this->send_command_(APC1Command::APC1_COMMAND_REQUEST_MEASUREMENT, 0x0000);
}
void APC1Component::set_idle_mode() {
this->idle_ = true;
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_IDLE);
}
void APC1Component::set_measurement_mode() {
this->idle_ = false;
this->arm_watchdog_();
this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_STANDARD);
}
void APC1Component::send_command_(APC1Command cmd, uint16_t data) {
std::array<uint8_t, APC1_COMMAND_FRAME_SIZE> buf{
APC1_HEADER_1,
APC1_HEADER_2,
static_cast<uint8_t>(cmd),
static_cast<uint8_t>((data >> 8) & 0xFF),
static_cast<uint8_t>(data & 0xFF),
0,
0,
};
uint16_t checksum = apc1_checksum(buf.data(), 5);
buf[5] = static_cast<uint8_t>((checksum >> 8) & 0xFF);
buf[6] = static_cast<uint8_t>(checksum & 0xFF);
this->write_array(buf);
}
} // namespace esphome::apc1
+99
View File
@@ -0,0 +1,99 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
#include <array>
namespace esphome::apc1 {
static constexpr uint8_t APC1_HEADER_1 = 0x42;
static constexpr uint8_t APC1_HEADER_2 = 0x4D;
static constexpr uint8_t APC1_FRAME_HEADER_SIZE = 4; // 2 start bytes (0x42, 0x4D) + 2 frame length bytes
static constexpr uint8_t APC1_FRAME_SIZE_MEASUREMENT = 64;
static constexpr uint8_t APC1_FRAME_SIZE_DEVICE_INFO = 23;
static constexpr uint8_t APC1_FRAME_SIZE_COMMAND_RESPONSE = 8;
static constexpr uint8_t APC1_COMMAND_FRAME_SIZE = 7;
enum class APC1Command : uint8_t {
APC1_COMMAND_MEASUREMENT_MODE = 0xE1,
APC1_COMMAND_REQUEST_MEASUREMENT = 0xE2,
APC1_COMMAND_OPERATION_MODE = 0xE4,
APC1_COMMAND_READ_SENSOR_VERSION = 0xE9,
};
static constexpr uint16_t APC1_MEASUREMENT_MODE_PASSIVE = 0x0000;
static constexpr uint16_t APC1_MEASUREMENT_MODE_ACTIVE = 0x0001;
static constexpr uint16_t APC1_OPERATING_MODE_IDLE = 0x0000;
static constexpr uint16_t APC1_OPERATING_MODE_STANDARD = 0x0001;
class APC1Component final : public uart::UARTDevice, public Component {
SUB_SENSOR(pm_1_0)
SUB_SENSOR(pm_2_5)
SUB_SENSOR(pm_10_0)
SUB_SENSOR(pm_1_0_std)
SUB_SENSOR(pm_2_5_std)
SUB_SENSOR(pm_10_0_std)
SUB_SENSOR(pm_0_3um)
SUB_SENSOR(pm_0_5um)
SUB_SENSOR(pm_1_0um)
SUB_SENSOR(pm_2_5um)
SUB_SENSOR(pm_5_0um)
SUB_SENSOR(pm_10_0um)
SUB_SENSOR(tvoc)
SUB_SENSOR(eco2)
SUB_SENSOR(aqi)
SUB_SENSOR(temperature)
SUB_SENSOR(humidity)
SUB_SENSOR(raw_temperature)
SUB_SENSOR(raw_humidity)
SUB_SENSOR(rs0)
SUB_SENSOR(rs2)
SUB_SENSOR(rs3)
SUB_SENSOR(error_code)
public:
void setup() override;
void dump_config() override;
void loop() override;
void set_active_mode();
void set_passive_mode();
void request_measurement();
void set_idle_mode();
void set_measurement_mode();
void set_set_pin(GPIOPin *set_pin) { this->set_pin_ = set_pin; }
void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; }
protected:
void send_command_(APC1Command cmd, uint16_t data);
void initialize_device_();
/// Restarts the no-data watchdog so a mode change gets the same grace period as a cold boot
void arm_watchdog_();
void parse_frame_(uint16_t total_len);
void parse_measurement_frame_();
void parse_device_info_frame_();
GPIOPin *set_pin_{nullptr};
GPIOPin *reset_pin_{nullptr};
uint32_t last_transmission_{0};
uint32_t last_valid_frame_{0};
uint32_t last_init_attempt_{0};
std::array<uint8_t, APC1_FRAME_SIZE_MEASUREMENT> rx_buffer_{};
uint8_t rx_index_{0};
uint8_t last_error_code_{0xFF};
bool active_mode_{true};
bool idle_{false};
bool gas_warming_up_{false};
};
} // namespace esphome::apc1
+222
View File
@@ -0,0 +1,222 @@
from esphome import automation, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import sensor, uart
from esphome.components.const import CONF_AQI
import esphome.config_validation as cv
from esphome.const import (
CONF_ECO2,
CONF_HUMIDITY,
CONF_ID,
CONF_PM_0_3UM,
CONF_PM_0_5UM,
CONF_PM_1_0,
CONF_PM_1_0_STD,
CONF_PM_1_0UM,
CONF_PM_2_5,
CONF_PM_2_5_STD,
CONF_PM_2_5UM,
CONF_PM_5_0UM,
CONF_PM_10_0,
CONF_PM_10_0_STD,
CONF_PM_10_0UM,
CONF_RESET_PIN,
CONF_TEMPERATURE,
CONF_TVOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
DEVICE_CLASS_PM25,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_CHEMICAL_WEAPON,
ICON_MOLECULE_CO2,
ICON_OMEGA,
ICON_RADIATOR,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_COUNT_DECILITRE,
UNIT_MICROGRAMS_PER_CUBIC_METER,
UNIT_OHM,
UNIT_PARTS_PER_BILLION,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
)
from esphome.types import ConfigType
CODEOWNERS = ["@ademuri"]
DEPENDENCIES = ["uart"]
CONF_SET_PIN = "set_pin"
CONF_RAW_TEMPERATURE = "raw_temperature"
CONF_RAW_HUMIDITY = "raw_humidity"
CONF_RS0 = "rs0"
CONF_RS2 = "rs2"
CONF_RS3 = "rs3"
CONF_ERROR_CODE = "error_code"
apc1_ns = cg.esphome_ns.namespace("apc1")
APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component)
def _pm_mass_schema(device_class: str) -> cv.Schema:
return sensor.sensor_schema(
unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
device_class=device_class,
state_class=STATE_CLASS_MEASUREMENT,
)
_PM_COUNT_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNT_DECILITRE,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
_TEMPERATURE_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
)
_HUMIDITY_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
)
_RESISTANCE_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_OHM,
icon=ICON_OMEGA,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(APC1Component),
cv.Optional(CONF_PM_1_0): _pm_mass_schema(DEVICE_CLASS_PM1),
cv.Optional(CONF_PM_2_5): _pm_mass_schema(DEVICE_CLASS_PM25),
cv.Optional(CONF_PM_10_0): _pm_mass_schema(DEVICE_CLASS_PM10),
cv.Optional(CONF_PM_1_0_STD): _pm_mass_schema(DEVICE_CLASS_PM1),
cv.Optional(CONF_PM_2_5_STD): _pm_mass_schema(DEVICE_CLASS_PM25),
cv.Optional(CONF_PM_10_0_STD): _pm_mass_schema(DEVICE_CLASS_PM10),
cv.Optional(CONF_PM_0_3UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_0_5UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_1_0UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_2_5UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_5_0UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_PM_10_0UM): _PM_COUNT_SCHEMA,
cv.Optional(CONF_TVOC): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_BILLION,
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_ECO2): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_MILLION,
icon=ICON_MOLECULE_CO2,
accuracy_decimals=0,
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_AQI): sensor.sensor_schema(
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): _TEMPERATURE_SCHEMA,
cv.Optional(CONF_HUMIDITY): _HUMIDITY_SCHEMA,
cv.Optional(CONF_RAW_TEMPERATURE): _TEMPERATURE_SCHEMA,
cv.Optional(CONF_RAW_HUMIDITY): _HUMIDITY_SCHEMA,
cv.Optional(CONF_RS0): _RESISTANCE_SCHEMA,
cv.Optional(CONF_RS2): _RESISTANCE_SCHEMA,
cv.Optional(CONF_RS3): _RESISTANCE_SCHEMA,
cv.Optional(CONF_ERROR_CODE): sensor.sensor_schema(
icon="mdi:alert-circle-outline",
accuracy_decimals=0,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_SET_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
def final_validate(config: ConfigType) -> None:
schema = uart.final_validate_device_schema(
"apc1", baud_rate=9600, require_rx=True, require_tx=True
)
schema(config)
FINAL_VALIDATE_SCHEMA = final_validate
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
sensors = sensor.sub_sensors(config)
await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor)
await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor)
await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor)
await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor)
await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor)
await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor)
await sensors(CONF_PM_0_3UM, var.set_pm_0_3um_sensor)
await sensors(CONF_PM_0_5UM, var.set_pm_0_5um_sensor)
await sensors(CONF_PM_1_0UM, var.set_pm_1_0um_sensor)
await sensors(CONF_PM_2_5UM, var.set_pm_2_5um_sensor)
await sensors(CONF_PM_5_0UM, var.set_pm_5_0um_sensor)
await sensors(CONF_PM_10_0UM, var.set_pm_10_0um_sensor)
await sensors(CONF_TVOC, var.set_tvoc_sensor)
await sensors(CONF_ECO2, var.set_eco2_sensor)
await sensors(CONF_AQI, var.set_aqi_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
await sensors(CONF_RAW_TEMPERATURE, var.set_raw_temperature_sensor)
await sensors(CONF_RAW_HUMIDITY, var.set_raw_humidity_sensor)
await sensors(CONF_RS0, var.set_rs0_sensor)
await sensors(CONF_RS2, var.set_rs2_sensor)
await sensors(CONF_RS3, var.set_rs3_sensor)
await sensors(CONF_ERROR_CODE, var.set_error_code_sensor)
if (set_pin_config := config.get(CONF_SET_PIN)) is not None:
set_pin = await cg.gpio_pin_expression(set_pin_config)
cg.add(var.set_set_pin(set_pin))
if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None:
reset_pin = await cg.gpio_pin_expression(reset_pin_config)
cg.add(var.set_reset_pin(reset_pin))
APC1_ACTION_SCHEMA = maybe_simple_id(
{
cv.GenerateID(): cv.use_id(APC1Component),
}
)
for _name, _call in (
("apc1.set_active_mode", "set_active_mode()"),
("apc1.set_passive_mode", "set_passive_mode()"),
("apc1.request_measurement", "request_measurement()"),
("apc1.set_idle_mode", "set_idle_mode()"),
("apc1.set_measurement_mode", "set_measurement_mode()"),
):
automation.register_apply_action(
_name, APC1_ACTION_SCHEMA, automation.ApplyCall(_call)
)
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@iyesin"]
DOMAIN = "apds9930"
+140
View File
@@ -0,0 +1,140 @@
#include "apds9930.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::apds9930 {
ESPHOME_LOG_TAG(TAG, "apds9930");
// Register addresses
static constexpr uint8_t APDS9930_ENABLE = 0x00;
static constexpr uint8_t APDS9930_ATIME = 0x01;
static constexpr uint8_t APDS9930_PTIME = 0x02;
static constexpr uint8_t APDS9930_PPULSE = 0x0E;
static constexpr uint8_t APDS9930_CONTROL = 0x0F;
static constexpr uint8_t APDS9930_ID = 0x12;
static constexpr uint8_t APDS9930_STATUS = 0x13;
// Command byte: repeated byte and auto-increment protocols
static constexpr uint8_t APDS9930_CMD = 0x80;
static constexpr uint8_t APDS9930_CMD_AUTO_INCREMENT = 0xA0;
static constexpr uint8_t APDS9930_CHIP_ID = 0x39;
// ENABLE register bits
static constexpr uint8_t APDS9930_PON = 0x01;
static constexpr uint8_t APDS9930_AEN = 0x02;
static constexpr uint8_t APDS9930_PEN = 0x04;
// STATUS register bits
static constexpr uint8_t APDS9930_AVALID = 0x01;
static constexpr uint8_t APDS9930_PVALID = 0x02;
// CONTROL register fields
static constexpr uint8_t CONTROL_PDRIVE_SHIFT = 6;
static constexpr uint8_t CONTROL_PDIODE_SHIFT = 4;
static constexpr uint8_t CONTROL_PGAIN_SHIFT = 2;
static constexpr uint8_t CONTROL_AGAIN_SHIFT = 0;
static constexpr uint8_t CONTROL_PDIODE_CH1 = 0b10; // The only supported proximity diode
static constexpr uint8_t APDS9930_ATIME_VALUE = 0xED; // 19 cycles, about 52 ms
static constexpr uint8_t APDS9930_PTIME_VALUE = 0xFF; // 2.73 ms
static constexpr uint8_t APDS9930_PPULSE_VALUE = 0x08; // 8 pulses
// Lux calculation coefficients
static constexpr float APDS9930_DF = 52.0f;
static constexpr float APDS9930_GA = 0.49f;
static constexpr float APDS9930_ALS_B = 1.862f;
static constexpr float APDS9930_ALS_C = 0.746f;
static constexpr float APDS9930_ALS_D = 1.291f;
static constexpr float APDS9930_ALSIT_MS = 2.73f * (256 - APDS9930_ATIME_VALUE);
static constexpr uint8_t AMBIENT_GAINS[4] PROGMEM = {1, 8, 16, 120};
static constexpr uint8_t PROXIMITY_GAINS[4] PROGMEM = {1, 2, 4, 8};
static constexpr float LED_CURRENT_MAX_MA = 100.0f; // halved for each drive step
// Lux per count at 1x gain and the fixed integration time
static constexpr float LUX_PER_COUNT_1X = APDS9930_GA * APDS9930_DF / APDS9930_ALSIT_MS;
// Each integration cycle adds up to 1024 counts, so this is the ceiling of both light channels
static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE);
bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); }
void APDS9930Component::setup() {
uint8_t id;
if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) {
this->mark_failed(LOG_STR("Communication failed"));
return;
}
if (id != APDS9930_CHIP_ID) {
ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID);
this->mark_failed(LOG_STR("Wrong chip ID"));
return;
}
const uint8_t control =
(this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT |
(this->proximity_gain_ & 0b11) << CONTROL_PGAIN_SHIFT | (this->ambient_gain_ & 0b11) << CONTROL_AGAIN_SHIFT;
uint8_t enable = APDS9930_PON;
if (this->illuminance_sensor_ != nullptr)
enable |= APDS9930_AEN;
if (this->proximity_sensor_ != nullptr)
enable |= APDS9930_PEN;
if (!this->write_reg_(APDS9930_ENABLE, 0x00) || !this->write_reg_(APDS9930_ATIME, APDS9930_ATIME_VALUE) ||
!this->write_reg_(APDS9930_PTIME, APDS9930_PTIME_VALUE) ||
!this->write_reg_(APDS9930_PPULSE, APDS9930_PPULSE_VALUE) || !this->write_reg_(APDS9930_CONTROL, control) ||
!this->write_reg_(APDS9930_ENABLE, enable)) {
this->mark_failed(LOG_STR("Configuration failed"));
}
}
void APDS9930Component::dump_config() {
ESP_LOGCONFIG(TAG,
"APDS9930:\n"
" LED Drive: %.1f mA\n"
" Proximity Gain: %ux\n"
" Ambient Light Gain: %ux",
LED_CURRENT_MAX_MA / (1 << this->led_drive_),
progmem_read_byte(&PROXIMITY_GAINS[this->proximity_gain_]),
progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_]));
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_);
LOG_SENSOR(" ", "Proximity", this->proximity_sensor_);
}
void APDS9930Component::update() {
// STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H
uint8_t raw[7];
if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) {
this->status_set_warning(LOG_STR("Reading data failed"));
return;
}
this->status_clear_warning();
const uint8_t status = raw[0];
if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) {
const uint16_t ch0 = encode_uint16(raw[2], raw[1]);
const uint16_t ch1 = encode_uint16(raw[4], raw[3]);
if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) {
// A saturated channel would give a lux value that is too low, not too high
ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain");
this->illuminance_sensor_->publish_state(NAN);
} else {
const float iac = std::max({ch0 - APDS9930_ALS_B * ch1, APDS9930_ALS_C * ch0 - APDS9930_ALS_D * ch1, 0.0f});
this->illuminance_sensor_->publish_state(iac * LUX_PER_COUNT_1X /
progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_]));
}
}
if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) {
this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5]));
}
}
} // namespace esphome::apds9930
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::apds9930 {
class APDS9930Component final : public PollingComponent, public i2c::I2CDevice {
SUB_SENSOR(illuminance)
SUB_SENSOR(proximity)
public:
void setup() override;
void dump_config() override;
void update() override;
void set_led_drive(uint8_t level) { this->led_drive_ = level; }
void set_proximity_gain(uint8_t gain) { this->proximity_gain_ = gain; }
void set_ambient_gain(uint8_t gain) { this->ambient_gain_ = gain; }
protected:
bool write_reg_(uint8_t reg, uint8_t value);
uint8_t led_drive_{0};
uint8_t proximity_gain_{0};
uint8_t ambient_gain_{0};
};
} // namespace esphome::apds9930
+70
View File
@@ -0,0 +1,70 @@
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.const import UNIT_COUNTS
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_ILLUMINANCE,
DEVICE_CLASS_ILLUMINANCE,
STATE_CLASS_MEASUREMENT,
UNIT_LUX,
)
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
CONF_AMBIENT_LIGHT_GAIN = "ambient_light_gain"
CONF_LED_DRIVE = "led_drive"
CONF_PROXIMITY = "proximity"
CONF_PROXIMITY_GAIN = "proximity_gain"
DRIVE_LEVELS = {"100ma": 0, "50ma": 1, "25ma": 2, "12.5ma": 3}
PROXIMITY_LEVELS = {"1x": 0, "2x": 1, "4x": 2, "8x": 3}
AMBIENT_LEVELS = {"1x": 0, "8x": 1, "16x": 2, "120x": 3}
apds9930_ns = cg.esphome_ns.namespace("apds9930")
APDS9930Component = apds9930_ns.class_(
"APDS9930Component", cg.PollingComponent, i2c.I2CDevice
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(APDS9930Component),
cv.Optional(CONF_LED_DRIVE, default="100ma"): cv.enum(
DRIVE_LEVELS, lower=True
),
cv.Optional(CONF_PROXIMITY_GAIN, default="8x"): cv.enum(
PROXIMITY_LEVELS, lower=True
),
cv.Optional(CONF_AMBIENT_LIGHT_GAIN, default="1x"): cv.enum(
AMBIENT_LEVELS, lower=True
),
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_LUX,
accuracy_decimals=1,
device_class=DEVICE_CLASS_ILLUMINANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PROXIMITY): sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x39))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_led_drive(config[CONF_LED_DRIVE]))
cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN]))
cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN]))
sensors = sensor.sub_sensors(config)
await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor)
await sensors(CONF_PROXIMITY, var.set_proximity_sensor)
+8 -34
View File
@@ -54,7 +54,7 @@ from esphome.const import (
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.cpp_generator import Expression, MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.helpers import cpp_string_escape, fnv1_hash
from esphome.helpers import fnv1_hash
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigFragmentType, ConfigType
@@ -537,28 +537,11 @@ FINAL_VALIDATE_SCHEMA = cv.All(
)
def _add_action_strings(
index: int, strings: list[str | None], interned: dict[str, MockObj]
) -> MockObj:
"""Emit the PROGMEM string table for one action.
Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical
strings are shared between actions through `interned`.
"""
entries: list[MockObj] = []
for string in strings:
if string is None:
entries.append(cg.nullptr)
continue
if (var := interned.get(string)) is None:
var = interned[string] = cg.progmem_array(
ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char),
string,
)
entries.append(var)
def _add_action_strings(index: int, strings: list[str | None]) -> MockObj:
"""Emit the PROGMEM string table for one action."""
return cg.progmem_array(
ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr),
entries,
[cg.nullptr if s is None else cg.shared_progmem_string(s) for s in strings],
)
@@ -611,7 +594,6 @@ async def to_code(config: ConfigType) -> None:
has_metadata = _has_action_metadata(actions)
if has_metadata:
cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA")
interned_strings: dict[str, MockObj] = {}
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.MockObj] = []
for index, conf in enumerate(actions):
@@ -656,7 +638,7 @@ async def to_code(config: ConfigType) -> None:
service_template_args.append(native)
func_args.append((native, name))
strings = _action_strings(conf, has_metadata)
table = _add_action_strings(index, strings, interned_strings)
table = _add_action_strings(index, strings)
if CORE.is_esp8266:
scratch_size = max(scratch_size, _action_strings_size(strings))
# Template args: supports_response mode, then user service arg types
@@ -682,7 +664,7 @@ async def to_code(config: ConfigType) -> None:
unregister_id,
var,
)
cg.add(auto.add_actions([unregister_action]))
cg.add(auto.add_action(unregister_action))
# Register all services at once - single allocation, no reallocations
cg.add(var.initialize_user_services(triggers))
if CORE.is_esp8266 and has_user_actions:
@@ -828,14 +810,6 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
)
def _field_string(value: str) -> Expression:
# ESP8266 can only keep a string in flash as its own PROGMEM array
literal = cg.RawExpression(cpp_string_escape(value))
if CORE.is_esp8266:
return cg.shared_progmem_array("ha_field_str", cg.char, literal)
return literal
async def _new_service_call_action(
server_id: ID,
action_id: ID,
@@ -858,12 +832,12 @@ async def _new_service_call_action(
)
async def field(key: str | None, value: Any, output_type: Any = None) -> Expression:
key_exp = cg.nullptr if key is None else _field_string(key)
key_exp = cg.nullptr if key is None else cg.shared_progmem_string(key)
if cg.is_template(value):
# output_type=None lets lambdas return numbers or char pointers; C++ converts them
lam = await cg.process_lambda(value, args, return_type=output_type)
return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})")
return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr)
return cg.ArrayInitializer(key_exp, cg.shared_progmem_string(value), cg.nullptr)
entries = [await field(None, service, cg.std_string)]
for group in groups:
+18 -5
View File
@@ -444,7 +444,7 @@ message DeviceWizardRequest {
// {"version": 1,
// "pages": [{"title": "...", "description": "...",
// "entities": [{"key": 123, "device_id": 456, "description": "..."}],
// "inputs": [{"key": 789, "description": "...",
// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
// "entity_filters": [{"integration": "...", "domain": ["..."],
// "device_class": ["..."], "supported_features": ["..."]}]}]}]}
//
@@ -468,6 +468,14 @@ message DeviceWizardRequest {
// an entity matches a filter when it satisfies every field that is set, and an entity
// that matches any one of the filters is accepted.
//
// An input with "multiple": true lets the user pick several entities. The client sends
// them in one WizardInputSetRequest as a comma separated list with no spaces
// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the
// list without them. "max_length" (only on a multiple input, 255 when left out) is the
// longest list in bytes the device accepts. The version stays 1: a client that does not
// know "multiple" ignores it, shows a single picker and sends one entity id, which a
// multiple input accepts.
//
// The device keeps the entity id of an input in RAM only and does not store it across
// restarts. The client therefore stores the choice itself and, after every connection
// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest
@@ -483,8 +491,13 @@ message DeviceWizardResponse {
// Sets the entity id of a wizard input (see WizardInputField.key). Clients send
// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no
// reply. The device ignores a request whose key matches no input or whose entity_id
// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM,
// reply. The device ignores a request whose key matches no input. A single input is
// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple
// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no
// spaces, the device trims spaces around each id and stores the list without them. The
// whole request is ignored when any id is empty or has no '.', or when the list without
// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id
// or list in RAM,
// not across restarts, and when the input belongs to a homeassistant platform entity
// it subscribes to that entity's state. Clients send this for every input right
// after connecting, before SubscribeHomeAssistantStatesRequest. A request that
@@ -496,7 +509,7 @@ message WizardInputSetRequest {
option (ifdef) = "USE_API_WIZARD_INPUTS";
fixed32 key = 1;
string entity_id = 2 [(max_data_length) = 255];
string entity_id = 2 [(max_data_length) = 4095];
}
message ListEntitiesRequest {
@@ -2557,7 +2570,7 @@ message ListEntitiesEventResponse {
EntityCategory entity_category = 7;
string device_class = 8 [(max_data_length) = 47];
repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector<const char *>"];
repeated string event_types = 9 [(container_pointer_no_template) = "ConstVector<const char *, true>"];
uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"];
}
message EventResponse {
+1 -1
View File
@@ -3460,7 +3460,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage {
const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); }
#endif
StringRef device_class{nullptr, 0}; // null until set, encode only
const FixedVector<const char *> *event_types{};
const ConstVector<const char *, true> *event_types{};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
+2
View File
@@ -237,6 +237,8 @@ void APIServer::refill_spare_ephemeral_() {
return;
}
}
// Generating the key pair cannot be split across loop passes
UnavoidableBlockingScope scope;
noise::prepare_spare_ephemeral();
}
#endif
+49 -7
View File
@@ -14,26 +14,68 @@ namespace esphome::api {
ESPHOME_LOG_TAG(TAG, "api.wizard");
#ifdef USE_API_WIZARD_INPUTS
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined
// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr.
static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) {
size_t joined = 0;
const char *end = value + length;
while (true) {
const char *comma = static_cast<const char *>(memchr(value, ',', end - value));
const char *id_end = comma != nullptr ? comma : end;
while (value < id_end && *value == ' ')
value++;
while (id_end > value && id_end[-1] == ' ')
id_end--;
size_t id_length = id_end - value;
if (id_length == 0 || memchr(value, '.', id_length) == nullptr)
return 0;
if (joined != 0) {
if (out != nullptr)
out[joined] = ',';
joined++;
}
if (out != nullptr)
memcpy(out + joined, value, id_length);
joined += id_length;
if (comma == nullptr)
return joined;
value = comma + 1;
}
}
#endif // USE_API_WIZARD_MULTIPLE_INPUTS
static bool wizard_entity_id_valid(const char *entity_id, size_t length) {
return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr;
}
const char *wizard_set_input(const WizardInputSetRequest &msg) {
if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) {
ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input");
return nullptr;
}
const char *value = msg.entity_id.c_str();
const size_t length = msg.entity_id.size();
for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) {
// The table is in flash, which ESP8266 can only read through progmem_memcpy
WizardInputEntry entry;
progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry));
if (entry.key != msg.key)
continue;
memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size());
entry.entity_id[msg.entity_id.size()] = '\0';
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
if (entry.size != 0) {
// Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged
const size_t joined = wizard_join_entity_ids(value, length, nullptr);
if (joined == 0 || joined >= entry.size)
break;
wizard_join_entity_ids(value, length, entry.entity_id);
entry.entity_id[joined] = '\0';
return entry.entity_id;
}
#endif
if (!wizard_entity_id_valid(value, length))
break;
memcpy(entry.entity_id, value, length);
entry.entity_id[length] = '\0';
return entry.entity_id;
}
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input");
ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid");
return nullptr;
}
#endif // USE_API_WIZARD_INPUTS
+33 -2
View File
@@ -6,6 +6,9 @@
#include <cstddef>
#include <cstdint>
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
#include <cstring>
#endif
#include "esphome/core/hal.h"
#include "proto.h"
@@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM;
/// Where the entity id of an input is kept, found by the key the client uses for it.
struct WizardInputEntry {
uint32_t key; // FNV-1 hash of the ESPHome id of the input
char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity
char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
uint16_t size; // Buffer size of a multiple input, 0 for a single input
#endif
};
/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long.
@@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg);
class WizardInput {
public:
explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {}
/// The Home Assistant entity ID, empty until the wizard sets one.
/// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole
/// comma separated list, which homeassistant.action data can use as it is.
StringRef entity_id() const { return StringRef(this->entity_id_); }
bool has_entity_id() const { return this->entity_id_[0] != '\0'; }
#ifdef USE_API_WIZARD_MULTIPLE_INPUTS
/// The number of entity IDs in the list, 0 until the wizard sets one.
size_t entity_id_count() const {
if (!this->has_entity_id())
return 0;
size_t count = 1;
for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++)
count++;
return count;
}
/// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is
/// not null terminated, so print it with "%.*s".
template<typename F> void for_each_entity_id(F &&callback) const {
// The device stores the list without spaces or empty entries
const char *start = this->entity_id_;
while (*start != '\0') {
const char *end = strchr(start, ',');
if (end == nullptr)
end = start + strlen(start);
callback(StringRef(start, end - start));
start = *end == ',' ? end + 1 : end;
}
}
#endif
protected:
const char *entity_id_;
+48 -7
View File
@@ -20,6 +20,8 @@ from esphome.const import (
CONF_ENTITY_ID,
CONF_ID,
CONF_INTERNAL,
CONF_MAX_LENGTH,
CONF_MULTIPLE,
CONF_NAME,
CONF_PAGES,
CONF_PLATFORM,
@@ -51,6 +53,10 @@ WizardInput = _API.class_("WizardInput")
WIZARD_ENTITY_ID_BUFFER_SIZE = (
256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes
)
WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1
# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266;
# api.proto max_data_length documents the same value
WIZARD_MULTIPLE_MAX_LENGTH = 4095
# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes,
# Noise) and footer (16 bytes, Noise MAC)
WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16
@@ -106,6 +112,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema(
)
def _validate_multiple(conf: ConfigType) -> ConfigType:
if conf[CONF_MULTIPLE] and CONF_ENTITY in conf:
raise cv.Invalid(
"A linked input follows the state of exactly one Home Assistant entity, so multiple "
"is only for standalone inputs (id)",
path=[CONF_MULTIPLE],
)
if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]:
raise cv.Invalid(
"max_length only applies to an input with multiple: true",
path=[CONF_MAX_LENGTH],
)
return conf
# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no
# entity_id of its own: Home Assistant sets it
WIZARD_INPUT_SCHEMA = cv.All(
@@ -114,6 +135,10 @@ WIZARD_INPUT_SCHEMA = cv.All(
cv.Optional(CONF_ID): cv.declare_id(WizardInput),
cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase),
cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH),
cv.Optional(CONF_MULTIPLE, default=False): cv.boolean,
cv.Optional(CONF_MAX_LENGTH): cv.int_range(
min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH
),
cv.Optional(CONF_TARGET): cv.Schema(
{
cv.Required(CONF_ENTITY): cv.All(
@@ -124,6 +149,7 @@ WIZARD_INPUT_SCHEMA = cv.All(
}
),
cv.has_exactly_one_key(CONF_ID, CONF_ENTITY),
_validate_multiple,
)
WIZARD_PAGE_SCHEMA = cv.All(
@@ -248,6 +274,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]:
defines.add("USE_API_WIZARD_LINKED_INPUTS")
if any(CONF_ID in conf for conf in inputs):
defines.add("USE_API_WIZARD_STANDALONE_INPUTS")
if any(conf.get(CONF_MULTIPLE) for conf in inputs):
defines.add("USE_API_WIZARD_MULTIPLE_INPUTS")
return defines
@@ -371,6 +399,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT
document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)}
if description := conf.get(CONF_DESCRIPTION):
document[CONF_DESCRIPTION] = description
if conf.get(CONF_MULTIPLE):
document["multiple"] = True
if (max_length := conf.get(CONF_MAX_LENGTH)) is not None:
document["max_length"] = max_length
if filters := _wizard_input_filters(conf, config):
document["entity_filters"] = [dict(entity_filter) for entity_filter in filters]
return document
@@ -384,7 +416,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
{"version": 1,
"pages": [{"title": "...", "description": "...",
"entities": [{"key": 123, "device_id": 456, "description": "..."}],
"inputs": [{"key": 789, "description": "...",
"inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023,
"entity_filters": [{"integration": "...", "domain": ["..."],
"device_class": ["..."], "supported_features": ["..."]}]}]}]}
@@ -396,6 +428,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi
the device it belongs to (esphome/core/config.py), and is left out for the main device.
- An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked
entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets.
- multiple means the user may pick several entities. The client sends them in one
WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the
device trims spaces around each id and stores the list without them. The whole request is
ignored when any id is empty or has no '.', or when the list without spaces does not fit
max_length (only on a multiple input, 255 when left out). A single input is ignored when the id
is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know
multiple ignores it, shows a single picker and sends one entity id, which a multiple input
accepts.
- entity_filters are the filters of the input, or the default filters of a linked switch,
number, text, select or button.
"""
@@ -441,14 +481,15 @@ async def to_code(wizard: ConfigType) -> None:
input_id = _wizard_input_id(conf)
# Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id.
buffer = _wizard_buffer_name(input_id)
cg.add_global(
cg.RawStatement(
f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";'
)
)
size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1
cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";'))
if CONF_ID in conf:
cg.new_Pvariable(input_id, cg.RawExpression(buffer))
entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}"))
key = fnv1_hash(input_id.id)
if conf[CONF_MULTIPLE]:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}"))
else:
entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}"))
if entries:
cg.extern_progmem_array(
"esphome::api::API_WIZARD_INPUTS",
+1 -1
View File
@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components.const import CONF_AQI # noqa: F401
CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"]
DOMAIN = "aqi"
@@ -6,7 +7,6 @@ DOMAIN = "aqi"
aqi_ns = cg.esphome_ns.namespace("aqi")
AQICalculatorType = aqi_ns.enum("AQICalculatorType")
CONF_AQI = "aqi"
CONF_CALCULATION_TYPE = "calculation_type"
CONF_EXTENDED_RANGE = "extended_range"
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@patrickod"]
DOMAIN = "aranet"
+157
View File
@@ -0,0 +1,157 @@
#include "aranet.h"
#include <cinttypes>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::aranet {
ESPHOME_LOG_TAG(TAG, "aranet");
static constexpr uint16_t ARANET_MANUFACTURER_ID = 0x0702;
static constexpr size_t ARANET4_DATA_LENGTH = 22;
static constexpr size_t OTHER_ARANET_DATA_LENGTH = 24;
enum class AranetDeviceType : uint8_t {
ARANET_DEVICE_TYPE_ARANET4 = 0,
ARANET_DEVICE_TYPE_ARANET2 = 1,
ARANET_DEVICE_TYPE_RADIATION = 2,
ARANET_DEVICE_TYPE_RADON = 3,
};
static inline void publish_if_set(sensor::Sensor *sensor, float value) {
if (sensor != nullptr)
sensor->publish_state(value);
}
// Aranet does not publish an official advertisement specification. The layout below is corroborated by these
// independently maintained implementations:
// https://github.com/Anrijs/Aranet4-Python/blob/master/aranet4/client.py
// https://github.com/Anrijs/Aranet4-ESP32/blob/main/src/Aranet4.h
void Aranet::dump_config() {
ESP_LOGCONFIG(TAG, "Aranet");
LOG_SENSOR(" ", "Carbon Dioxide", this->co2_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
LOG_SENSOR(" ", "Pressure", this->pressure_sensor_);
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
LOG_SENSOR(" ", "Measurement Interval", this->measurement_interval_sensor_);
LOG_SENSOR(" ", "Measurement Age", this->measurement_age_sensor_);
LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_);
LOG_SENSOR(" ", "Radon Concentration", this->radon_sensor_);
LOG_SENSOR(" ", "Radiation Dose Rate", this->radiation_rate_sensor_);
LOG_SENSOR(" ", "Radiation Total Dose", this->radiation_total_sensor_);
LOG_SENSOR(" ", "Radiation Dose Duration", this->radiation_duration_sensor_);
}
bool Aranet::parse_device(const ble_device_base::ESPBTDevice &device) {
if (device.address_uint64() != this->address_)
return false;
const auto manufacturer_uuid = ble_device_base::ESPBTUUID::from_uint16(ARANET_MANUFACTURER_ID);
for (const auto &manufacturer_data : device.get_manufacturer_datas()) {
if (manufacturer_data.uuid != manufacturer_uuid)
continue;
const auto &data = manufacturer_data.data;
if (data.size() != ARANET4_DATA_LENGTH && data.size() != OTHER_ARANET_DATA_LENGTH) {
if ((this->warned_ & WARNED_INVALID_LENGTH) == 0) {
ESP_LOGW(TAG, "Unexpected manufacturer data length: %u", static_cast<unsigned>(data.size()));
this->warned_ |= WARNED_INVALID_LENGTH;
}
return false;
}
// Aranet4 has no device type byte, so its flags and trailer start one byte earlier
const bool is_aranet4 = data.size() == ARANET4_DATA_LENGTH;
if ((data[is_aranet4 ? 0 : 1] & 0x20) == 0) {
if ((this->warned_ & WARNED_INTEGRATION_DISABLED) == 0) {
ESP_LOGW(TAG, "Smart Home integration is disabled on the Aranet device");
this->warned_ |= WARNED_INTEGRATION_DISABLED;
}
return false;
}
if (!is_aranet4 && (data[0] == 0 || data[0] > 3)) {
if ((this->warned_ & WARNED_UNSUPPORTED_TYPE) == 0) {
ESP_LOGW(TAG, "Unsupported Aranet device type: %u", data[0]);
this->warned_ |= WARNED_UNSUPPORTED_TYPE;
}
return false;
}
const auto device_type =
is_aranet4 ? AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4 : static_cast<AranetDeviceType>(data[0]);
// Battery, measurement interval, measurement age and measurement counter
const uint8_t *trailer = &data[is_aranet4 ? 15 : 17];
const uint16_t measurement_interval = encode_uint16(trailer[3], trailer[2]);
const uint16_t measurement_age = encode_uint16(trailer[5], trailer[4]);
const uint8_t measurement_counter = trailer[6];
publish_if_set(this->measurement_age_sensor_, measurement_age);
publish_if_set(this->signal_strength_sensor_, device.get_rssi());
if (measurement_counter == this->last_measurement_counter_)
return true;
this->last_measurement_counter_ = measurement_counter;
// Not meaningful for Aranet Radiation, which stores its dose duration in these bytes
const float temperature = encode_uint16(data[11], data[10]) / 20.0f;
const float pressure = encode_uint16(data[13], data[12]) / 10.0f;
switch (device_type) {
case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4: {
const uint16_t co2 = encode_uint16(data[9], data[8]);
publish_if_set(this->co2_sensor_, co2);
publish_if_set(this->temperature_sensor_, temperature);
publish_if_set(this->pressure_sensor_, pressure);
publish_if_set(this->humidity_sensor_, data[14]);
ESP_LOGD(TAG, "Aranet4 CO2: %u ppm, temperature: %.1f C, humidity: %u%%, pressure: %.1f hPa", co2, temperature,
data[14], pressure);
break;
}
case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET2: {
const float humidity = encode_uint16(data[15], data[14]) / 10.0f;
publish_if_set(this->temperature_sensor_, temperature);
publish_if_set(this->humidity_sensor_, humidity);
ESP_LOGD(TAG, "Aranet2 temperature: %.1f C, humidity: %.1f%%", temperature, humidity);
break;
}
case AranetDeviceType::ARANET_DEVICE_TYPE_RADIATION: {
const uint32_t radiation_total = encode_uint32(data[9], data[8], data[7], data[6]);
const uint32_t radiation_duration = encode_uint32(data[13], data[12], data[11], data[10]);
const uint16_t radiation_rate = encode_uint16(data[15], data[14]);
publish_if_set(this->radiation_rate_sensor_, radiation_rate / 1000.0f);
publish_if_set(this->radiation_total_sensor_, radiation_total);
publish_if_set(this->radiation_duration_sensor_, radiation_duration);
ESP_LOGD(TAG, "Aranet Radiation dose rate: %u nSv/h, total dose: %" PRIu32 " nSv", radiation_rate,
radiation_total);
break;
}
case AranetDeviceType::ARANET_DEVICE_TYPE_RADON: {
const uint16_t radon = encode_uint16(data[9], data[8]);
const float humidity = encode_uint16(data[15], data[14]) / 10.0f;
publish_if_set(this->radon_sensor_, radon);
// Aranet Radon One uses zero temperature and humidity to indicate fields not supported by that model.
if (temperature != 0)
publish_if_set(this->temperature_sensor_, temperature);
publish_if_set(this->pressure_sensor_, pressure);
if (humidity != 0)
publish_if_set(this->humidity_sensor_, humidity);
ESP_LOGD(TAG, "Aranet Radon: %u Bq/m3, temperature: %.1f C, humidity: %.1f%%, pressure: %.1f hPa", radon,
temperature, humidity, pressure);
break;
}
default:
break;
}
publish_if_set(this->battery_level_sensor_, trailer[0]);
publish_if_set(this->measurement_interval_sensor_, measurement_interval);
return true;
}
return false;
}
} // namespace esphome::aranet
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::aranet {
class Aranet final : public Component, public ble_device_base::ESPBTDeviceListener {
public:
explicit Aranet(uint64_t address) : address_(address) {}
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
void dump_config() override;
protected:
static constexpr uint8_t WARNED_INVALID_LENGTH = 1 << 0;
static constexpr uint8_t WARNED_INTEGRATION_DISABLED = 1 << 1;
static constexpr uint8_t WARNED_UNSUPPORTED_TYPE = 1 << 2;
// Small state first so it fills the padding before the first pointer
uint16_t last_measurement_counter_{0x100}; // Out of range of the 8-bit counter until the first measurement
uint8_t warned_{0};
SUB_SENSOR(co2)
SUB_SENSOR(temperature)
SUB_SENSOR(humidity)
SUB_SENSOR(pressure)
SUB_SENSOR(battery_level)
SUB_SENSOR(measurement_interval)
SUB_SENSOR(measurement_age)
SUB_SENSOR(signal_strength)
SUB_SENSOR(radon)
SUB_SENSOR(radiation_rate)
SUB_SENSOR(radiation_total)
SUB_SENSOR(radiation_duration)
protected:
uint64_t address_;
};
} // namespace esphome::aranet
+150
View File
@@ -0,0 +1,150 @@
import esphome.codegen as cg
from esphome.components import ble_device_base, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_CO2,
CONF_HUMIDITY,
CONF_ID,
CONF_MAC_ADDRESS,
CONF_PRESSURE,
CONF_RADON,
CONF_SIGNAL_STRENGTH,
CONF_TEMPERATURE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_RADON,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_RADIOACTIVE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_BECQUEREL_PER_CUBIC_METER,
UNIT_CELSIUS,
UNIT_DECIBEL_MILLIWATT,
UNIT_HECTOPASCAL,
UNIT_MICROSILVERTS_PER_HOUR,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
UNIT_SECOND,
)
from esphome.types import ConfigType
AUTO_LOAD = ["ble_device_base"]
CONF_MEASUREMENT_AGE = "measurement_age"
CONF_MEASUREMENT_INTERVAL = "measurement_interval"
CONF_RADIATION_RATE = "radiation_rate"
CONF_RADIATION_TOTAL = "radiation_total"
CONF_RADIATION_DURATION = "radiation_duration"
UNIT_NANOSIEVERT = "nSv"
aranet_ns = cg.esphome_ns.namespace("aranet")
Aranet = aranet_ns.class_("Aranet", ble_device_base.ESPBTDeviceListener, cg.Component)
DIAGNOSTIC_DURATION_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_SECOND,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(Aranet),
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_CO2): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_MILLION,
accuracy_decimals=0,
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HUMIDITY): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_HUMIDITY,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_PRESSURE): sensor.sensor_schema(
unit_of_measurement=UNIT_HECTOPASCAL,
accuracy_decimals=1,
device_class=DEVICE_CLASS_PRESSURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_MEASUREMENT_INTERVAL): DIAGNOSTIC_DURATION_SCHEMA,
cv.Optional(CONF_MEASUREMENT_AGE): DIAGNOSTIC_DURATION_SCHEMA,
cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema(
unit_of_measurement=UNIT_DECIBEL_MILLIWATT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_SIGNAL_STRENGTH,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_RADON): sensor.sensor_schema(
unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER,
accuracy_decimals=0,
device_class=DEVICE_CLASS_RADON,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_RADIATION_RATE): sensor.sensor_schema(
unit_of_measurement=UNIT_MICROSILVERTS_PER_HOUR,
accuracy_decimals=3,
icon=ICON_RADIOACTIVE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_RADIATION_TOTAL): sensor.sensor_schema(
unit_of_measurement=UNIT_NANOSIEVERT,
accuracy_decimals=0,
icon=ICON_RADIOACTIVE,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_RADIATION_DURATION): sensor.sensor_schema(
unit_of_measurement=UNIT_SECOND,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex)
await cg.register_component(var, config)
await ble_device_base.register_ble_device(var, config)
sensors = sensor.sub_sensors(config)
await sensors(CONF_CO2, var.set_co2_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_HUMIDITY, var.set_humidity_sensor)
await sensors(CONF_PRESSURE, var.set_pressure_sensor)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_MEASUREMENT_INTERVAL, var.set_measurement_interval_sensor)
await sensors(CONF_MEASUREMENT_AGE, var.set_measurement_age_sensor)
await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength_sensor)
await sensors(CONF_RADON, var.set_radon_sensor)
await sensors(CONF_RADIATION_RATE, var.set_radiation_rate_sensor)
await sensors(CONF_RADIATION_TOTAL, var.set_radiation_total_sensor)
await sensors(CONF_RADIATION_DURATION, var.set_radiation_duration_sensor)
+1 -1
View File
@@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None:
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.2.0")
add_idf_component(name="esphome/micro-flac", ref="0.3.0")
_emit_memory_pair(
data.flac.buffer_memory,
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
@@ -3,6 +3,7 @@ from pathlib import Path
import esphome.codegen as cg
from esphome.components import audio, media_source, psram
from esphome.components.const import CONF_VERIFY_SSL
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
import esphome.final_validate as fv
@@ -16,7 +17,6 @@ AUTO_LOAD = ["audio"]
CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer"
CONF_CA_CERTIFICATE_PATH = "ca_certificate_path"
CONF_HTTP_REQUEST = "http_request"
CONF_VERIFY_SSL = "verify_ssl"
audio_http_ns = cg.esphome_ns.namespace("audio_http")
AudioHTTPMediaSource = audio_http_ns.class_(
+1 -4
View File
@@ -146,10 +146,7 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
}
uint8_t checksum = 0;
// Calculate checksum and compare with signal value.
for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++)
checksum += remote_state[i];
const uint8_t checksum = sum_bytes(remote_state, BALLU_STATE_LENGTH - 1);
if (checksum != remote_state[BALLU_STATE_LENGTH - 1]) {
ESP_LOGVV(TAG, "Checksum fail");
+75
View File
@@ -0,0 +1,75 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import (
CONF_GAIN,
CONF_GLASS_ATTENUATION_FACTOR,
CONF_ID,
CONF_INTEGRATION_TIME,
)
from esphome.types import ConfigType
CODEOWNERS = ["@latonita"]
DEPENDENCIES = ["i2c"]
DOMAIN = "bh1745"
MULTI_CONF = True
CONF_BH1745_ID = "bh1745_id"
bh1745_ns = cg.esphome_ns.namespace("bh1745")
BH1745Component = bh1745_ns.class_(
"BH1745Component", cg.PollingComponent, i2c.I2CDevice
)
AdcGain = bh1745_ns.enum("AdcGain", is_class=True)
ADC_GAINS = {
"1X": AdcGain.ADC_GAIN_1X,
"2X": AdcGain.ADC_GAIN_2X,
"16X": AdcGain.ADC_GAIN_16X,
}
MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True)
MEASUREMENT_TIMES = {
160: MeasurementTime.MEASUREMENT_TIME_160MS,
320: MeasurementTime.MEASUREMENT_TIME_320MS,
640: MeasurementTime.MEASUREMENT_TIME_640MS,
1280: MeasurementTime.MEASUREMENT_TIME_1280MS,
2560: MeasurementTime.MEASUREMENT_TIME_2560MS,
5120: MeasurementTime.MEASUREMENT_TIME_5120MS,
}
def validate_measurement_time(value: Any) -> Any:
value = cv.positive_time_period_milliseconds(value).total_milliseconds
return cv.enum(MEASUREMENT_TIMES, int=True)(value)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BH1745Component),
cv.Optional(CONF_GAIN, default="1X"): cv.enum(ADC_GAINS, upper=True),
cv.Optional(
CONF_INTEGRATION_TIME, default="160ms"
): validate_measurement_time,
cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.float_range(
min=1.0
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x38))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_adc_gain(config[CONF_GAIN]))
cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME]))
cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR]))
+182
View File
@@ -0,0 +1,182 @@
#include "bh1745.h"
#include <cinttypes>
#include <cmath>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::bh1745 {
ESPHOME_LOG_TAG(TAG, "bh1745");
static constexpr uint8_t REG_SYSTEM_CONTROL = 0x40;
static constexpr uint8_t REG_MODE_CONTROL1 = 0x41;
static constexpr uint8_t REG_MODE_CONTROL2 = 0x42;
static constexpr uint8_t REG_MODE_CONTROL3 = 0x44;
static constexpr uint8_t REG_RED_DATA_LSB = 0x50;
static constexpr uint8_t REG_INTERRUPT = 0x60;
static constexpr uint8_t REG_TH_LSB = 0x62;
static constexpr uint8_t REG_MANUFACTURER_ID = 0x92;
static constexpr uint8_t MANUFACTURER_ID = 0xE0;
static constexpr uint8_t PART_ID = 0b001011;
static constexpr uint8_t SYSTEM_CONTROL_PART_ID_MASK = 0x3F;
static constexpr uint8_t SYSTEM_CONTROL_SW_RESET = 0x80;
static constexpr uint8_t MODE_CONTROL2_RGBC_EN = 0x10;
static constexpr uint8_t MODE_CONTROL2_VALID = 0x80;
static constexpr uint8_t MODE_CONTROL3_VALUE = 0x02; // the datasheet says always write 0x02
static constexpr uint8_t INTERRUPT_ENABLE = 0x01;
static constexpr uint8_t BH1745_CHANNELS = 4;
static constexpr uint32_t RESET_DELAY_MS = 100;
static constexpr uint32_t BASE_MEAS_TIME_MS = 160;
static uint32_t get_measurement_time_ms(MeasurementTime time) {
return BASE_MEAS_TIME_MS << static_cast<uint8_t>(time);
}
static uint8_t get_adc_gain(AdcGain gain) {
return gain == AdcGain::ADC_GAIN_16X ? 16 : 1 + static_cast<uint8_t>(gain);
}
static float calculate_lux(uint16_t red, uint16_t green, bool low_clear_ratio, float scale) {
if (green == 0)
return 0.0f;
float lx = low_clear_ratio ? 0.202f * red + 0.766f * green : 0.159f * red + 0.646f * green;
return lx * scale;
}
static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low_clear_ratio) {
if (green == 0)
return 0.0f;
float all = static_cast<float>(static_cast<uint32_t>(red) + green + blue);
float r_ratio = red / all;
float b_ratio = blue / all;
float ct;
if (low_clear_ratio) {
float b_eff = std::fmin(b_ratio * 3.13f, 1.0f);
ct = ((1.0f - b_eff) * 12746.0f * std::exp(-2.911f * r_ratio)) + (b_eff * 1637.0f * std::exp(4.865f * b_ratio));
} else {
float b_eff = std::fmin(b_ratio * 10.67f, 1.0f);
ct = ((1.0f - b_eff) * 16234.0f * std::exp(-2.781f * r_ratio)) + (b_eff * 1882.0f * std::exp(4.448f * b_ratio));
}
return clamp_at_most(ct, 10000.0f);
}
void BH1745Component::setup() {
uint8_t manufacturer_id;
if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (manufacturer_id != MANUFACTURER_ID) {
this->mark_failed(LOG_STR("Wrong manufacturer ID"));
return;
}
uint8_t system_control;
if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) ||
(system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) {
this->mark_failed(LOG_STR("Wrong part ID"));
return;
}
if (!this->write_byte(REG_SYSTEM_CONTROL, SYSTEM_CONTROL_SW_RESET)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// Light lost behind glass is scaled back up, as the other light sensors do
this->lux_scale_ = 160.0f * this->glass_attenuation_factor_ /
(get_adc_gain(this->adc_gain_) * get_measurement_time_ms(this->measurement_time_));
this->set_timeout(RESET_DELAY_MS, [this]() {
if (this->interrupt_output_) {
// High threshold 0 and low threshold 0xFFFF (low byte first) make the interrupt condition always
// true, so the INT pin simply follows the enable bit, which drives the breakout's LEDs.
static constexpr uint8_t THRESHOLDS[4] = {0x00, 0x00, 0xFF, 0xFF};
if (!this->write_bytes(REG_TH_LSB, THRESHOLDS, sizeof(THRESHOLDS))) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
}
// RGBC_EN makes the chip measure continuously; update() reads the latest result.
if (!this->write_byte(REG_MODE_CONTROL1, static_cast<uint8_t>(this->measurement_time_)) ||
!this->write_byte(REG_MODE_CONTROL2, static_cast<uint8_t>(this->adc_gain_) | MODE_CONTROL2_RGBC_EN) ||
!this->write_byte(REG_MODE_CONTROL3, MODE_CONTROL3_VALUE)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->busy_ = false;
});
}
void BH1745Component::dump_config() {
ESP_LOGCONFIG(TAG,
"BH1745:\n"
" Gain: %ux\n"
" Integration time: %" PRIu32 " ms\n"
" Glass attenuation factor: %.2f",
get_adc_gain(this->adc_gain_), get_measurement_time_ms(this->measurement_time_),
this->glass_attenuation_factor_);
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Red Counts", this->red_counts_sensor_);
LOG_SENSOR(" ", "Green Counts", this->green_counts_sensor_);
LOG_SENSOR(" ", "Blue Counts", this->blue_counts_sensor_);
LOG_SENSOR(" ", "Clear Counts", this->clear_counts_sensor_);
LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_);
LOG_SENSOR(" ", "Color Temperature", this->color_temperature_sensor_);
}
void BH1745Component::update() {
if (this->busy_)
return;
// Reading MODE_CONTROL2 clears VALID, so read it only once per poll.
uint8_t mode_control2;
if (!this->read_byte(REG_MODE_CONTROL2, &mode_control2)) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if ((mode_control2 & MODE_CONTROL2_VALID) == 0)
return;
uint8_t buffer[BH1745_CHANNELS * 2];
if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->status_clear_warning();
// Each channel is sent low byte first.
const uint16_t red = encode_uint16(buffer[1], buffer[0]);
const uint16_t green = encode_uint16(buffer[3], buffer[2]);
const uint16_t blue = encode_uint16(buffer[5], buffer[4]);
const uint16_t clear = encode_uint16(buffer[7], buffer[6]);
ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear);
if (this->red_counts_sensor_ != nullptr)
this->red_counts_sensor_->publish_state(red);
if (this->green_counts_sensor_ != nullptr)
this->green_counts_sensor_->publish_state(green);
if (this->blue_counts_sensor_ != nullptr)
this->blue_counts_sensor_->publish_state(blue);
if (this->clear_counts_sensor_ != nullptr)
this->clear_counts_sensor_->publish_state(clear);
// The datasheet picks its coefficients by whether clear / green < 0.160 (= 4 / 25).
const bool low_clear_ratio = static_cast<uint32_t>(clear) * 25 < static_cast<uint32_t>(green) * 4;
if (this->illuminance_sensor_ != nullptr)
this->illuminance_sensor_->publish_state(calculate_lux(red, green, low_clear_ratio, this->lux_scale_));
if (this->color_temperature_sensor_ != nullptr)
this->color_temperature_sensor_->publish_state(calculate_cct(red, green, blue, low_clear_ratio));
}
void BH1745Component::set_interrupt_state(bool on) {
if (this->is_failed())
return;
if (!this->write_byte(REG_INTERRUPT, on ? INTERRUPT_ENABLE : 0x00)) {
this->status_set_warning();
}
}
} // namespace esphome::bh1745
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::bh1745 {
enum class MeasurementTime : uint8_t {
MEASUREMENT_TIME_160MS = 0b000,
MEASUREMENT_TIME_320MS = 0b001,
MEASUREMENT_TIME_640MS = 0b010,
MEASUREMENT_TIME_1280MS = 0b011,
MEASUREMENT_TIME_2560MS = 0b100,
MEASUREMENT_TIME_5120MS = 0b101,
};
enum class AdcGain : uint8_t {
ADC_GAIN_1X = 0,
ADC_GAIN_2X = 1,
ADC_GAIN_16X = 2,
};
class BH1745Component : public PollingComponent, public i2c::I2CDevice {
SUB_SENSOR(red_counts)
SUB_SENSOR(green_counts)
SUB_SENSOR(blue_counts)
SUB_SENSOR(clear_counts)
SUB_SENSOR(illuminance)
SUB_SENSOR(color_temperature)
public:
void setup() override;
void dump_config() override;
void update() override;
void set_measurement_time(MeasurementTime measurement_time) { this->measurement_time_ = measurement_time; }
void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; }
void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; }
/// Called when an output platform uses the INT pin, so setup() pins the interrupt thresholds.
void enable_interrupt_output() { this->interrupt_output_ = true; }
void set_interrupt_state(bool on);
protected:
float glass_attenuation_factor_{1.0f};
float lux_scale_{1.0f}; // 160 / (gain * integration time * glass attenuation), set in setup()
MeasurementTime measurement_time_{MeasurementTime::MEASUREMENT_TIME_160MS};
AdcGain adc_gain_{AdcGain::ADC_GAIN_1X};
bool busy_{true}; // cleared once the chip is configured after the reset
bool interrupt_output_{false};
};
} // namespace esphome::bh1745
@@ -0,0 +1,28 @@
import esphome.codegen as cg
from esphome.components import output
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_BH1745_ID, BH1745Component, bh1745_ns
DEPENDENCIES = ["bh1745"]
BH1745InterruptPinOutput = bh1745_ns.class_(
"BH1745InterruptPinOutput", output.BinaryOutput
)
CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BH1745InterruptPinOutput),
cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component),
}
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await output.register_output(var, config)
hub = await cg.get_variable(config[CONF_BH1745_ID])
await cg.register_parented(var, hub)
cg.add(hub.enable_interrupt_output())
@@ -0,0 +1,16 @@
#pragma once
#include "esphome/components/output/binary_output.h"
#include "esphome/core/helpers.h"
#include "../bh1745.h"
namespace esphome::bh1745 {
/// Drives the BH1745 INT pin, which switches the LEDs on breakout boards such as the Pimoroni one.
class BH1745InterruptPinOutput : public output::BinaryOutput, public Parented<BH1745Component> {
protected:
void write_state(bool state) override { this->parent_->set_interrupt_state(state); }
};
} // namespace esphome::bh1745
+66
View File
@@ -0,0 +1,66 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import (
CONF_BLUE_CHANNEL,
CONF_CLEAR_CHANNEL,
CONF_GREEN_CHANNEL,
CONF_RED_CHANNEL,
UNIT_COUNTS,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_COLOR_TEMPERATURE,
CONF_ILLUMINANCE,
DEVICE_CLASS_ILLUMINANCE,
ICON_LIGHTBULB,
ICON_THERMOMETER,
STATE_CLASS_MEASUREMENT,
UNIT_KELVIN,
UNIT_LUX,
)
from esphome.types import ConfigType
from . import CONF_BH1745_ID, BH1745Component
DEPENDENCIES = ["bh1745"]
color_channel_schema = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_LIGHTBULB,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component),
cv.Optional(CONF_RED_CHANNEL): color_channel_schema,
cv.Optional(CONF_GREEN_CHANNEL): color_channel_schema,
cv.Optional(CONF_BLUE_CHANNEL): color_channel_schema,
cv.Optional(CONF_CLEAR_CHANNEL): color_channel_schema,
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_LUX,
icon=ICON_LIGHTBULB,
accuracy_decimals=1,
device_class=DEVICE_CLASS_ILLUMINANCE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_KELVIN,
icon=ICON_THERMOMETER,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_BH1745_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_RED_CHANNEL, hub.set_red_counts_sensor)
await sensors(CONF_GREEN_CHANNEL, hub.set_green_counts_sensor)
await sensors(CONF_BLUE_CHANNEL, hub.set_blue_counts_sensor)
await sensors(CONF_CLEAR_CHANNEL, hub.set_clear_counts_sensor)
await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor)
await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor)
+17 -9
View File
@@ -74,6 +74,10 @@ from esphome.types import ConfigType
from esphome.util import Registry
CODEOWNERS = ["@esphome/core"]
DEFAULT_INVALID_COOLDOWN_MS = (
1000 # Keep in sync with invalid_cooldown_ in automation.h
)
DEVICE_CLASSES = [
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_BATTERY_CHARGING,
@@ -131,10 +135,9 @@ ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.templa
DoubleClickTrigger = binary_sensor_ns.class_(
"DoubleClickTrigger", automation.Trigger.template()
)
MultiClickTriggerBase = binary_sensor_ns.class_(
"MultiClickTriggerBase", automation.Trigger.template(), cg.Component
MultiClickTrigger = binary_sensor_ns.class_(
"MultiClickTrigger", automation.Trigger.template(), cg.Component
)
MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase)
MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent")
@@ -492,7 +495,8 @@ _BINARY_SENSOR_SCHEMA = (
cv.Length(min=1, max=255),
),
cv.Optional(
CONF_INVALID_COOLDOWN, default="1s"
CONF_INVALID_COOLDOWN,
default=f"{DEFAULT_INVALID_COOLDOWN_MS}ms",
): cv.positive_time_period_milliseconds,
}
),
@@ -586,11 +590,14 @@ async def _build_binary_sensor_automations(var, config):
)
for tim in conf[CONF_TIMING]
]
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], cg.TemplateArguments(len(timings)), var, timings
table = cg.shared_progmem_array(
"multi_click_timing", MultiClickTriggerEvent, cg.ArrayInitializer(*timings)
)
if CONF_INVALID_COOLDOWN in conf:
cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN]))
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, table, len(timings))
if (
cooldown := conf[CONF_INVALID_COOLDOWN]
).total_milliseconds != DEFAULT_INVALID_COOLDOWN_MS:
cg.add(trigger.set_invalid_cooldown(cooldown))
await cg.register_component(trigger, conf)
await automation.build_automation(trigger, [], conf)
@@ -607,7 +614,8 @@ async def setup_binary_sensor_core_(var, config):
if filters_config := config.get(CONF_FILTERS):
cg.add_define("USE_BINARY_SENSOR_FILTER")
filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config)
cg.add(var.add_filters(filters))
for filter_ in filters:
cg.add(var.add_filter(filter_))
CORE.add_job(_build_binary_sensor_automations, var, config)
+13 -14
View File
@@ -18,7 +18,7 @@ constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1;
constexpr uint32_t MULTICLICK_IS_VALID_ID = 2;
constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3;
void MultiClickTriggerBase::on_state_(bool state) {
void MultiClickTrigger::on_state_(bool state) {
// Handle duplicate events
if (state == this->last_state_) {
return;
@@ -30,7 +30,7 @@ void MultiClickTriggerBase::on_state_(bool state) {
return;
}
if (!this->at_index_.has_value()) {
if (this->at_index_ == 0) {
// Start matching
MultiClickTriggerEvent evt = this->timing_[0];
if (evt.state == state) {
@@ -55,8 +55,7 @@ void MultiClickTriggerBase::on_state_(bool state) {
return;
}
// at_index_ has a value here (the !has_value() branch above returns).
size_t at_index = *this->at_index_;
uint8_t at_index = this->at_index_;
if (at_index == this->timing_count_) {
this->trigger_();
return;
@@ -65,15 +64,15 @@ void MultiClickTriggerBase::on_state_(bool state) {
MultiClickTriggerEvent evt = this->timing_[at_index];
if (evt.max_length != 4294967294UL) {
ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT
ESP_LOGV(TAG, "A i=%u min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length);
this->schedule_is_valid_(evt.min_length);
this->schedule_is_not_valid_(evt.max_length);
} else if (at_index + 1 != this->timing_count_) {
ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT
ESP_LOGV(TAG, "B i=%u min=%" PRIu32, at_index, evt.min_length);
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
this->schedule_is_valid_(evt.min_length);
} else {
ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT
ESP_LOGV(TAG, "C i=%u min=%" PRIu32, at_index, evt.min_length);
this->is_valid_ = false;
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); });
@@ -81,19 +80,19 @@ void MultiClickTriggerBase::on_state_(bool state) {
this->at_index_ = at_index + 1;
}
void MultiClickTriggerBase::schedule_cooldown_() {
void MultiClickTrigger::schedule_cooldown_() {
ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_);
this->is_in_cooldown_ = true;
this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() {
ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again.");
this->is_in_cooldown_ = false;
});
this->at_index_.reset();
this->at_index_ = 0;
this->cancel_timeout(MULTICLICK_TRIGGER_ID);
this->cancel_timeout(MULTICLICK_IS_VALID_ID);
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
}
void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) {
void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) {
if (min_length == 0) {
this->is_valid_ = true;
return;
@@ -105,7 +104,7 @@ void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) {
this->is_valid_ = true;
});
}
void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) {
void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) {
this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() {
ESP_LOGV(TAG, "Multi Click: You waited too long to %s.",
this->parent_->state ? LOG_STR_LITERAL("RELEASE") : LOG_STR_LITERAL("PRESS"));
@@ -113,14 +112,14 @@ void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) {
this->schedule_cooldown_();
});
}
void MultiClickTriggerBase::cancel() {
void MultiClickTrigger::cancel() {
ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled.");
this->is_valid_ = false;
this->schedule_cooldown_();
}
void MultiClickTriggerBase::trigger_() {
void MultiClickTrigger::trigger_() {
ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!");
this->at_index_.reset();
this->at_index_ = 0;
this->cancel_timeout(MULTICLICK_TRIGGER_ID);
this->cancel_timeout(MULTICLICK_IS_VALID_ID);
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
+16 -26
View File
@@ -1,7 +1,7 @@
#pragma once
#include <array>
#include <cinttypes>
#include <type_traits>
#include <utility>
#include "esphome/core/component.h"
@@ -13,10 +13,14 @@
namespace esphome::binary_sensor {
struct MultiClickTriggerEvent {
bool state;
uint32_t state; // Not bool: all-word fields allow aligned loads from PROGMEM on ESP8266
uint32_t min_length;
uint32_t max_length;
};
// Read straight from PROGMEM; a byte-sized field would fault on ESP8266.
static_assert(std::is_same_v<decltype(MultiClickTriggerEvent::state), uint32_t> &&
sizeof(MultiClickTriggerEvent) == 3 * sizeof(uint32_t),
"MultiClickTriggerEvent fields must all be uint32_t");
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length);
@@ -70,10 +74,11 @@ class DoubleClickTrigger final : public Trigger<> {
uint32_t max_length_; /// Maximum length of click. 0 means no maximum.
};
/// Non-template base for MultiClickTrigger (keeps large method bodies out of the header).
class MultiClickTriggerBase : public Trigger<>, public Component {
class MultiClickTrigger final : public Trigger<>, public Component {
public:
explicit MultiClickTriggerBase(BinarySensor *parent) : parent_(parent) {}
/// `timing` is a codegen PROGMEM table shared by triggers with the same timing.
MultiClickTrigger(BinarySensor *parent, const MultiClickTriggerEvent *timing, uint8_t timing_count)
: parent_(parent), timing_(timing), timing_count_(timing_count) {}
void setup() override {
this->last_state_ = this->parent_->get_state_default(false);
@@ -85,8 +90,8 @@ class MultiClickTriggerBase : public Trigger<>, public Component {
void set_invalid_cooldown(uint32_t invalid_cooldown) { this->invalid_cooldown_ = invalid_cooldown; }
void cancel();
MultiClickTriggerBase(const MultiClickTriggerBase &) = delete;
MultiClickTriggerBase &operator=(const MultiClickTriggerBase &) = delete;
MultiClickTrigger(const MultiClickTrigger &) = delete;
MultiClickTrigger &operator=(const MultiClickTrigger &) = delete;
protected:
void on_state_(bool state);
@@ -96,28 +101,13 @@ class MultiClickTriggerBase : public Trigger<>, public Component {
void trigger_();
BinarySensor *parent_;
const MultiClickTriggerEvent *timing_{nullptr};
uint32_t invalid_cooldown_{1000};
optional<size_t> at_index_{};
uint8_t timing_count_{0};
const MultiClickTriggerEvent *timing_;
uint32_t invalid_cooldown_{1000}; // Must match DEFAULT_INVALID_COOLDOWN_MS in __init__.py
uint8_t timing_count_;
uint8_t at_index_{0}; // 0: not matching, otherwise the index of the next timing event
bool last_state_{false};
bool is_in_cooldown_{false};
bool is_valid_{false};
};
/// Template wrapper that provides inline std::array storage for timing events.
/// N is set by code generation to match the exact number of timing events configured in YAML.
template<size_t N> class MultiClickTrigger final : public MultiClickTriggerBase {
public:
MultiClickTrigger(BinarySensor *parent, std::initializer_list<MultiClickTriggerEvent> timing)
: MultiClickTriggerBase(parent) {
init_array_from(this->timing_storage_, timing);
this->timing_ = this->timing_storage_.data();
this->timing_count_ = N;
}
protected:
std::array<MultiClickTriggerEvent, N> timing_storage_{};
};
} // namespace esphome::binary_sensor
+14 -6
View File
@@ -51,12 +51,8 @@ void BL0939::loop() {
}
bool BL0939::validate_checksum(const DataPacket *data) {
uint8_t checksum = BL0939_READ_COMMAND;
// Whole package but checksum
for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) {
checksum += data->raw[i];
}
checksum ^= 0xFF;
const uint8_t checksum = sum_bytes(data->raw, sizeof(data->raw) - 1, BL0939_READ_COMMAND) ^ 0xFF;
if (checksum != data->checksum) {
ESP_LOGW(TAG, "BL0939 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum);
}
@@ -120,13 +116,24 @@ void BL0939::received_package_(const DataPacket *data) const {
energy_sensor_sum_->publish_state(total_energy_consumption);
}
if (this->internal_temperature_sensor_ != nullptr) {
const uint16_t raw_temperature = ((uint16_t(data->tps1.h) << 8) | data->tps1.l) & 0x3FF;
// Datasheet temperature conversion: Tx = (170 / 448) * (TB / 2 - 32) - 45.
const float temperature = (170.0f / 448.0f) * (raw_temperature / 2.0f - 32.0f) - 45.0f;
this->internal_temperature_sensor_->publish_state(temperature);
}
ESP_LOGV(TAG,
"BL0939: U %fV, I1 %fA, I2 %fA, P1 %fW, P2 %fW, CntA %" PRId32 ", CntB %" PRId32 ", ∫P1 %fkWh, ∫P2 %fkWh",
v_rms, ia_rms, ib_rms, a_watt, b_watt, cfa_cnt, cfb_cnt, a_energy_consumption, b_energy_consumption);
}
void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexity)
ESP_LOGCONFIG(TAG, "BL0939:");
ESP_LOGCONFIG(TAG,
"BL0939:\n"
" Mode: %s",
this->mode_ == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER ? LOG_STR_LITERAL("current_transformer")
: LOG_STR_LITERAL("direct"));
LOG_SENSOR("", "Voltage", this->voltage_sensor_);
LOG_SENSOR("", "Current 1", this->current_sensor_1_);
LOG_SENSOR("", "Current 2", this->current_sensor_2_);
@@ -135,6 +142,7 @@ void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexit
LOG_SENSOR("", "Energy 1", this->energy_sensor_1_);
LOG_SENSOR("", "Energy 2", this->energy_sensor_2_);
LOG_SENSOR("", "Energy sum", this->energy_sensor_sum_);
LOG_SENSOR("", "Internal Temperature", this->internal_temperature_sensor_);
}
uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; }
+35 -4
View File
@@ -8,13 +8,32 @@ namespace esphome::bl0939 {
// https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf
// (unfortunately chinese, but the formulas can be easily understood)
// The datasheet has one set of formulas for direct mode (current shunt) and one for current transformer mode.
// Direct mode.
// Sonoff Dual R3 V2 has the exact same resistor values for the current shunts (RL=1miliOhm)
// and for the voltage divider (R1=0.51kOhm, R2=5*390kOhm)
// as in the manufacturer's reference circuit, so the same formulas were used here (Vref=1.218V)
static const float BL0939_IREF = 324004 * 1 / 1.218;
static const float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51));
static const float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51));
static const float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_IREF = 324004 * 1 / 1.218;
static constexpr float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51));
static constexpr float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51));
// Current transformer mode.
// Values from the Seeed Studio XIAO 2-Channel Wi-Fi AC Energy Meter V1.0 schematic (Vref=1.218V).
// Current: 100A:50mA CT (2000:1) into a 0.6Ohm burden (2 * 0.3Ohm), so 0.3mV per primary amp.
// That is the same as a 0.3mOhm shunt in the direct mode formula.
// Voltage: ZMPT107-1 2mA:2mA voltage transformer with R2=5*22kOhm on the primary and R1=24.9Ohm on
// the secondary. The transformer isolates R1 from R2, so the divider is R1/R2 instead of R1/(R1+R2).
static constexpr float BL0939_IREF_CT = 324004 * 0.3 / 1.218;
static constexpr float BL0939_UREF_CT = 79931 * 24.9 * 1000 / (1.218 * 5 * 22000);
static constexpr float BL0939_PREF_CT = 4046 * 0.3 * 24.9 * 1000 / (1.218 * 1.218 * 5 * 22000);
static constexpr float BL0939_EREF_CT = 3.6e6 * 4046 * 0.3 * 24.9 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * 5 * 22000);
enum class BL0939Mode : uint8_t {
BL0939_MODE_DIRECT = 0,
BL0939_MODE_CURRENT_TRANSFORMER = 1,
};
struct ube24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align)
uint8_t l;
@@ -66,6 +85,16 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
void set_energy_sensor_1(sensor::Sensor *energy_sensor_1) { energy_sensor_1_ = energy_sensor_1; }
void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; }
void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; }
void set_internal_temperature_sensor(sensor::Sensor *sensor) { this->internal_temperature_sensor_ = sensor; }
// Inline so a constant mode from codegen folds down to plain stores.
void set_mode(BL0939Mode mode) {
this->mode_ = mode;
const bool current_transformer = mode == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER;
this->power_reference_ = current_transformer ? BL0939_PREF_CT : BL0939_PREF;
this->voltage_reference_ = current_transformer ? BL0939_UREF_CT : BL0939_UREF;
this->current_reference_ = current_transformer ? BL0939_IREF_CT : BL0939_IREF;
this->energy_reference_ = current_transformer ? BL0939_EREF_CT : BL0939_EREF;
}
void loop() override;
@@ -84,6 +113,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
sensor::Sensor *energy_sensor_1_{nullptr};
sensor::Sensor *energy_sensor_2_{nullptr};
sensor::Sensor *energy_sensor_sum_{nullptr};
sensor::Sensor *internal_temperature_sensor_{nullptr};
// Divide by this to turn into Watt
float power_reference_ = BL0939_PREF;
@@ -93,6 +123,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice {
float current_reference_ = BL0939_IREF;
// Divide by this to turn into kWh
float energy_reference_ = BL0939_EREF;
BL0939Mode mode_{BL0939Mode::BL0939_MODE_DIRECT};
static uint32_t to_uint32_t(ube24_t input);
+27
View File
@@ -3,14 +3,19 @@ from esphome.components import sensor, uart
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_INTERNAL_TEMPERATURE,
CONF_MODE,
CONF_VOLTAGE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_KILOWATT_HOURS,
UNIT_VOLT,
UNIT_WATT,
@@ -29,11 +34,22 @@ CONF_ENERGY_TOTAL = "energy_total"
bl0939_ns = cg.esphome_ns.namespace("bl0939")
BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice)
BL0939Mode = bl0939_ns.enum("BL0939Mode", is_class=True)
MODE_OPTIONS = {
"direct": BL0939Mode.BL0939_MODE_DIRECT,
"current_transformer": BL0939Mode.BL0939_MODE_CURRENT_TRANSFORMER,
}
# Must match the mode_ member initializer in bl0939.h; set_mode() is skipped
# when the config already equals it.
DEFAULT_MODE = "direct"
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BL0939),
cv.Optional(CONF_MODE, default=DEFAULT_MODE): cv.enum(
MODE_OPTIONS, lower=True
),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
@@ -82,6 +98,13 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_INTERNAL_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
@@ -94,6 +117,9 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
if (mode := config[CONF_MODE]) != DEFAULT_MODE:
cg.add(var.set_mode(mode))
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_CURRENT_1, var.set_current_sensor_1)
@@ -103,3 +129,4 @@ async def to_code(config: ConfigType) -> None:
await sensors(CONF_ENERGY_1, var.set_energy_sensor_1)
await sensors(CONF_ENERGY_2, var.set_energy_sensor_2)
await sensors(CONF_ENERGY_TOTAL, var.set_energy_sensor_sum)
await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor)
+2 -6
View File
@@ -44,13 +44,9 @@ void BL0940::loop() {
}
bool BL0940::validate_checksum_(DataPacket *data) {
uint8_t checksum = this->read_command_;
// Whole package but checksum
uint8_t *raw = (uint8_t *) data;
for (uint32_t i = 0; i < sizeof(*data) - 1; i++) {
checksum += raw[i];
}
checksum ^= 0xFF;
const uint8_t checksum =
sum_bytes(reinterpret_cast<const uint8_t *>(data), sizeof(*data) - 1, this->read_command_) ^ 0xFF;
if (checksum != data->checksum) {
ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum);
}
+3 -6
View File
@@ -70,13 +70,10 @@ void BL0942::loop() {
}
bool BL0942::validate_checksum_(DataPacket *data) {
uint8_t checksum = BL0942_READ_COMMAND | this->address_;
// Whole package but checksum
uint8_t *raw = (uint8_t *) data;
for (uint32_t i = 0; i < sizeof(*data) - 1; i++) {
checksum += raw[i];
}
checksum ^= 0xFF;
const uint8_t checksum =
sum_bytes(reinterpret_cast<const uint8_t *>(data), sizeof(*data) - 1, BL0942_READ_COMMAND | this->address_) ^
0xFF;
if (checksum != data->checksum) {
ESP_LOGW(TAG, "BL0942 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum);
}
+2 -1
View File
@@ -3,6 +3,7 @@ from esphome.components.logger import request_log_listener
from esphome.components.uart import (
UARTComponent,
debug_to_code,
mark_unclocked,
maybe_empty_debug,
uart_ns,
)
@@ -42,7 +43,7 @@ def validate_rx_buffer(config: ConfigType) -> ConfigType:
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLENUS),
cv.GenerateID(): cv.All(cv.declare_id(BLENUS), mark_unclocked),
cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of(
*[CONF_LOGS, CONF_UART], lower=True
),
+173 -22
View File
@@ -5,12 +5,16 @@
namespace esphome::bmi270 {
#ifdef USE_BMI270_BMM150
using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device definitions
#endif
ESPHOME_LOG_TAG(TAG, "bmi270");
#if defined(USE_ARDUINO) && !defined(USE_ESP32)
static const size_t MAX_I2C_BUFFER_SIZE = 32;
static constexpr size_t MAX_I2C_BUFFER_SIZE = 32;
#else
static const size_t MAX_I2C_BUFFER_SIZE = 256;
static constexpr size_t MAX_I2C_BUFFER_SIZE = 256;
#endif
// Configuration blob upload
@@ -19,12 +23,12 @@ static const size_t MAX_I2C_BUFFER_SIZE = 256;
bool BMI270Component::load_config_file_() {
// 1. Disable advanced power-save so the config port is accessible
if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00))
if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_ADV_POWER_SAVE_OFF))
return false;
delay(1);
// 2. Prepare config load: write 0x00 to INIT_CTRL to start
if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00))
if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_START))
return false;
// 3. Burst-write the config in pages
@@ -50,7 +54,7 @@ bool BMI270Component::load_config_file_() {
}
// 4. Signal end of config load
if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01))
if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_DONE))
return false;
delay(20); // spec: wait ≥20 ms for init to complete
@@ -58,7 +62,7 @@ bool BMI270Component::load_config_file_() {
uint8_t status = 0;
if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status))
return false;
if ((status & 0x0F) != 0x01) {
if ((status & BMI270_INTERNAL_STATUS_MASK) != BMI270_INTERNAL_STATUS_INIT_OK) {
ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status);
return false;
}
@@ -84,7 +88,7 @@ void BMI270Component::setup() {
ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id);
// 2. Soft-reset via CMD register (0x7E = 0xB6)
if (!this->write_byte(0x7E, 0xB6)) {
if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) {
this->mark_failed();
return;
}
@@ -100,7 +104,8 @@ void BMI270Component::setup() {
// 5. Configure accelerometer
// ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1)
uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7);
uint8_t acc_conf =
(uint8_t) (accel_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | BMI270_ACC_CONF_PERF_MODE;
if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) {
this->mark_failed();
return;
@@ -112,7 +117,8 @@ void BMI270Component::setup() {
// 6. Configure gyroscope
// GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1)
uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7);
uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) |
BMI270_GYR_CONF_NOISE_PERF | BMI270_GYR_CONF_FILTER_PERF;
if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) {
this->mark_failed();
return;
@@ -124,7 +130,7 @@ void BMI270Component::setup() {
// 7. Enable accelerometer, gyroscope, and temperature sensor
// PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1]
if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) {
if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU)) {
this->mark_failed();
return;
}
@@ -132,14 +138,133 @@ void BMI270Component::setup() {
// 8. Re-enable advanced power save (optional; keeps current low between reads)
// Disabled here for simplicity – leave in performance mode
if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup
if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_FIFO_SELF_WAKEUP)) { // bit1 = fifo_self_wakeup
this->mark_failed();
return;
}
// 9. Optionally bring up whatever is wired to the aux interface.
// This is entirely additive: it is off by default, and failure only disables
// magnetometer reporting rather than the whole component.
#ifdef USE_BMI270_BMM150
if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) {
this->magnetometer_ready_ = this->setup_magnetometer_();
if (!this->magnetometer_ready_) {
ESP_LOGW(TAG, "BMM150 magnetometer setup failed; continuing without it");
}
}
#endif
ESP_LOGCONFIG(TAG, "BMI270 initialised successfully");
}
#ifdef USE_BMI270_BMM150
bool BMI270Component::wait_for_aux_idle_(uint32_t timeout_ms) {
// The STATUS aux-busy bit is set while an aux transaction is in flight. Aux transaction
// duration depends on the attached device's own timing, so poll with a bounded
// timeout instead of guessing a fixed delay.
uint32_t start = millis();
while (true) {
uint8_t status = 0;
if (!this->read_byte(BMI270_REG_STATUS, &status))
return false;
if ((status & BMI270_STATUS_AUX_BUSY) == 0)
return true;
if (millis() - start >= timeout_ms)
return false;
delay(1);
}
}
bool BMI270Component::write_aux_register_(uint8_t reg, uint8_t value) {
if (!this->write_byte(BMI270_REG_AUX_WR_DATA, value))
return false;
if (!this->write_byte(BMI270_REG_AUX_WR_ADDR, reg))
return false;
return this->wait_for_aux_idle_(20);
}
bool BMI270Component::read_aux_block_(uint8_t reg, uint8_t *buf) {
if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, reg))
return false;
if (!this->wait_for_aux_idle_(20))
return false;
return this->read_bytes(BMI270_REG_AUX_X_LSB, buf, BMI270_AUX_DATA_LEN);
}
bool BMI270Component::read_magnetometer_trim_() {
uint8_t regs[BMM150_TRIM_BLOCKS * BMM150_TRIM_BLOCK_LEN];
for (uint8_t i = 0; i < BMM150_TRIM_BLOCKS; i++) {
if (!this->read_aux_block_(BMM150_REG_TRIM_START + i * BMM150_TRIM_BLOCK_LEN, regs + i * BMM150_TRIM_BLOCK_LEN))
return false;
}
this->mag_trim_ = bmm150_parse_trim(regs);
return true;
}
bool BMI270Component::setup_magnetometer_() {
// Sequence adapted from the Bosch BMI270 aux-interface protocol: enable the secondary
// I2C master in manual mode, point it at the BMM150's address, power the BMM150 up
// and soft-reset it, verify its chip ID, read its factory trim values, put it in
// normal/30Hz mode, then switch the aux interface to auto-burst mode so BMI270 keeps
// AUX_X/Y/Z/R refreshed from the BMM150's data registers.
ESP_LOGD(TAG, "Setting up BMM150 magnetometer via BMI270 aux interface...");
if (!this->write_byte(BMI270_REG_IF_CONF, BMI270_IF_CONF_AUX_IF_EN))
return false;
if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_MANUAL_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8))
return false;
if (!this->write_byte(BMI270_REG_AUX_DEV_ID, (uint8_t) (this->aux_device_address_ << 1)))
return false;
// Suspend -> sleep, then a soft reset so the BMM150 starts from a known state even if it
// stayed powered across a restart. The chip ID is only readable once the power bit is set.
if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_POWER_ON)) {
ESP_LOGW(TAG, "BMM150 power-on write timed out");
return false;
}
delay(BMM150_START_UP_TIME_MS);
if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_SOFT_RESET)) {
ESP_LOGW(TAG, "BMM150 soft-reset write timed out");
return false;
}
delay(BMM150_SOFT_RESET_TIME_MS);
uint8_t chip_id[BMI270_AUX_DATA_LEN];
if (!this->read_aux_block_(BMM150_REG_CHIP_ID, chip_id)) {
ESP_LOGW(TAG, "BMM150 chip-ID read failed");
return false;
}
if (chip_id[0] != BMM150_CHIP_ID_VALUE) {
ESP_LOGW(TAG, "BMM150 not detected (chip ID 0x%02X, expected 0x%02X)", chip_id[0], BMM150_CHIP_ID_VALUE);
return false;
}
if (!this->read_magnetometer_trim_()) {
ESP_LOGW(TAG, "BMM150 trim data read failed");
return false;
}
if (!this->write_aux_register_(BMM150_REG_OP_MODE, BMM150_CMD_NORMAL_MODE_ODR_30HZ)) {
ESP_LOGW(TAG, "BMM150 mode write timed out");
return false;
}
// Switch to auto-burst mode: BMI270 will keep polling 8 bytes from the BMM150's
// data registers (0x42) into its own AUX_X/Y/Z/R registers automatically.
if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_FCU_WRITE_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8 |
BMI270_AUX_IF_CONF_RD_BURST_8))
return false;
if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, BMM150_REG_DATA_X_LSB))
return false;
if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU | BMI270_PWR_CTRL_AUX_EN))
return false;
ESP_LOGD(TAG, "BMM150 magnetometer ready");
return true;
}
#endif // USE_BMI270_BMM150
void BMI270Component::dump_config() {
ESP_LOGCONFIG(TAG, "BMI270 IMU:");
LOG_I2C_DEVICE(this);
@@ -153,6 +278,12 @@ void BMI270Component::dump_config() {
ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]);
ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]);
#ifdef USE_BMI270_BMM150
if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) {
ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)",
this->magnetometer_ready_ ? LOG_STR_LITERAL("ready") : LOG_STR_LITERAL("NOT DETECTED"));
}
#endif
MotionComponent::dump_config();
}
@@ -163,9 +294,22 @@ bool BMI270Component::update_data(motion::MotionData &data) {
if (this->is_failed())
return false;
// Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb)
uint8_t raw_data[REG_READ_LEN];
if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) {
// Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb)
// When the magnetometer is active, the 8 aux data bytes (0x04–0x0B) sit directly before
// the accel data, so one burst read from AUX_X_LSB fetches both.
#ifdef USE_BMI270_BMM150
const bool read_mag = this->magnetometer_ready_ && !this->magnetometer_callback_.empty();
uint8_t buf[BMI270_AUX_DATA_LEN + REG_READ_LEN];
const uint8_t *mag_raw = buf;
const uint8_t *raw_data = read_mag ? buf + BMI270_AUX_DATA_LEN : buf;
const bool read_ok = read_mag ? this->read_bytes(BMI270_REG_AUX_X_LSB, buf, sizeof(buf))
: this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN);
#else
uint8_t buf[REG_READ_LEN];
const uint8_t *raw_data = buf;
const bool read_ok = this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN);
#endif
if (!read_ok) {
ESP_LOGW(TAG, "Failed to read IMU data");
return false;
}
@@ -195,14 +339,21 @@ bool BMI270Component::update_data(motion::MotionData &data) {
data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale;
data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale;
if (this->temperature_callback_.empty())
return true;
// Temperature: registers 0x22–0x23
// Formula from datasheet: T[°C] = raw / 512 + 23
static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8;
int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]);
float temperature = (raw_t / 512.0f) + 23.0f;
this->temperature_callback_.call(temperature);
if (!this->temperature_callback_.empty()) {
// Temperature: registers 0x22–0x23
// Formula from datasheet: T[°C] = raw / 512 + 23
static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8;
int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]);
float temperature = (raw_t / 512.0f) + 23.0f;
this->temperature_callback_.call(temperature);
}
#ifdef USE_BMI270_BMM150
if (read_mag) {
const BMM150Data mag_data = bmm150_convert(this->mag_trim_, mag_raw);
this->magnetometer_callback_.call(mag_data);
}
#endif
return true;
}
+98 -20
View File
@@ -4,35 +4,81 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/defines.h"
#ifdef USE_BMI270_BMM150
#include "bmm150_aux.h"
#endif
#include <functional>
namespace esphome::bmi270 {
// Register map
static const uint8_t BMI270_REG_CHIP_ID = 0x00;
static const uint8_t BMI270_REG_ERR_REG = 0x02;
static const uint8_t BMI270_REG_STATUS = 0x03;
static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB
static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB
static const uint8_t BMI270_REG_TEMP_0 = 0x22;
static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish)
static constexpr uint8_t BMI270_REG_CHIP_ID = 0x00;
static constexpr uint8_t BMI270_REG_ERR_REG = 0x02;
static constexpr uint8_t BMI270_REG_STATUS = 0x03;
static constexpr uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB
static constexpr uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB
static constexpr uint8_t BMI270_REG_TEMP_0 = 0x22;
static constexpr uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish)
static constexpr uint8_t REG_READ_LEN =
BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 +
1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4)
static const uint8_t BMI270_REG_PWR_CONF = 0x7C;
static const uint8_t BMI270_REG_PWR_CTRL = 0x7D;
static const uint8_t BMI270_REG_INIT_CTRL = 0x59;
static const uint8_t BMI270_REG_INIT_DATA = 0x5E;
static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B;
static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21;
static const uint8_t BMI270_REG_ACC_CONF = 0x40;
static const uint8_t BMI270_REG_ACC_RANGE = 0x41;
static const uint8_t BMI270_REG_GYR_CONF = 0x42;
static const uint8_t BMI270_REG_GYR_RANGE = 0x43;
static constexpr uint8_t BMI270_REG_PWR_CONF = 0x7C;
static constexpr uint8_t BMI270_REG_PWR_CTRL = 0x7D;
static constexpr uint8_t BMI270_REG_INIT_CTRL = 0x59;
static constexpr uint8_t BMI270_REG_INIT_DATA = 0x5E;
static constexpr uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B;
static constexpr uint8_t BMI270_REG_INTERNAL_STATUS = 0x21;
static constexpr uint8_t BMI270_REG_ACC_CONF = 0x40;
static constexpr uint8_t BMI270_REG_ACC_RANGE = 0x41;
static constexpr uint8_t BMI270_REG_GYR_CONF = 0x42;
static constexpr uint8_t BMI270_REG_GYR_RANGE = 0x43;
static const uint8_t BMI270_CHIP_ID_VALUE = 0x24;
static constexpr uint8_t BMI270_CHIP_ID_VALUE = 0x24;
// Auxiliary (secondary I2C master) interface registers.
// Used to optionally drive a BMM150 magnetometer wired to the BMI270's AUX pins
// instead of the main I2C bus - the BMM150 has no address of its own on that bus.
static constexpr uint8_t BMI270_REG_AUX_X_LSB = 0x04; // start of aux data: X,Y,Z,R (8 bytes)
static constexpr uint8_t BMI270_REG_AUX_DEV_ID = 0x4B;
static constexpr uint8_t BMI270_REG_AUX_IF_CONF = 0x4C;
static constexpr uint8_t BMI270_REG_AUX_RD_ADDR = 0x4D;
static constexpr uint8_t BMI270_REG_AUX_WR_ADDR = 0x4E;
static constexpr uint8_t BMI270_REG_AUX_WR_DATA = 0x4F;
static constexpr uint8_t BMI270_REG_IF_CONF = 0x6B;
static constexpr uint8_t BMI270_REG_CMD = 0x7E;
// Register values and bitfields
static constexpr uint8_t BMI270_CMD_SOFT_RESET = 0xB6;
static constexpr uint8_t BMI270_PWR_CONF_ADV_POWER_SAVE_OFF = 0x00;
static constexpr uint8_t BMI270_PWR_CONF_FIFO_SELF_WAKEUP = 0x02;
static constexpr uint8_t BMI270_INIT_CTRL_LOAD_START = 0x00;
static constexpr uint8_t BMI270_INIT_CTRL_LOAD_DONE = 0x01;
static constexpr uint8_t BMI270_INTERNAL_STATUS_MASK = 0x0F;
static constexpr uint8_t BMI270_INTERNAL_STATUS_INIT_OK = 0x01;
static constexpr uint8_t BMI270_CONF_BWP_NORMAL = 0x02; // filter bandwidth, bits 5:4
static constexpr uint8_t BMI270_CONF_BWP_SHIFT = 4;
static constexpr uint8_t BMI270_ACC_CONF_PERF_MODE = 0x80;
static constexpr uint8_t BMI270_GYR_CONF_NOISE_PERF = 0x40;
static constexpr uint8_t BMI270_GYR_CONF_FILTER_PERF = 0x80;
static constexpr uint8_t BMI270_STATUS_AUX_BUSY = 0x04;
static constexpr uint8_t BMI270_IF_CONF_AUX_IF_EN = 0x20;
static constexpr uint8_t BMI270_PWR_CTRL_AUX_EN = 0x01;
static constexpr uint8_t BMI270_PWR_CTRL_ACC_EN = 0x02;
static constexpr uint8_t BMI270_PWR_CTRL_GYR_EN = 0x04;
static constexpr uint8_t BMI270_PWR_CTRL_TEMP_EN = 0x08;
static constexpr uint8_t BMI270_PWR_CTRL_IMU =
BMI270_PWR_CTRL_ACC_EN | BMI270_PWR_CTRL_GYR_EN | BMI270_PWR_CTRL_TEMP_EN;
static constexpr uint8_t BMI270_AUX_IF_CONF_MANUAL_EN = 0x80;
static constexpr uint8_t BMI270_AUX_IF_CONF_FCU_WRITE_EN = 0x40;
static constexpr uint8_t BMI270_AUX_IF_CONF_MAN_RD_BURST_8 = 0x0C; // bits 3:2 = 3
static constexpr uint8_t BMI270_AUX_IF_CONF_RD_BURST_8 = 0x03; // bits 1:0 = 3
// AUX_X_LSB..AUX_R_MSB (8 bytes) sit directly before the accel data, so one burst can read both.
static constexpr uint8_t BMI270_AUX_DATA_LEN = 8;
static_assert(BMI270_REG_AUX_X_LSB + BMI270_AUX_DATA_LEN == BMI270_REG_DATA_8, "aux data must precede accel data");
// Accelerometer range options
enum BMI270AccelRange : uint8_t {
@@ -75,7 +121,14 @@ enum BMI270GyroODR : uint8_t {
BMI270_GYRO_ODR_3200 = 0x0D,
};
// ---Data class
#ifdef USE_BMI270_BMM150
// What is wired to this instance's auxiliary (secondary I2C master) interface; another
// instance in the same build may have nothing attached.
enum BMI270AuxDevice : uint8_t {
BMI270_AUX_DEVICE_NONE = 0,
BMI270_AUX_DEVICE_BMM150 = 1,
};
#endif
// Main component class
class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice {
@@ -91,18 +144,43 @@ class BMI270Component final : public motion::MotionComponent, public i2c::I2CDev
void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; }
void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; }
template<typename F> void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward<F>(cb)); }
#ifdef USE_BMI270_BMM150
void set_aux_device(BMI270AuxDevice device) { this->aux_device_ = device; }
void set_aux_device_address(uint8_t address) { this->aux_device_address_ = address; }
template<typename F> void add_magnetometer_listener(F &&cb) { this->magnetometer_callback_.add(std::forward<F>(cb)); }
#endif
protected:
bool update_data(motion::MotionData &data) override;
bool load_config_file_();
#ifdef USE_BMI270_BMM150
// Brings up the BMM150 over the BMI270's aux interface. Optional: on failure this only
// disables magnetometer reporting, it does not fail the whole component (accel/gyro still work).
bool setup_magnetometer_();
// Blocking poll of the aux-transaction-busy bit; aux transaction time depends on the
// attached device, so this is bounded by retries/timeout rather than a fixed delay.
bool wait_for_aux_idle_(uint32_t timeout_ms);
// Manual-mode aux read of 8 bytes starting at `reg` on the aux device.
bool read_aux_block_(uint8_t reg, uint8_t *buf);
// Manual-mode aux write of one byte to `reg` on the aux device.
bool write_aux_register_(uint8_t reg, uint8_t value);
// Reads the BMM150 factory trim values used to compensate its samples.
bool read_magnetometer_trim_();
#endif
// Config
BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G};
BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100};
BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000};
BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200};
LazyCallbackManager<void(float)> temperature_callback_{};
#ifdef USE_BMI270_BMM150
LazyCallbackManager<void(const bmm150::BMM150Data &)> magnetometer_callback_{};
bmm150::BMM150Trim mag_trim_{};
BMI270AuxDevice aux_device_{BMI270_AUX_DEVICE_NONE};
uint8_t aux_device_address_{bmm150::BMM150_DEFAULT_I2C_ADDRESS};
bool magnetometer_ready_{false};
#endif
};
} // namespace esphome::bmi270
+123
View File
@@ -0,0 +1,123 @@
#pragma once
#include <cmath>
#include <cstdint>
#include "esphome/core/helpers.h"
// BMM150 magnetometer definitions - the chip that can optionally be wired to the BMI270's
// auxiliary (secondary I2C master) interface. Lives in its own header/namespace because this
// is BMM150-specific driver knowledge, not BMI270 knowledge - it just can't be a standalone
// ESPHome component, since the BMM150 has no address of its own on the main I2C bus and is only
// reachable by having the BMI270 proxy register reads/writes through its aux interface.
namespace esphome::bmi270::bmm150 {
static constexpr uint8_t BMM150_DEFAULT_I2C_ADDRESS = 0x10;
static constexpr uint8_t BMM150_REG_CHIP_ID = 0x40;
static constexpr uint8_t BMM150_REG_DATA_X_LSB = 0x42;
static constexpr uint8_t BMM150_REG_POWER_CONTROL = 0x4B;
static constexpr uint8_t BMM150_REG_OP_MODE = 0x4C;
static constexpr uint8_t BMM150_CHIP_ID_VALUE = 0x32;
static constexpr uint8_t BMM150_CMD_POWER_ON = 0x01; // power control bit: suspend -> sleep (3 ms start-up)
static constexpr uint8_t BMM150_CMD_SOFT_RESET = 0x83; // soft reset bits 7 and 1, power control bit kept set
static constexpr uint8_t BMM150_CMD_NORMAL_MODE_ODR_30HZ = 0x38; // data rate 111b (30 Hz), opmode 00b (normal)
static constexpr uint32_t BMM150_START_UP_TIME_MS = 3;
static constexpr uint32_t BMM150_SOFT_RESET_TIME_MS = 1;
// Factory trim registers 0x5D-0x71, read once at setup and used to compensate every sample.
static constexpr uint8_t BMM150_REG_TRIM_START = 0x5D;
static constexpr uint8_t BMM150_TRIM_BLOCK_LEN = 8; // one aux burst
static constexpr uint8_t BMM150_TRIM_BLOCKS = 3; // 0x5D, 0x65, 0x6D -> covers 0x5D-0x74
static constexpr uint8_t BMM150_TRIM_X1 = 0x5D;
static constexpr uint8_t BMM150_TRIM_Y1 = 0x5E;
static constexpr uint8_t BMM150_TRIM_Z4 = 0x62; // 16-bit, LSB first
static constexpr uint8_t BMM150_TRIM_X2 = 0x64;
static constexpr uint8_t BMM150_TRIM_Y2 = 0x65;
static constexpr uint8_t BMM150_TRIM_Z2 = 0x68; // 16-bit
static constexpr uint8_t BMM150_TRIM_Z1 = 0x6A; // 16-bit
static constexpr uint8_t BMM150_TRIM_XYZ1 = 0x6C; // 16-bit, 15 valid bits
static constexpr uint8_t BMM150_TRIM_Z3 = 0x6E; // 16-bit
static constexpr uint8_t BMM150_TRIM_XY2 = 0x70;
static constexpr uint8_t BMM150_TRIM_XY1 = 0x71;
static constexpr uint16_t BMM150_TRIM_XYZ1_MASK = 0x7FFF;
// Raw ADC values that mean "overflow" per the datasheet.
static constexpr int16_t BMM150_XY_OVERFLOW_ADCVAL = -4096;
static constexpr int16_t BMM150_Z_OVERFLOW_ADCVAL = -16384;
// Result of a BMM150 magnetometer reading, in µT. An axis is NaN when the sensor reported overflow.
struct BMM150Data {
float x;
float y;
float z;
};
// Factory trim values (BMM150 datasheet, compensation section).
struct BMM150Trim {
int8_t x1, y1, x2, y2, xy2;
uint8_t xy1;
int16_t z2, z3, z4;
uint16_t z1, xyz1;
};
// A little-endian 16-bit trim word, addressed by its register
inline uint16_t bmm150_trim_word(const uint8_t *regs, uint8_t reg) {
const uint8_t offset = reg - BMM150_REG_TRIM_START;
return encode_uint16(regs[offset + 1], regs[offset]);
}
// Decodes the trim registers; `regs` holds the bytes of registers 0x5D onwards (at least 21).
inline BMM150Trim bmm150_parse_trim(const uint8_t *regs) {
BMM150Trim trim;
trim.x1 = static_cast<int8_t>(regs[BMM150_TRIM_X1 - BMM150_REG_TRIM_START]);
trim.y1 = static_cast<int8_t>(regs[BMM150_TRIM_Y1 - BMM150_REG_TRIM_START]);
trim.z4 = static_cast<int16_t>(bmm150_trim_word(regs, BMM150_TRIM_Z4));
trim.x2 = static_cast<int8_t>(regs[BMM150_TRIM_X2 - BMM150_REG_TRIM_START]);
trim.y2 = static_cast<int8_t>(regs[BMM150_TRIM_Y2 - BMM150_REG_TRIM_START]);
trim.z2 = static_cast<int16_t>(bmm150_trim_word(regs, BMM150_TRIM_Z2));
trim.z1 = bmm150_trim_word(regs, BMM150_TRIM_Z1);
trim.xyz1 = bmm150_trim_word(regs, BMM150_TRIM_XYZ1) & BMM150_TRIM_XYZ1_MASK;
trim.z3 = static_cast<int16_t>(bmm150_trim_word(regs, BMM150_TRIM_Z3));
trim.xy2 = static_cast<int8_t>(regs[BMM150_TRIM_XY2 - BMM150_REG_TRIM_START]);
trim.xy1 = regs[BMM150_TRIM_XY1 - BMM150_REG_TRIM_START];
return trim;
}
// Compensates an X or Y sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant).
inline float bmm150_compensate_xy(const BMM150Trim &trim, int8_t trim_a, int8_t trim_b, int16_t raw, uint16_t rhall) {
if (raw == BMM150_XY_OVERFLOW_ADCVAL || rhall == 0 || trim.xyz1 == 0)
return NAN;
float retval = (float) trim.xyz1 * 16384.0f / (float) rhall - 16384.0f;
float comp1 = (float) trim.xy2 * (retval * retval / 268435456.0f);
float comp2 = comp1 + retval * (float) trim.xy1 / 16384.0f;
float comp3 = (float) trim_b + 160.0f;
float comp4 = (float) raw * ((comp2 + 256.0f) * comp3);
return ((comp4 / 8192.0f) + (float) trim_a * 8.0f) / 16.0f;
}
// Compensates a Z sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant).
inline float bmm150_compensate_z(const BMM150Trim &trim, int16_t raw, uint16_t rhall) {
if (raw == BMM150_Z_OVERFLOW_ADCVAL || trim.z2 == 0 || trim.z1 == 0 || trim.xyz1 == 0 || rhall == 0)
return NAN;
float comp0 = (float) raw - (float) trim.z4;
float comp1 = (float) rhall - (float) trim.xyz1;
float comp2 = (float) trim.z3 * comp1;
float comp3 = (float) trim.z1 * (float) rhall / 32768.0f;
float comp4 = (float) trim.z2 + comp3;
float comp5 = comp0 * 131072.0f - comp2;
return (comp5 / (comp4 * 4.0f)) / 16.0f;
}
// Converts 8 raw bytes from the BMM150 data registers (X, Y, Z, RHALL; 0x42-0x49) to compensated µT.
inline BMM150Data bmm150_convert(const BMM150Trim &trim, const uint8_t *raw) {
// X/Y are 13-bit (LSB byte bits 7:3), Z is 15-bit (bits 7:1), RHALL is 14-bit (bits 7:2).
int16_t x = (int16_t) (((int16_t) (int8_t) raw[1] * 32) | (raw[0] >> 3));
int16_t y = (int16_t) (((int16_t) (int8_t) raw[3] * 32) | (raw[2] >> 3));
int16_t z = (int16_t) (((int16_t) (int8_t) raw[5] * 128) | (raw[4] >> 1));
uint16_t rhall = (uint16_t) (((uint16_t) raw[7] << 6) | (raw[6] >> 2));
return BMM150Data{
.x = bmm150_compensate_xy(trim, trim.x1, trim.x2, x, rhall),
.y = bmm150_compensate_xy(trim, trim.y1, trim.y2, y, rhall),
.z = bmm150_compensate_z(trim, z, rhall),
};
}
} // namespace esphome::bmi270::bmm150
+17
View File
@@ -14,7 +14,11 @@ from . import BMI270Component, bmi270_ns
DEPENDENCIES = ["i2c"]
CONF_AUX_ADDRESS = "aux_address"
CONF_AUX_DEVICE = "aux_device"
# Enum proxies (must match the C++ enum values exactly)
BMI270AuxDevice = bmi270_ns.enum("BMI270AuxDevice")
BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange")
ACCEL_RANGE_OPTIONS = {
"2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G,
@@ -73,6 +77,13 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum(
GYRO_ODR_OPTIONS, upper=True
),
# Optional device wired to the BMI270's aux interface (not the main I2C bus).
# Defaults to NONE so existing configs/boards are unaffected.
cv.Optional(CONF_AUX_DEVICE, default="NONE"): cv.one_of(
"NONE", "BMM150", upper=True
),
# Default matches the BMM150's fixed I2C address; irrelevant when aux_device is NONE.
cv.Optional(CONF_AUX_ADDRESS, default=0x10): cv.i2c_address,
}
)
.extend(i2c.i2c_device_schema(0x68))
@@ -90,3 +101,9 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR]))
cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE]))
cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR]))
# The aux interface code only compiles when some instance has a BMM150; the
# instance still records its own device, as another one may have none
if config[CONF_AUX_DEVICE] == "BMM150":
cg.add_define("USE_BMI270_BMM150")
cg.add(var.set_aux_device(BMI270AuxDevice.BMI270_AUX_DEVICE_BMM150))
cg.add(var.set_aux_device_address(config[CONF_AUX_ADDRESS]))
+74 -14
View File
@@ -6,37 +6,97 @@ from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
DEVICE_CLASS_TEMPERATURE,
ICON_MAGNET,
ICON_THERMOMETER,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_MICROTESLA,
)
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import CONF_BMI270_ID, BMI270Component
from . import CONF_BMI270_ID, BMI270Component, bmi270_ns
from .motion import CONF_AUX_DEVICE
AUTO_LOAD = ["bmi270"]
CONFIG_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
icon=ICON_THERMOMETER,
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_TEMPERATURE,
).extend(
CONF_MAGNETIC_FIELD_X = "magnetic_field_x"
CONF_MAGNETIC_FIELD_Y = "magnetic_field_y"
CONF_MAGNETIC_FIELD_Z = "magnetic_field_z"
BMM150Data = bmi270_ns.namespace("bmm150").class_("BMM150Data")
_MAGNETIC_FIELDS = (CONF_MAGNETIC_FIELD_X, CONF_MAGNETIC_FIELD_Y, CONF_MAGNETIC_FIELD_Z)
def _parent_schema() -> cv.Schema:
return cv.Schema({cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component)})
def _temperature_sensor_schema() -> cv.Schema:
return sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
icon=ICON_THERMOMETER,
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
device_class=DEVICE_CLASS_TEMPERATURE,
).extend(_parent_schema())
def _magnetic_field_sensor_schema() -> cv.Schema:
return sensor.sensor_schema(
unit_of_measurement=UNIT_MICROTESLA,
icon=ICON_MAGNET,
accuracy_decimals=2,
state_class=STATE_CLASS_MEASUREMENT,
).extend(_parent_schema())
# `type` defaults to temperature so existing configs that omit it keep working unchanged.
CONFIG_SCHEMA = cv.typed_schema(
{
cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE),
cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component),
}
CONF_TEMPERATURE: _temperature_sensor_schema(),
**{x: _magnetic_field_sensor_schema() for x in _MAGNETIC_FIELDS},
},
default_type=CONF_TEMPERATURE,
)
def _final_validate(config: ConfigType) -> None:
if config[CONF_TYPE] not in _MAGNETIC_FIELDS:
return
full_config = fv.full_config.get()
bmi270_path = full_config.get_path_for_id(config[CONF_BMI270_ID])[:-1]
bmi270_config = full_config.get_config_for_path(bmi270_path)
if bmi270_config.get(CONF_AUX_DEVICE) != "BMM150":
raise cv.Invalid(
"This sensor requires the parent bmi270's 'aux_device' to be set to 'BMM150'",
path=[CONF_TYPE],
)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
sensor_type = config[CONF_TYPE]
var = await sensor.new_sensor(config)
parent = await cg.get_variable(config[CONF_BMI270_ID])
if sensor_type == CONF_TEMPERATURE:
data = MockObj("data")
value_lambda = await cg.process_lambda(
var.publish_state(data),
[(cg.float_, str(data))],
)
cg.add(parent.add_temperature_listener(value_lambda))
return
axis = sensor_type[-1:]
data = MockObj("data")
value_lambda = await cg.process_lambda(
var.publish_state(data),
[(cg.float_, str(data))],
var.publish_state(getattr(data, axis)),
[(BMM150Data.operator("const").operator("ref"), str(data))],
)
cg.add(parent.add_temperature_listener(value_lambda))
cg.add(parent.add_magnetometer_listener(value_lambda))
+377
View File
@@ -0,0 +1,377 @@
from collections.abc import Callable
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL, CONF_WATCHDOG
from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod
from esphome.types import ConfigType, TemplateArgsType
CODEOWNERS = ["@Rapsssito"]
DEPENDENCIES = ["i2c"]
DOMAIN = "bq25186"
MULTI_CONF = True
CONF_BQ25186_ID = "bq25186_id"
CONF_CONFIGURE_ON_BOOT = "configure_on_boot"
CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage"
CONF_FAST_CHARGE_CURRENT = "fast_charge_current"
CONF_CHARGE_ENABLED = "charge_enabled"
CONF_FLASH_CHARGING_MODE = "flash_charging_mode"
CONF_PRECHARGE_IS_ITERM = "precharge_is_iterm"
CONF_TERMINATION_PERCENT = "termination_percent"
CONF_VINDPM_MODE = "vindpm_mode"
CONF_THERMAL_REGULATION = "thermal_regulation"
CONF_BATTERY_OCP_LIMIT = "battery_ocp_limit"
CONF_BATTERY_UVLO = "battery_uvlo"
CONF_MASK_CHARGE_STATUS_INTERRUPT = "mask_charge_status_interrupt"
CONF_MASK_ILIM_INTERRUPT = "mask_ilim_interrupt"
CONF_MASK_VINDPM_INTERRUPT = "mask_vindpm_interrupt"
CONF_TS_AUTO_FUNCTION = "ts_auto_function"
CONF_VLOWV = "vlowv"
CONF_RECHARGE_THRESHOLD = "recharge_threshold"
CONF_DOUBLE_TIMER_DURING_DPM = "double_timer_during_dpm"
CONF_SAFETY_TIMER = "safety_timer"
CONF_LONG_PRESS_DURATION = "long_press_duration"
CONF_HW_RESET_REQUIRES_VIN = "hw_reset_requires_vin"
CONF_AUTOWAKE = "autowake"
CONF_INPUT_CURRENT_LIMIT = "input_current_limit"
CONF_PUSH_BUTTON_LONG_PRESS_ACTION = "push_button_long_press_action"
CONF_WAKE1_TIMER = "wake1_timer"
CONF_WAKE2_TIMER = "wake2_timer"
CONF_ENABLE_PUSH_BUTTON = "enable_push_button"
CONF_SYSTEM_REGULATION = "system_regulation"
CONF_SYS_MODE = "sys_mode"
CONF_WATCHDOG_15S_ENABLE = "watchdog_15s_enable"
CONF_DISABLE_VDPPM = "disable_vdppm"
CONF_TS_HOT = "ts_hot"
CONF_TS_COLD = "ts_cold"
CONF_TS_WARM_DISABLE = "ts_warm_disable"
CONF_TS_COOL_DISABLE = "ts_cool_disable"
CONF_TS_ICHG = "ts_ichg"
CONF_TS_VRCG = "ts_vrcg"
CONF_MASK_TS_INTERRUPT = "mask_ts_interrupt"
CONF_MASK_TREG_INTERRUPT = "mask_treg_interrupt"
CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt"
CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt"
bq25186_ns = cg.esphome_ns.namespace("bq25186")
BQ25186Component = bq25186_ns.class_(
"BQ25186Component", cg.PollingComponent, i2c.I2CDevice
)
BQ25186_CHILD_SCHEMA = cv.Schema(
{cv.GenerateID(CONF_BQ25186_ID): cv.use_id(BQ25186Component)}
)
_request_binary_sensor_slot = cg.slot_counter("BQ25186_BINARY_SENSOR_COUNT")
_request_text_sensor_slot = cg.slot_counter("BQ25186_TEXT_SENSOR_COUNT")
def register_binary_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression:
_request_binary_sensor_slot(str(hub))
return hub.register_binary_sensor(var)
def register_text_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression:
_request_text_sensor_slot(str(hub))
return hub.register_text_sensor(var)
REG_VBAT_CTRL = 0x03
REG_ICHG_CTRL = 0x04
REG_CHARGECTRL0 = 0x05
REG_CHARGECTRL1 = 0x06
REG_IC_CTRL = 0x07
REG_TMR_ILIM = 0x08
REG_SHIP_RST = 0x09
REG_SYS_REG = 0x0A
REG_TS_CONTROL = 0x0B
REG_MASK_ID = 0x0C
REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1
TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3}
VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3}
THERM_REGULATION_OPTIONS = {"100c": 0, "80c": 1, "60c": 2, "disabled": 3}
BATTERY_OCP_LIMIT_OPTIONS = {"500ma": 0, "1000ma": 1, "1500ma": 2, "3000ma": 3}
BATTERY_UVLO_OPTIONS = {
"3.0v": 0,
"2.8v": 3,
"2.6v": 4,
"2.4v": 5,
"2.2v": 6,
"2.0v": 7,
}
VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1}
RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1}
SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3}
# The I2C watchdog restores the power-on registers when the bus stays quiet for its period
WATCHDOG_OPTIONS = {
"160s_software_reset": 0,
"160s_hardware_reset": 1,
"40s_hardware_reset": 2,
"disabled": 3,
}
WATCHDOG_PERIOD_S = {
"160s_software_reset": 160,
"160s_hardware_reset": 160,
"40s_hardware_reset": 40,
}
# SYS_REG watchdog_15s_enable: a hardware reset when the bus stays quiet for 15 s after VIN connects
WATCHDOG_15S_PERIOD_S = 15
# The chip boots with the 160 s software reset watchdog, which runs until the configuration is applied
POWER_ON_WATCHDOG_PERIOD_S = 160
LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3}
AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3}
INPUT_CURRENT_LIMIT_OPTIONS = {
"50ma": 0,
"100ma": 1,
"200ma": 2,
"300ma": 3,
"400ma": 4,
"500ma": 5,
"665ma": 6,
"1050ma": 7,
}
PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS = {
"none": 0,
"hardware_reset": 1,
"ship_mode": 2,
"shutdown_mode": 3,
}
WAKE1_TIMER_OPTIONS = {"300ms": 0, "1s": 1}
WAKE2_TIMER_OPTIONS = {"2s": 0, "3s": 1}
SYSTEM_REGULATION_OPTIONS = {
"battery_tracking": 0,
"4.4v": 1,
"4.5v": 2,
"4.6v": 3,
"4.7v": 4,
"4.8v": 5,
"4.9v": 6,
"pass_through": 7,
}
SYS_MODE_OPTIONS = {
"normal": 0,
"battery_only": 1,
"floating_off": 2,
"pulldown_off": 3,
}
TS_HOT_OPTIONS = {"60c": 0, "65c": 1, "50c": 2, "45c": 3}
TS_COLD_OPTIONS = {"0c": 0, "3c": 1, "5c": 2, "-3c": 3}
TS_ICHG_OPTIONS = {"0.5x": 0, "0.2x": 1}
TS_VRCG_OPTIONS = {"-100mv": 0, "-200mv": 1}
def _on_voltage_step(volts: float) -> float:
millivolts = volts * 1000
if abs(millivolts - round(millivolts / 10) * 10) > 1e-6:
raise cv.Invalid("battery_regulation_voltage must be a multiple of 10 mV")
return volts
def _on_current_step(amps: float) -> float:
milliamps = amps * 1000
step = 1 if milliamps < 36 else 10
if abs(milliamps - round(milliamps / step) * step) > 1e-6 or 35 < milliamps < 40:
raise cv.Invalid(
"fast_charge_current must be a whole number of mA up to 35mA, then a multiple of 10 mA"
)
return amps
# (config key, register, bit shift, validator or enum options, default).
# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; the PG/GPO
# switch drives them.
FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = (
(
CONF_BATTERY_REGULATION_VOLTAGE,
REG_VBAT_CTRL,
0,
cv.All(cv.voltage, cv.Range(min=3.5, max=4.65), _on_voltage_step),
"4.2V",
),
(CONF_CHARGE_ENABLED, REG_ICHG_CTRL, 7, cv.boolean, True),
(
CONF_FAST_CHARGE_CURRENT,
REG_ICHG_CTRL,
0,
cv.All(cv.current, cv.Range(min=0.005, max=1.0), _on_current_step),
"10mA",
),
(CONF_FLASH_CHARGING_MODE, REG_CHARGECTRL0, 7, cv.boolean, False),
(CONF_PRECHARGE_IS_ITERM, REG_CHARGECTRL0, 6, cv.boolean, False),
(CONF_TERMINATION_PERCENT, REG_CHARGECTRL0, 4, TERMINATION_PERCENT_OPTIONS, "10%"),
(CONF_VINDPM_MODE, REG_CHARGECTRL0, 2, VINDPM_MODE_OPTIONS, "4.5v"),
(CONF_THERMAL_REGULATION, REG_CHARGECTRL0, 0, THERM_REGULATION_OPTIONS, "100c"),
(CONF_BATTERY_OCP_LIMIT, REG_CHARGECTRL1, 6, BATTERY_OCP_LIMIT_OPTIONS, "3000ma"),
(CONF_BATTERY_UVLO, REG_CHARGECTRL1, 3, BATTERY_UVLO_OPTIONS, "3.0v"),
(CONF_MASK_CHARGE_STATUS_INTERRUPT, REG_CHARGECTRL1, 2, cv.boolean, True),
(CONF_MASK_ILIM_INTERRUPT, REG_CHARGECTRL1, 1, cv.boolean, True),
(CONF_MASK_VINDPM_INTERRUPT, REG_CHARGECTRL1, 0, cv.boolean, False),
(CONF_TS_AUTO_FUNCTION, REG_IC_CTRL, 7, cv.boolean, True),
(CONF_VLOWV, REG_IC_CTRL, 6, VLOWV_OPTIONS, "3.0v"),
(CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"),
(CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False),
(CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"),
(CONF_WATCHDOG, REG_IC_CTRL, 0, WATCHDOG_OPTIONS, "disabled"),
(CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"),
(CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False),
(CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"),
(CONF_INPUT_CURRENT_LIMIT, REG_TMR_ILIM, 0, INPUT_CURRENT_LIMIT_OPTIONS, "500ma"),
(
CONF_PUSH_BUTTON_LONG_PRESS_ACTION,
REG_SHIP_RST,
3,
PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS,
"ship_mode",
),
(CONF_WAKE1_TIMER, REG_SHIP_RST, 2, WAKE1_TIMER_OPTIONS, "300ms"),
(CONF_WAKE2_TIMER, REG_SHIP_RST, 1, WAKE2_TIMER_OPTIONS, "2s"),
(CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True),
(CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"),
(CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"),
(CONF_WATCHDOG_15S_ENABLE, REG_SYS_REG, 1, cv.boolean, False),
(CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False),
(CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"),
(CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"),
(CONF_TS_WARM_DISABLE, REG_TS_CONTROL, 3, cv.boolean, False),
(CONF_TS_COOL_DISABLE, REG_TS_CONTROL, 2, cv.boolean, False),
(CONF_TS_ICHG, REG_TS_CONTROL, 1, TS_ICHG_OPTIONS, "0.5x"),
(CONF_TS_VRCG, REG_TS_CONTROL, 0, TS_VRCG_OPTIONS, "-100mv"),
(CONF_MASK_TS_INTERRUPT, REG_MASK_ID, 7, cv.boolean, False),
(CONF_MASK_TREG_INTERRUPT, REG_MASK_ID, 6, cv.boolean, True),
(CONF_MASK_BAT_INTERRUPT, REG_MASK_ID, 5, cv.boolean, False),
(CONF_MASK_PG_INTERRUPT, REG_MASK_ID, 4, cv.boolean, False),
)
def _battery_regulation_code(volts: float) -> int:
"""VBAT_CTRL[6:0]: 3.5 V plus 10 mV per step."""
return (round(volts * 1000) - 3500) // 10
def _fast_charge_code(amps: float) -> int:
"""ICHG_CTRL[6:0]: 1 mA steps from 5 mA to 35 mA, then 10 mA steps from 40 mA."""
milliamps = round(amps * 1000)
if milliamps <= 35:
return milliamps - 5
return milliamps // 10 + 27
_ENCODERS: dict[str, Callable[[Any], int]] = {
CONF_BATTERY_REGULATION_VOLTAGE: _battery_regulation_code,
CONF_FAST_CHARGE_CURRENT: _fast_charge_code,
CONF_CHARGE_ENABLED: lambda enabled: not enabled,
}
def pack_registers(config: ConfigType) -> list[int]:
"""Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes."""
regs = [0] * REGISTER_COUNT
for key, register, shift, validator, _ in FIELDS:
value = config[key]
if isinstance(validator, dict):
value = validator[value]
elif (encode := _ENCODERS.get(key)) is not None:
value = encode(value)
regs[register - REG_VBAT_CTRL] |= int(value) << shift
return regs
def _field_schema(with_defaults: bool) -> cv.Schema:
schema = {}
for key, _, _, validator, default in FIELDS:
if isinstance(validator, dict):
validator = cv.enum(validator, lower=True)
optional = (
cv.Optional(key, default=default) if with_defaults else cv.Optional(key)
)
schema[optional] = validator
return cv.Schema(schema)
BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True)
def _check_watchdog(config: ConfigType) -> ConfigType:
"""Only the status poll talks to the chip, so it has to come round before any watchdog does."""
periods = []
if (period := WATCHDOG_PERIOD_S.get(config[CONF_WATCHDOG])) is not None:
periods.append(period)
if config[CONF_WATCHDOG_15S_ENABLE]:
periods.append(WATCHDOG_15S_PERIOD_S)
if not config.get(CONF_CONFIGURE_ON_BOOT, True):
periods.append(POWER_ON_WATCHDOG_PERIOD_S)
if not periods:
return config
if config[CONF_UPDATE_INTERVAL] >= TimePeriod(seconds=(limit := min(periods))):
raise cv.Invalid(
f"{CONF_UPDATE_INTERVAL} must be shorter than {limit} s, or the "
f"watchdog resets the charger between status polls",
path=[CONF_UPDATE_INTERVAL],
)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BQ25186Component),
cv.Optional(CONF_CONFIGURE_ON_BOOT, default=True): cv.boolean,
}
)
.extend(BQ25186_CONFIG_SCHEMA)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x6A)),
_check_watchdog,
)
CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(BQ25186Component),
}
).extend(_field_schema(with_defaults=False))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if config[CONF_CONFIGURE_ON_BOOT]:
cg.add(var.set_setup_registers(cg.ArrayInitializer(*pack_registers(config))))
@automation.register_action(
"bq25186.apply_configuration",
automation.LambdaAction,
CONFIGURE_ACTION_SCHEMA,
synchronous=True,
)
async def bq25186_configure_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> cg.MockObj:
parent = await cg.get_variable(config[CONF_ID])
# Options the action leaves out fall back to the component's configuration
path = CORE.config.get_path_for_id(config[CONF_ID])[:-1]
parent_config = CORE.config.get_config_for_path(path)
merged = {**parent_config, **config}
try:
_check_watchdog(merged)
except cv.Invalid as err:
raise EsphomeError(f"bq25186.apply_configuration: {err.msg}") from err
regs = pack_registers(merged)
text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs))))
lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void)
return automation.new_lambda_pvariable(
action_id, lambda_, automation.StatelessLambdaAction, template_arg
)
@@ -0,0 +1,73 @@
from functools import partial
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_OPENING,
DEVICE_CLASS_PLUG,
DEVICE_CLASS_RUNNING,
)
from esphome.types import ConfigType
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_binary_sensor
DEPENDENCIES = ["bq25186"]
BQ25186BinarySensor = bq25186_ns.class_(
"BQ25186BinarySensor", binary_sensor.BinarySensor
)
REG_STAT0 = 0
REG_STAT1 = 1
REG_FLAG0 = 2
# Bit set for each field value that reads as on: a single bit is on when it is 1
_BIT_SET = 0b10
# (key, status register, bit shift, field mask, on-values bitmap, device class)
_FIELDS = (
("ts", REG_STAT0, 7, 0x01, _BIT_SET, DEVICE_CLASS_OPENING),
("charging", REG_STAT0, 5, 0x03, 0b0110, DEVICE_CLASS_BATTERY_CHARGING),
("charge_done", REG_STAT0, 5, 0x03, 0b1000, cv.UNDEFINED),
("ilim", REG_STAT0, 4, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("vdppm", REG_STAT0, 3, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("vindpm", REG_STAT0, 2, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("thermreg", REG_STAT0, 1, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("power_good", REG_STAT0, 0, 0x01, _BIT_SET, DEVICE_CLASS_PLUG),
("vin_ovp", REG_STAT1, 7, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("battery_uvlo", REG_STAT1, 6, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING),
("safety_timer_flag", REG_STAT1, 2, 0x01, _BIT_SET, cv.UNDEFINED),
("wake1_flag", REG_STAT1, 1, 0x01, _BIT_SET, cv.UNDEFINED),
("wake2_flag", REG_STAT1, 0, 0x01, _BIT_SET, cv.UNDEFINED),
("ts_flag", REG_FLAG0, 7, 0x01, _BIT_SET, cv.UNDEFINED),
("ilim_flag", REG_FLAG0, 6, 0x01, _BIT_SET, cv.UNDEFINED),
("vdppm_flag", REG_FLAG0, 5, 0x01, _BIT_SET, cv.UNDEFINED),
("vindpm_flag", REG_FLAG0, 4, 0x01, _BIT_SET, cv.UNDEFINED),
("thermreg_flag", REG_FLAG0, 3, 0x01, _BIT_SET, cv.UNDEFINED),
("vin_ovp_flag", REG_FLAG0, 2, 0x01, _BIT_SET, cv.UNDEFINED),
("battery_uvlo_flag", REG_FLAG0, 1, 0x01, _BIT_SET, cv.UNDEFINED),
("battery_ocp_flag", REG_FLAG0, 0, 0x01, _BIT_SET, cv.UNDEFINED),
)
CONFIG_SCHEMA = cv.All(
BQ25186_CHILD_SCHEMA.extend(
{
cv.Optional(key): binary_sensor.binary_sensor_schema(
BQ25186BinarySensor, device_class=device_class
)
for key, *_, device_class in _FIELDS
}
),
cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_BQ25186_ID])
sensors = binary_sensor.sub_binary_sensors(config)
for key, reg, shift, mask, match, _ in _FIELDS:
await sensors(
key, partial(register_binary_sensor, hub), reg, shift, mask, match
)
+175
View File
@@ -0,0 +1,175 @@
#include "bq25186.h"
#ifdef USE_BINARY_SENSOR
#include "bq25186_binary_sensor.h"
#endif
#ifdef USE_TEXT_SENSOR
#include "bq25186_text_sensor.h"
#endif
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::bq25186 {
ESPHOME_LOG_TAG(TAG, "bq25186");
// Bits the configuration owns in each register; the rest are the SYS_REG GPO level, reset
// triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read
static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0,
};
bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) {
if (!this->write_byte(reg, value)) {
ESP_LOGW(TAG, "Failed to write register 0x%02X", reg);
return false;
}
return true;
}
bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value) {
uint8_t reg_value;
if (!this->read_byte(reg, &reg_value)) {
ESP_LOGW(TAG, "Failed to read register 0x%02X", reg);
return false;
}
reg_value = (reg_value & ~mask) | (value & mask);
return this->write_register_(reg, reg_value);
}
uint8_t BQ25186Component::expected_register_(size_t index) const {
uint8_t value = this->regs_[index];
#ifdef USE_BQ25186_PG_GPO
if (index == 0 && this->pg_gpo_switch_ != nullptr)
value |= BQ25186_VBAT_CTRL_PG_MODE;
#endif
return value;
}
bool BQ25186Component::config_matches_(const BQ25186StatusRegisters &regs) const {
for (uint8_t reg = BQ25186_REG_VBAT_CTRL; reg <= BQ25186_REG_MASK_ID; reg++) {
const size_t index = reg - BQ25186_REG_VBAT_CTRL;
const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[index]);
if (((regs[reg] ^ this->expected_register_(index)) & mask) != 0)
return false;
}
return true;
}
bool BQ25186Component::apply_config(const BQ25186ConfigRegisters &regs) {
// Setup rejects an unknown chip; the action must not program it before that check or after it failed
if (!this->is_ready() || this->is_failed()) {
ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded");
return false;
}
this->regs_ = regs;
this->configured_ = true;
for (size_t i = 0; i < regs.size(); i++) {
const uint8_t reg = BQ25186_REG_VBAT_CTRL + i;
const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]);
const uint8_t value = this->expected_register_(i);
const bool ok = mask == 0xFF ? this->write_register_(reg, value) : this->update_register_(reg, mask, value);
if (!ok) {
this->status_set_warning(LOG_STR("Configuration partly applied"));
return false;
}
}
this->status_clear_warning();
return true;
}
void BQ25186Component::setup() {
uint8_t mask_id;
if (!this->read_byte(BQ25186_REG_MASK_ID, &mask_id)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
// Another charger at this address could lay its registers out differently, so do not write into it
const uint8_t device_id = mask_id & 0x0F;
if (device_id != 0x01) {
ESP_LOGE(TAG, "Unexpected device id 0x%X (expected 0x1)", device_id);
this->mark_failed(LOG_STR("Not a BQ25186"));
return;
}
// A failed write only sets the warning status: the chip has identified itself, so a later
// apply_configuration may retry
if (this->configure_on_boot_)
this->apply_config(this->regs_);
#ifdef USE_BQ25186_PG_GPO
if (this->pg_gpo_switch_ != nullptr) {
// The poll below puts the pin in output mode; without a restore mode the switch takes its level from it
auto initial = this->pg_gpo_switch_->get_initial_state_with_restore_mode();
if (initial.has_value())
this->pg_gpo_switch_->publish_state(*initial); // the first poll writes it to the pin
}
#endif
// The poller first runs after a random share of the interval; the entities get a state now
this->update();
}
#ifdef USE_BQ25186_PG_GPO
// The pin mode and level come from the switch, so every poll puts back whatever the chip lost
void BQ25186Component::check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending) {
if (this->pg_gpo_switch_ == nullptr)
return;
if ((status[BQ25186_REG_VBAT_CTRL] & BQ25186_VBAT_CTRL_PG_MODE) == 0 &&
!this->update_register_(BQ25186_REG_VBAT_CTRL, BQ25186_VBAT_CTRL_PG_MODE, BQ25186_VBAT_CTRL_PG_MODE)) {
this->status_set_warning(LOG_STR("PG/GPO pin not in output mode"));
pending = true;
}
const bool level = (status[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0;
if (!this->pg_gpo_switch_->has_state()) {
this->pg_gpo_switch_->publish_state(level);
} else if (level != this->pg_gpo_switch_->state && !this->write_pg_gpo_level(this->pg_gpo_switch_->state)) {
this->status_set_warning(LOG_STR("PG/GPO level write failed"));
pending = true;
}
}
#endif
// Every poll also serves the I2C watchdog, which wants to see traffic within its period
void BQ25186Component::update() {
BQ25186StatusRegisters status;
if (!this->read_bytes(BQ25186_REG_STAT0, status.data(), status.size())) {
this->status_set_warning(LOG_STR("Status read failed"));
return;
}
// Whatever failed is tried again here, so the warning only stays while something is still wrong
bool pending = false;
// A watchdog or hardware reset puts the power-on values back, and a failed write leaves a register
// behind; either way the configuration is written again
if (this->configured_ && !this->config_matches_(status)) {
ESP_LOGW(TAG, "Configuration registers differ from the values applied, writing them again");
if (this->apply_config(this->regs_)) {
status[BQ25186_REG_VBAT_CTRL] |= BQ25186_VBAT_CTRL_PG_MODE; // part of the write when a switch is configured
} else {
pending = true;
}
}
#ifdef USE_BQ25186_PG_GPO
this->check_pg_gpo_(status, pending);
#endif
if (!pending)
this->status_clear_warning();
#ifdef BQ25186_BINARY_SENSOR_COUNT
for (auto *sensor : this->binary_sensors_)
sensor->on_status(status);
#endif
#ifdef BQ25186_TEXT_SENSOR_COUNT
for (auto *sensor : this->text_sensors_)
sensor->on_status(status);
#endif
}
void BQ25186Component::dump_config() {
ESP_LOGCONFIG(TAG, "BQ25186:");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
#ifdef USE_BQ25186_PG_GPO
LOG_SWITCH(" ", "PG/GPO", this->pg_gpo_switch_);
#endif
}
} // namespace esphome::bq25186
+95
View File
@@ -0,0 +1,95 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#ifdef USE_BQ25186_PG_GPO
#include "esphome/components/switch/switch.h"
#endif
#include <array>
namespace esphome::bq25186 {
static constexpr uint8_t BQ25186_REG_STAT0 = 0x00;
static constexpr uint8_t BQ25186_REG_STAT1 = 0x01;
static constexpr uint8_t BQ25186_REG_FLAG0 = 0x02;
static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03;
static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04;
static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05;
static constexpr uint8_t BQ25186_REG_CHARGECTRL1 = 0x06;
static constexpr uint8_t BQ25186_REG_IC_CTRL = 0x07;
static constexpr uint8_t BQ25186_REG_TMR_ILIM = 0x08;
static constexpr uint8_t BQ25186_REG_SHIP_RST = 0x09;
static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A;
static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B;
static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C;
static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1;
// STAT0 to MASK_ID, read together by every poll: the status for the entities, then the configuration
// registers, which show whether the chip was reset or a write failed
static constexpr size_t BQ25186_STATUS_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_STAT0 + 1;
// VBAT_CTRL pg_mode: the /PG pin is a general purpose output driven by SYS_REG pg_gpo_level
static constexpr uint8_t BQ25186_VBAT_CTRL_PG_MODE = 0x80;
static constexpr uint8_t BQ25186_SYS_REG_PG_GPO_LEVEL = 0x10;
// Register values for VBAT_CTRL..MASK_ID, packed by code generation
using BQ25186ConfigRegisters = std::array<uint8_t, BQ25186_CONFIG_REGISTER_COUNT>;
using BQ25186StatusRegisters = std::array<uint8_t, BQ25186_STATUS_REGISTER_COUNT>;
class BQ25186BinarySensor;
class BQ25186TextSensor;
class BQ25186Component : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void update() override;
void dump_config() override;
void set_setup_registers(const BQ25186ConfigRegisters &regs) {
this->regs_ = regs;
this->configure_on_boot_ = true;
}
bool apply_config(const BQ25186ConfigRegisters &regs);
#ifdef BQ25186_BINARY_SENSOR_COUNT
void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); }
#endif
#ifdef BQ25186_TEXT_SENSOR_COUNT
void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); }
#endif
#ifdef USE_BQ25186_PG_GPO
/// The /PG/GPO pin is put in general purpose output mode when a switch drives it
void set_pg_gpo_switch(switch_::Switch *pg_gpo_switch) { this->pg_gpo_switch_ = pg_gpo_switch; }
bool write_pg_gpo_level(bool level) {
return this->update_register_(BQ25186_REG_SYS_REG, BQ25186_SYS_REG_PG_GPO_LEVEL,
level ? BQ25186_SYS_REG_PG_GPO_LEVEL : 0);
}
#endif
/// Write the masked bits of SHIP_RST
bool write_ship_rst(uint8_t mask, uint8_t value) { return this->update_register_(BQ25186_REG_SHIP_RST, mask, value); }
protected:
bool write_register_(uint8_t reg, uint8_t value);
uint8_t expected_register_(size_t index) const;
bool config_matches_(const BQ25186StatusRegisters &regs) const;
#ifdef USE_BQ25186_PG_GPO
void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending);
#endif
bool update_register_(uint8_t reg, uint8_t mask, uint8_t value);
#ifdef BQ25186_BINARY_SENSOR_COUNT
StaticVector<BQ25186BinarySensor *, BQ25186_BINARY_SENSOR_COUNT> binary_sensors_;
#endif
#ifdef BQ25186_TEXT_SENSOR_COUNT
StaticVector<BQ25186TextSensor *, BQ25186_TEXT_SENSOR_COUNT> text_sensors_;
#endif
#ifdef USE_BQ25186_PG_GPO
switch_::Switch *pg_gpo_switch_{nullptr};
#endif
BQ25186ConfigRegisters regs_{}; // the configuration last applied, written again after a chip reset
bool configure_on_boot_{false};
bool configured_{false}; // regs_ went to the chip at least once
};
} // namespace esphome::bq25186
@@ -0,0 +1,31 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BINARY_SENSOR
#include "bq25186.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome::bq25186 {
/// One field of a status register; `match` has a bit set for each field value that reads as on
class BQ25186BinarySensor final : public binary_sensor::BinarySensor {
public:
BQ25186BinarySensor(uint8_t reg, uint8_t shift, uint8_t mask, uint8_t match)
: reg_(reg), shift_(shift), mask_(mask), match_(match) {}
void on_status(const BQ25186StatusRegisters &status) {
const uint8_t value = (status[this->reg_] >> this->shift_) & this->mask_;
this->publish_state((this->match_ >> value) & 1);
}
protected:
const uint8_t reg_;
const uint8_t shift_;
const uint8_t mask_;
const uint8_t match_;
};
} // namespace esphome::bq25186
#endif // USE_BINARY_SENSOR
@@ -0,0 +1,25 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BUTTON
#include "bq25186.h"
#include "esphome/components/button/button.h"
namespace esphome::bq25186 {
/// Writes one SHIP_RST field when pressed
class BQ25186Button final : public button::Button, public Parented<BQ25186Component> {
public:
BQ25186Button(uint8_t mask, uint8_t value) : mask_(mask), value_(value) {}
protected:
void press_action() override { this->parent_->write_ship_rst(this->mask_, this->value_); }
const uint8_t mask_;
const uint8_t value_;
};
} // namespace esphome::bq25186
#endif // USE_BUTTON
@@ -0,0 +1,21 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BQ25186_PG_GPO
#include "bq25186.h"
#include "esphome/components/switch/switch.h"
namespace esphome::bq25186 {
class BQ25186PgGpoSwitch final : public switch_::Switch, public Parented<BQ25186Component> {
protected:
void write_state(bool state) override {
if (this->parent_->write_pg_gpo_level(state))
this->publish_state(state);
}
};
} // namespace esphome::bq25186
#endif // USE_BQ25186_PG_GPO
@@ -0,0 +1,26 @@
#include "bq25186_text_sensor.h"
#ifdef USE_TEXT_SENSOR
#include "esphome/core/progmem.h"
namespace esphome::bq25186 {
// STAT0[6:5] charge status, then STAT1[4:3] TS status
PROGMEM_STRING_TABLE(StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", "Charge Done/Disabled",
"Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", "Warm (Voltage Reduced)");
void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) {
const uint8_t value = (status[this->reg_] >> this->shift_) & 0x03;
if (value == this->last_)
return;
this->last_ = value;
const ProgmemStr name = StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX);
char text[32];
ESPHOME_strncpy_P(text, reinterpret_cast<ESPHOME_PGM_P>(name), sizeof(text));
text[sizeof(text) - 1] = '\0';
this->publish_state(text);
}
} // namespace esphome::bq25186
#endif // USE_TEXT_SENSOR
@@ -0,0 +1,26 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_TEXT_SENSOR
#include "bq25186.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::bq25186 {
/// A two-bit status field; `first` is the string table index of the field's value 0
class BQ25186TextSensor final : public text_sensor::TextSensor {
public:
BQ25186TextSensor(uint8_t reg, uint8_t shift, uint8_t first) : reg_(reg), shift_(shift), first_(first) {}
void on_status(const BQ25186StatusRegisters &status);
protected:
const uint8_t reg_;
const uint8_t shift_;
const uint8_t first_;
uint8_t last_{0xFF}; // the field value last published
};
} // namespace esphome::bq25186
#endif // USE_TEXT_SENSOR
+52
View File
@@ -0,0 +1,52 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_RESTART,
ENTITY_CATEGORY_CONFIG,
ICON_POWER,
ICON_RESTART,
ICON_RESTART_ALERT,
)
from esphome.types import ConfigType
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns
DEPENDENCIES = ["bq25186"]
BQ25186Button = bq25186_ns.class_("BQ25186Button", button.Button)
# SHIP_RST: bit 7 is the software reset, bits 6:5 the shutdown, ship and hardware reset codes
SOFTWARE_RESET_MASK = 0x80
MODE_MASK = 0x60
# (key, SHIP_RST mask, value, device class, icon)
_BUTTONS = (
("software_reset", SOFTWARE_RESET_MASK, 0x80, DEVICE_CLASS_RESTART, ICON_RESTART),
("shutdown", MODE_MASK, 0x20, cv.UNDEFINED, ICON_POWER),
("ship_mode", MODE_MASK, 0x40, cv.UNDEFINED, ICON_POWER),
("hardware_reset", MODE_MASK, 0x60, DEVICE_CLASS_RESTART, ICON_RESTART_ALERT),
)
CONFIG_SCHEMA = cv.All(
BQ25186_CHILD_SCHEMA.extend(
{
cv.Optional(key): button.button_schema(
BQ25186Button,
entity_category=ENTITY_CATEGORY_CONFIG,
icon=icon,
device_class=device_class,
)
for key, _, _, device_class, icon in _BUTTONS
}
),
cv.has_at_least_one_key(*(key for key, *_ in _BUTTONS)),
)
async def to_code(config: ConfigType) -> None:
for key, mask, value, _, _ in _BUTTONS:
if (conf := config.get(key)) is not None:
var = await button.new_button(conf, mask, value)
await cg.register_parented(var, config[CONF_BQ25186_ID])
+27
View File
@@ -0,0 +1,27 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns
DEPENDENCIES = ["bq25186"]
CONF_PG_GPO = "pg_gpo"
BQ25186PgGpoSwitch = bq25186_ns.class_("BQ25186PgGpoSwitch", switch.Switch)
CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend(
{
cv.Required(CONF_PG_GPO): switch.switch_schema(
BQ25186PgGpoSwitch, default_restore_mode="DISABLED"
),
}
)
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_BQ25186_PG_GPO")
hub = await cg.get_variable(config[CONF_BQ25186_ID])
switches = switch.sub_switches(config, parent=hub)
await switches(CONF_PG_GPO, hub.set_pg_gpo_switch)
+38
View File
@@ -0,0 +1,38 @@
from functools import partial
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_text_sensor
DEPENDENCIES = ["bq25186"]
BQ25186TextSensor = bq25186_ns.class_("BQ25186TextSensor", text_sensor.TextSensor)
REG_STAT0 = 0
REG_STAT1 = 1
# (key, status register, bit shift, index of the field's value 0 in the C++ string table)
_FIELDS = (
("charge_status", REG_STAT0, 5, 0),
("ts_status", REG_STAT1, 3, 4),
)
CONFIG_SCHEMA = cv.All(
BQ25186_CHILD_SCHEMA.extend(
{
cv.Optional(key): text_sensor.text_sensor_schema(BQ25186TextSensor)
for key, *_ in _FIELDS
}
),
cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_BQ25186_ID])
sensors = text_sensor.sub_text_sensors(config)
for key, reg, shift, first in _FIELDS:
await sensors(key, partial(register_text_sensor, hub), reg, shift, first)
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@rggammon"]
DOMAIN = "bq27220"
+93
View File
@@ -0,0 +1,93 @@
#include "bq27220.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::bq27220 {
ESPHOME_LOG_TAG(TAG, "bq27220");
// Standard command registers (16-bit, little-endian), SLUUBD4A
static constexpr uint8_t REG_CONTROL = 0x00;
static constexpr uint8_t REG_TEMPERATURE = 0x06; // 0.1 K
static constexpr uint8_t REG_VOLTAGE = 0x08; // mV
static constexpr uint8_t REG_CURRENT = 0x0C; // mA, signed
static constexpr uint8_t REG_REMAINING_CAPACITY = 0x10; // mAh
static constexpr uint8_t REG_FULL_CHARGE_CAPACITY = 0x12; // mAh
static constexpr uint8_t REG_TIME_TO_EMPTY = 0x16; // min, 0xFFFF when not discharging
static constexpr uint8_t REG_STATE_OF_CHARGE = 0x2C; // %
static constexpr uint8_t REG_STATE_OF_HEALTH = 0x2E; // %
// The DEVICE_NUMBER subcommand (0x0001, SLUUBD4A §2.2.2) answers through the MAC data buffer
static constexpr uint8_t REG_MAC_DATA = 0x40;
static constexpr uint16_t DEVICE_NUMBER = 0x0220;
static constexpr uint16_t TIME_TO_EMPTY_NOT_APPLICABLE = 0xFFFF;
bool BQ27220Component::read_word_(uint8_t reg, uint16_t &value) {
uint8_t data[2];
if (this->read_register(reg, data, 2) != i2c::ERROR_OK) {
return false;
}
value = encode_uint16(data[1], data[0]);
return true;
}
void BQ27220Component::setup() {
const uint8_t device_number_cmd[2] = {0x01, 0x00}; // DEVICE_NUMBER subcommand, little-endian
if (this->write_register(REG_CONTROL, device_number_cmd, 2) != i2c::ERROR_OK) {
this->mark_failed(LOG_STR("Failed to communicate with BQ27220"));
return;
}
delay(2); // SLUUBD4A §5.3: the subcommand result needs a moment before it can be read
uint16_t device_number = 0;
if (!this->read_word_(REG_MAC_DATA, device_number)) {
this->mark_failed(LOG_STR("Failed to read device number from BQ27220"));
return;
}
if (device_number != DEVICE_NUMBER) {
ESP_LOGE(TAG, "Unexpected device number 0x%04X (expected 0x%04X)", device_number, DEVICE_NUMBER);
this->mark_failed();
}
}
void BQ27220Component::update() {
bool ok = true;
this->publish_(this->voltage_sensor_, REG_VOLTAGE, ok, [](uint16_t raw) { return raw * 0.001f; });
this->publish_(this->current_sensor_, REG_CURRENT, ok,
[](uint16_t raw) { return static_cast<int16_t>(raw) * 0.001f; });
this->publish_(this->battery_level_sensor_, REG_STATE_OF_CHARGE, ok, [](uint16_t raw) { return float(raw); });
this->publish_(this->temperature_sensor_, REG_TEMPERATURE, ok, [](uint16_t raw) { return raw * 0.1f - 273.15f; });
this->publish_(this->remaining_capacity_sensor_, REG_REMAINING_CAPACITY, ok, [](uint16_t raw) { return float(raw); });
this->publish_(this->full_charge_capacity_sensor_, REG_FULL_CHARGE_CAPACITY, ok,
[](uint16_t raw) { return float(raw); });
this->publish_(this->time_to_empty_sensor_, REG_TIME_TO_EMPTY, ok,
[](uint16_t raw) { return raw == TIME_TO_EMPTY_NOT_APPLICABLE ? NAN : float(raw); });
this->publish_(this->state_of_health_sensor_, REG_STATE_OF_HEALTH, ok, [](uint16_t raw) { return float(raw); });
if (ok) {
this->status_clear_warning();
} else {
this->status_set_warning(LOG_STR("Failed to read one or more registers from BQ27220"));
}
}
void BQ27220Component::dump_config() {
ESP_LOGCONFIG(TAG, "BQ27220 Battery Fuel Gauge:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Voltage", this->voltage_sensor_);
LOG_SENSOR(" ", "Current", this->current_sensor_);
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Remaining Capacity", this->remaining_capacity_sensor_);
LOG_SENSOR(" ", "Full Charge Capacity", this->full_charge_capacity_sensor_);
LOG_SENSOR(" ", "Time To Empty", this->time_to_empty_sensor_);
LOG_SENSOR(" ", "State Of Health", this->state_of_health_sensor_);
}
} // namespace esphome::bq27220
+44
View File
@@ -0,0 +1,44 @@
#pragma once
// Datasheet: https://www.ti.com/lit/ds/symlink/bq27220.pdf
// Technical Reference Manual: https://www.ti.com/lit/ug/sluubd4a/sluubd4a.pdf
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::bq27220 {
class BQ27220Component final : public PollingComponent, public i2c::I2CDevice {
SUB_SENSOR(voltage)
SUB_SENSOR(current)
SUB_SENSOR(battery_level)
SUB_SENSOR(temperature)
SUB_SENSOR(remaining_capacity)
SUB_SENSOR(full_charge_capacity)
SUB_SENSOR(time_to_empty)
SUB_SENSOR(state_of_health)
public:
void setup() override;
void update() override;
void dump_config() override;
protected:
bool read_word_(uint8_t reg, uint16_t &value);
/// Read a standard command register and publish it through the sensor, if one is configured.
/// A failed read publishes NAN and clears `ok`.
template<typename F> void publish_(sensor::Sensor *sensor, uint8_t reg, bool &ok, F convert) {
if (sensor == nullptr)
return;
uint16_t raw;
if (!this->read_word_(reg, raw)) {
ok = false;
sensor->publish_state(NAN);
return;
}
sensor->publish_state(convert(raw));
}
};
} // namespace esphome::bq27220
+123
View File
@@ -0,0 +1,123 @@
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.const import UNIT_MILLIAMPERE_HOUR
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_CURRENT,
CONF_ID,
CONF_TEMPERATURE,
CONF_VOLTAGE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_BATTERY,
STATE_CLASS_MEASUREMENT,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_MINUTE,
UNIT_PERCENT,
UNIT_VOLT,
)
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
bq27220_ns = cg.esphome_ns.namespace("bq27220")
BQ27220Component = bq27220_ns.class_(
"BQ27220Component", cg.PollingComponent, i2c.I2CDevice
)
CONF_REMAINING_CAPACITY = "remaining_capacity"
CONF_FULL_CHARGE_CAPACITY = "full_charge_capacity"
CONF_TIME_TO_EMPTY = "time_to_empty"
CONF_STATE_OF_HEALTH = "state_of_health"
# Other SLUUBD4A standard commands are intentionally not exposed to keep the
# component focused; they can be added later if there is demand: TimeToFull
# (0x18), CycleCount (0x2A), BatteryStatus (0x0A), AveragePower (0x24),
# DesignCapacity (0x3C).
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BQ27220Component),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_CURRENT): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_REMAINING_CAPACITY): sensor.sensor_schema(
unit_of_measurement=UNIT_MILLIAMPERE_HOUR,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
icon=ICON_BATTERY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_FULL_CHARGE_CAPACITY): sensor.sensor_schema(
unit_of_measurement=UNIT_MILLIAMPERE_HOUR,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
icon=ICON_BATTERY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_TIME_TO_EMPTY): sensor.sensor_schema(
unit_of_measurement=UNIT_MINUTE,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_STATE_OF_HEALTH): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
icon=ICON_BATTERY,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x55))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_CURRENT, var.set_current_sensor)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_REMAINING_CAPACITY, var.set_remaining_capacity_sensor)
await sensors(CONF_FULL_CHARGE_CAPACITY, var.set_full_charge_capacity_sensor)
await sensors(CONF_TIME_TO_EMPTY, var.set_time_to_empty_sensor)
await sensors(CONF_STATE_OF_HEALTH, var.set_state_of_health_sensor)
+111
View File
@@ -0,0 +1,111 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_ID,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_VOLT,
)
from esphome.types import ConfigType
AUTO_LOAD = ["sensor"]
CODEOWNERS = ["@limpkin"]
DEPENDENCIES = ["i2c"]
DOMAIN = "bq76972"
MULTI_CONF = True
bq76972_ns = cg.esphome_ns.namespace("bq76972")
BQ76972Component = bq76972_ns.class_(
"BQ76972Component", cg.PollingComponent, i2c.I2CDevice
)
CONF_REG_DISABLED = "reg_disabled"
CONF_CRC_ENABLED = "crc_enabled"
CONF_STACK_VOLTAGE = "stack_voltage"
# Ordered as the chip reports them, starting at command 0x68
CONF_TEMPERATURES = [
"internal_temperature",
"cfetoff_temperature",
"dfetoff_temperature",
"alert_temperature",
"ts1_temperature",
"ts2_temperature",
"ts3_temperature",
"hdq_temperature",
"dchg_temperature",
"ddsg_temperature",
]
CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)]
voltage_sensor_schema = sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
)
temp_sensor_schema = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BQ76972Component),
cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean,
cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean,
cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema,
**{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES},
**{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES},
}
)
.extend(i2c.i2c_device_schema(0x08))
.extend(cv.polling_component_schema("10s"))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED]))
cg.add(var.set_reg_disable(config[CONF_REG_DISABLED]))
sensors = sensor.sub_sensors(config)
await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor)
for idx, key in enumerate(CONF_TEMPERATURES):
if (conf := config.get(key)) is not None:
sens = await sensor.new_sensor(conf)
cg.add(var.set_temperature_sensor(sens, idx))
for idx, key in enumerate(CONF_CELL_VOLTAGES):
if (conf := config.get(key)) is not None:
sens = await sensor.new_sensor(conf)
cg.add(var.set_cell_sensor(sens, idx))
BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(BQ76972Component),
cv.Required(CONF_ADDRESS): cv.templatable(cv.i2c_address),
},
key=CONF_ADDRESS,
)
automation.register_apply_action(
"bq76972.program_i2c_address",
BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA,
automation.ApplyField(CONF_ADDRESS, "program_i2c_address", cg.uint8),
)
+298
View File
@@ -0,0 +1,298 @@
#include "bq76972.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::bq76972 {
ESPHOME_LOG_TAG(TAG, "bq76972");
static constexpr uint8_t BQ76972_REG_SUBCOMMAND = 0x3E; // 0x3E low byte, 0x3F high byte
static constexpr uint8_t BQ76972_REG_TRANSFER_BUFFER = 0x40;
static constexpr uint8_t BQ76972_REG_CHECKSUM = 0x60; // 0x60 checksum, 0x61 length
static constexpr uint8_t BQ76972_CMD_BAT_STATUS = 0x12;
static constexpr uint8_t BQ76972_CMD_CELL_START = 0x14;
static constexpr uint8_t BQ76972_CMD_STACK_VOLTAGE = 0x34;
static constexpr uint8_t BQ76972_CMD_TEMP_START = 0x68;
static constexpr uint16_t BQ76972_SUBCMD_ENTER_CFG_UPDATE = 0x0090;
static constexpr uint16_t BQ76972_SUBCMD_EXIT_CFG_UPDATE = 0x0092;
static constexpr uint16_t BQ76972_MEM_REG12_CONFIG = 0x9236;
static constexpr uint16_t BQ76972_MEM_REG0_CONFIG = 0x9237;
static constexpr uint16_t BQ76972_MEM_I2C_ADDRESS = 0x923A;
static constexpr uint16_t BQ76972_MEM_SWAP_COMM_MODE = 0x29BC;
// Pin config of temperature reading N (1 = CFETOFF ... 9 = DDSG) is at this base + N
static constexpr uint16_t BQ76972_MEM_TEMP_PIN_CONFIG_BASE = 0x92F9;
static constexpr size_t BQ76972_TS1_INDEX = 4; // a thermistor input by default
static constexpr uint8_t BQ76972_PIN_CONFIG_THERMISTOR = 0x07;
// 16 words with a CRC byte after every data byte
static constexpr size_t BQ76972_MAX_TRANSFER = 64;
static constexpr uint8_t BQ76972_MAX_POLLS = 50;
static float raw_to_celsius(uint16_t raw) { return static_cast<int16_t>(raw) * 0.1f - 273.15f; }
bool BQ76972Component::read_block_(uint8_t start_register, uint8_t *data, size_t len) {
if (!this->crc_mode_)
return this->read_bytes(start_register, data, len);
// With CRC enabled, every data byte is followed by a CRC byte
std::array<uint8_t, BQ76972_MAX_TRANSFER> buffer;
if (len == 0 || len * 2 > buffer.size())
return false;
if (!this->read_bytes(start_register, buffer.data(), len * 2))
return false;
// The first CRC also covers the write address, register and read address
const uint8_t crc0_input[4] = {static_cast<uint8_t>(this->address_ << 1), start_register,
static_cast<uint8_t>((this->address_ << 1) | 1), buffer[0]};
if (crc8(crc0_input, 4, 0x00, 0x07, true) != buffer[1]) {
ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte 0", start_register);
return false;
}
data[0] = buffer[0];
for (size_t i = 1; i < len; ++i) {
if (crc8(&buffer[i * 2], 1, 0x00, 0x07, true) != buffer[i * 2 + 1]) {
ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte %u", start_register, static_cast<unsigned>(i));
return false;
}
data[i] = buffer[i * 2];
}
return true;
}
bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, size_t len) {
if (!this->crc_mode_)
return this->write_bytes(start_register, data, len);
std::array<uint8_t, BQ76972_MAX_TRANSFER> buffer;
if (len == 0 || len * 2 > buffer.size())
return false;
// The first CRC also covers the write address and register
const uint8_t crc0_input[3] = {static_cast<uint8_t>(this->address_ << 1), start_register, data[0]};
buffer[0] = data[0];
buffer[1] = crc8(crc0_input, 3, 0x00, 0x07, true);
for (size_t i = 1; i < len; ++i) {
buffer[i * 2] = data[i];
buffer[i * 2 + 1] = crc8(&data[i], 1, 0x00, 0x07, true);
}
return this->write_bytes(start_register, buffer.data(), len * 2);
}
bool BQ76972Component::read_words_(uint8_t start_register, uint16_t *data, size_t num_words) {
std::array<uint8_t, BQ76972_MAX_TRANSFER / 2> bytes;
if (num_words * 2 > bytes.size() || !this->read_block_(start_register, bytes.data(), num_words * 2))
return false;
for (size_t i = 0; i < num_words; i++)
data[i] = encode_uint16(bytes[2 * i + 1], bytes[2 * i]);
return true;
}
bool BQ76972Component::send_subcommand_(uint16_t subcommand) {
const uint8_t bytes[2] = {static_cast<uint8_t>(subcommand & 0xFF), static_cast<uint8_t>(subcommand >> 8)};
return this->write_block_(BQ76972_REG_SUBCOMMAND, bytes, 2);
}
bool BQ76972Component::wait_for_subcommand_() {
uint8_t bytes[2];
for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) {
if (!this->read_block_(BQ76972_REG_SUBCOMMAND, bytes, 2))
return false;
if (bytes[0] != 0xFF && bytes[1] != 0xFF)
return true;
delay(1);
}
return false;
}
bool BQ76972Component::wait_for_cfgupdate_() {
uint16_t status;
for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) {
if (!this->read_words_(BQ76972_CMD_BAT_STATUS, &status, 1))
return false;
if (status & 0x0001)
return true;
delay(1);
}
return false;
}
bool BQ76972Component::read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len) {
return this->send_subcommand_(subcommand) && this->wait_for_subcommand_() &&
this->read_block_(BQ76972_REG_TRANSFER_BUFFER, data, len);
}
bool BQ76972Component::write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len) {
if (!this->send_subcommand_(subcommand))
return false;
if (len == 0)
return true;
if (!this->write_block_(BQ76972_REG_TRANSFER_BUFFER, data, len))
return false;
// Inverted 8-bit sum of 0x3E, 0x3F and the data; length counts 0x3E, 0x3F, 0x60, 0x61 and the data
uint8_t sum = static_cast<uint8_t>(subcommand & 0xFF) + static_cast<uint8_t>(subcommand >> 8);
for (size_t i = 0; i < len; ++i)
sum += data[i];
const uint8_t tail[2] = {static_cast<uint8_t>(~sum), static_cast<uint8_t>(4 + len)};
return this->write_block_(BQ76972_REG_CHECKSUM, tail, 2);
}
bool BQ76972Component::enter_cfg_update_() {
if (!this->write_subcommand_(BQ76972_SUBCMD_ENTER_CFG_UPDATE, nullptr, 0))
return false;
delay(10);
return this->wait_for_cfgupdate_();
}
bool BQ76972Component::exit_cfg_update_() {
return this->write_subcommand_(BQ76972_SUBCMD_EXIT_CFG_UPDATE, nullptr, 0);
}
bool BQ76972Component::regs_disabled_(bool &out) {
uint8_t reg12;
uint8_t reg0;
if (!this->read_subcommand_(BQ76972_MEM_REG12_CONFIG, &reg12, 1) ||
!this->read_subcommand_(BQ76972_MEM_REG0_CONFIG, &reg0, 1))
return false;
out = reg12 == 0 && (reg0 & 0x01) == 0;
return true;
}
const LogString *BQ76972Component::configure_() {
uint16_t internal_temp;
if (!this->read_words_(BQ76972_CMD_TEMP_START, &internal_temp, 1))
return LOG_STR(ESP_LOG_MSG_COMM_FAIL);
// Only write the regulator config when it differs, see
// https://e2e.ti.com/support/power-management-group/power-management/f/power-management-forum/1583718/bq76972-undocumented-4-minute-config_update-auto-exit-timeout
bool regs_disabled = true;
if (this->reg_disable_ && !this->regs_disabled_(regs_disabled))
return LOG_STR("Reading regulator config failed");
const bool write_regs = !regs_disabled;
bool write_thermistors = false;
for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) {
if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr)
write_thermistors = true;
}
if (write_regs || write_thermistors) {
if (!this->enter_cfg_update_())
return LOG_STR("Entering config update mode failed");
const LogString *error = this->write_config_(write_regs);
// The chip stops measuring in config update mode, so leave it even after a failed write
if (!this->exit_cfg_update_() && error == nullptr)
error = LOG_STR("Exiting config update mode failed");
if (error != nullptr)
return error;
if (write_regs && (!this->regs_disabled_(regs_disabled) || !regs_disabled))
return LOG_STR("Disabling regulators failed");
}
ESP_LOGD(TAG, "Internal temperature: %.1f°C", raw_to_celsius(internal_temp));
return nullptr;
}
const LogString *BQ76972Component::write_config_(bool write_regs) {
if (write_regs) {
const uint8_t zero = 0;
if (!this->write_subcommand_(BQ76972_MEM_REG12_CONFIG, &zero, 1) ||
!this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1))
return LOG_STR("Writing regulator config failed");
}
for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) {
if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr)
continue;
if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1))
return LOG_STR("Writing thermistor pin config failed");
}
return nullptr;
}
void BQ76972Component::setup() {
if (const LogString *error = this->configure_(); error != nullptr)
this->mark_failed(error);
}
void BQ76972Component::dump_config() {
ESP_LOGCONFIG(TAG, "BQ76972:");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
ESP_LOGCONFIG(TAG,
" CRC Mode: %s\n"
" Regulators disabled: %s",
this->crc_mode_ ? LOG_STR_LITERAL("Enabled") : LOG_STR_LITERAL("Disabled"), YESNO(this->reg_disable_));
}
void BQ76972Component::program_i2c_address(uint8_t address) {
if (this->is_failed())
return;
const uint8_t shifted = address << 1;
if (!this->enter_cfg_update_()) {
this->status_set_warning(LOG_STR("Programming I2C address failed"));
return;
}
// The new address only takes effect with the swap, so after a failed write the chip still answers at the
// old one and must still be taken out of config update mode
const bool written = this->write_subcommand_(BQ76972_MEM_I2C_ADDRESS, &shifted, 1) &&
this->write_subcommand_(BQ76972_MEM_SWAP_COMM_MODE, nullptr, 0);
if (written) {
delay(10);
this->set_i2c_address(address);
}
if (!this->exit_cfg_update_() || !written) {
this->status_set_warning(LOG_STR("Programming I2C address failed"));
return;
}
ESP_LOGI(TAG, "I2C address set to 0x%02X", address);
}
template<size_t N> static bool any_set(const std::array<sensor::Sensor *, N> &sensors) {
for (auto *sensor : sensors) {
if (sensor != nullptr)
return true;
}
return false;
}
void BQ76972Component::update() {
// Each group publishes as soon as its read succeeds, so one failed read does not hold back the others
bool ok = true;
uint16_t words[BQ76972_NUM_CELLS];
if (this->stack_voltage_sensor_ != nullptr) {
if (this->read_words_(BQ76972_CMD_STACK_VOLTAGE, words, 1)) {
if (words[0] != 0)
this->stack_voltage_sensor_->publish_state(words[0] * 0.01f); // 10 mV units
} else {
ok = false;
}
}
if (any_set(this->cell_sensors_)) {
if (this->read_words_(BQ76972_CMD_CELL_START, words, BQ76972_NUM_CELLS)) {
for (size_t i = 0; i < BQ76972_NUM_CELLS; i++) {
if (this->cell_sensors_[i] != nullptr && words[i] != 0)
this->cell_sensors_[i]->publish_state(words[i] * 0.001f); // mV
}
} else {
ok = false;
}
}
if (any_set(this->temperature_sensors_)) {
if (this->read_words_(BQ76972_CMD_TEMP_START, words, BQ76972_NUM_TEMPERATURES)) {
for (size_t i = 0; i < BQ76972_NUM_TEMPERATURES; i++) {
if (this->temperature_sensors_[i] != nullptr)
this->temperature_sensors_[i]->publish_state(raw_to_celsius(words[i]));
}
} else {
ok = false;
}
}
if (ok) {
this->status_clear_warning();
} else {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
}
}
} // namespace esphome::bq76972
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include <array>
namespace esphome::bq76972 {
static constexpr size_t BQ76972_NUM_CELLS = 16;
static constexpr size_t BQ76972_NUM_TEMPERATURES = 10;
class BQ76972Component final : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void update() override;
void dump_config() override;
void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; }
void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; }
void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; }
void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; }
void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; }
void program_i2c_address(uint8_t address);
protected:
bool read_block_(uint8_t start_register, uint8_t *data, size_t len);
bool write_block_(uint8_t start_register, const uint8_t *data, size_t len);
bool read_words_(uint8_t start_register, uint16_t *data, size_t num_words);
bool send_subcommand_(uint16_t subcommand);
bool read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len);
bool write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len);
bool wait_for_subcommand_();
bool wait_for_cfgupdate_();
bool enter_cfg_update_();
bool exit_cfg_update_();
bool regs_disabled_(bool &out);
const LogString *configure_();
const LogString *write_config_(bool write_regs);
bool reg_disable_{false};
bool crc_mode_{false};
sensor::Sensor *stack_voltage_sensor_{nullptr};
std::array<sensor::Sensor *, BQ76972_NUM_CELLS> cell_sensors_{};
std::array<sensor::Sensor *, BQ76972_NUM_TEMPERATURES> temperature_sensors_{};
};
} // namespace esphome::bq76972
+31 -6
View File
@@ -1,14 +1,17 @@
import logging
import re
from typing import Any
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID
from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM, CONF_TRIGGER_ID
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@mvturnho", "@danielschramm"]
DOMAIN = "canbus"
IS_PLATFORM_COMPONENT = True
@@ -20,6 +23,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request"
CONF_CANBUS_ID = "canbus_id"
CONF_BIT_RATE = "bit_rate"
CONF_ON_FRAME = "on_frame"
CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop"
DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h
DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h
def validate_id(config: ConfigType) -> ConfigType:
@@ -87,8 +93,13 @@ CANBUS_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(CanbusComponent),
cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF),
cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True),
cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum(
CAN_SPEEDS, upper=True
),
cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean,
cv.Optional(
CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP
): cv.positive_not_null_int,
cv.Optional(CONF_ON_FRAME): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger),
@@ -109,9 +120,13 @@ CANBUS_SCHEMA.add_extra(validate_id)
async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None:
await cg.register_component(var, config)
cg.add(var.set_can_id([config[CONF_CAN_ID]]))
cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]]))
cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]]))
cg.add(var.set_can_id(config[CONF_CAN_ID]))
if config[CONF_USE_EXTENDED_ID]:
cg.add(var.set_use_extended_id(True))
if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE:
cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate]))
if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP:
cg.add(var.set_max_frames_per_loop(max_frames))
for conf in config.get(CONF_ON_FRAME, []):
can_id = conf[CONF_CAN_ID]
@@ -138,9 +153,19 @@ async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None:
)
async def register_canbus(var: MockObj, config: ConfigType) -> None:
async def register_canbus(
var: MockObj, config: ConfigType, *, reports_status: bool | None = None
) -> None:
if not CORE.has_id(config[CONF_ID]):
var = cg.new_Pvariable(config[CONF_ID], var)
if reports_status is None:
_LOGGER.warning(
"CAN bus platform '%s' does not report bus state, so bus-off recovery and "
"error-state logging are disabled. Platform authors should either override "
"get_status() and get_events() and call register_canbus(..., reports_status=True) "
"or pass reports_status=False if the hardware cannot report it",
config[CONF_PLATFORM],
)
await setup_canbus_core_(var, config)
+73 -7
View File
@@ -1,5 +1,6 @@
#include "canbus.h"
#include <algorithm>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::canbus {
@@ -65,17 +66,25 @@ void Canbus::add_trigger(CanbusTrigger *trigger) {
};
void Canbus::loop() {
uint32_t now = App.get_loop_component_start_time();
if ((now - this->last_event_check_time_) >= EVENT_CHECK_INTERVAL_MS) {
this->log_events_(this->get_events(), now);
this->last_event_check_time_ = now;
}
struct CanFrame can_message;
// read all messages until queue is empty
int message_counter = 0;
while (this->read_message(&can_message) == canbus::ERROR_OK) {
message_counter++;
uint32_t message_counter = 0;
for (; message_counter < this->max_frames_per_loop_; message_counter++) {
if (this->read_message(&can_message) != canbus::ERROR_OK) {
break;
}
if (can_message.use_extended_id) {
ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter,
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
} else {
ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id,
can_message.can_data_length_code);
ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1,
can_message.can_id, can_message.can_data_length_code);
}
std::vector<uint8_t> data;
@@ -99,6 +108,63 @@ void Canbus::loop() {
}
}
}
if (message_counter == this->max_frames_per_loop_) {
ESP_LOGW(TAG,
"Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped",
this->max_frames_per_loop_);
}
}
void Canbus::log_events_(CanEventFlags events, uint32_t now) {
// Bus-off gets its own handling because automatic recovery can make it flap
if (events & CanEventFlags::CAN_EVENT_BUS_OFF) {
this->last_bus_off_time_ = now;
if (!this->bus_off_) {
this->bus_off_ = true;
this->status_set_warning(LOG_STR("Bus off"));
}
} else if (this->bus_off_) {
if ((now - this->last_bus_off_time_) >= EVENT_LOG_BUS_OFF_HOLDOFF_MS) {
// No bus-off event for the hold-off time, so ask whether it is really over
if (this->get_status().bus_off) {
this->last_bus_off_time_ = now;
} else {
this->bus_off_ = false;
this->status_clear_warning();
// Events gathered while bus-off say nothing about the recovered bus
this->events_to_log_ = 0;
}
}
} else {
this->events_to_log_ |= events;
if (this->events_to_log_ != 0 && (now - this->last_event_log_time_) >= EVENT_LOG_THROTTLE_MS) {
if (this->events_to_log_ & (CanEventFlags::CAN_EVENT_PASSIVE | CanEventFlags::CAN_EVENT_ACTIVE)) {
bool error_passive = this->events_to_log_ & CanEventFlags::CAN_EVENT_PASSIVE;
if (error_passive && (this->events_to_log_ & CanEventFlags::CAN_EVENT_ACTIVE)) {
// Both edges within one throttle window, so the counters decide the current state
auto status = this->get_status();
error_passive = status.rx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD ||
status.tx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD;
}
ESP_LOGW(TAG, "entered %s", error_passive ? LOG_STR_LITERAL("error-passive") : LOG_STR_LITERAL("error-active"));
}
if (this->events_to_log_ & CanEventFlags::CAN_EVENT_RX_QUEUE_FULL) {
ESP_LOGD(TAG, "receive buffer overrun");
}
this->events_to_log_ = 0;
this->last_event_log_time_ = now;
}
}
#ifdef ESPHOME_LOG_HAS_VERBOSE
if ((now - this->last_state_log_time_) >= STATE_LOG_INTERVAL_MS) {
auto status = this->get_status();
ESP_LOGV(TAG, "Status: bus_off %d, tx_err %d, rx_err %d", status.bus_off, status.tx_error_counter,
status.rx_error_counter);
this->last_state_log_time_ = now;
}
#endif
}
} // namespace esphome::canbus
+48 -2
View File
@@ -48,6 +48,8 @@ template<typename... Ts> class CanbusSendAction;
/* CAN payload length definitions according to ISO 11898-1 */
static const uint8_t CAN_MAX_DATA_LENGTH = 8;
static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128;
/*
Can Frame describes a normative CAN Frame
The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used
@@ -61,6 +63,26 @@ struct CanFrame {
uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8)));
};
enum CanEventFlags : uint8_t {
CAN_EVENT_PASSIVE = 1 << 0,
CAN_EVENT_ACTIVE = 1 << 1,
CAN_EVENT_BUS_OFF = 1 << 2,
CAN_EVENT_RX_QUEUE_FULL = 1 << 3,
};
static constexpr uint32_t CAN_STATUS_UNSET = UINT32_MAX;
struct CanStatus {
bool bus_off{false};
uint8_t rx_error_counter{0};
uint8_t tx_error_counter{0};
uint32_t tx_failed_count{CAN_STATUS_UNSET};
uint32_t rx_missed_count{CAN_STATUS_UNSET};
uint32_t rx_overrun_count{CAN_STATUS_UNSET};
uint32_t arb_lost_count{CAN_STATUS_UNSET};
uint32_t bus_error_count{CAN_STATUS_UNSET};
};
class Canbus : public Component {
public:
Canbus(){};
@@ -82,6 +104,8 @@ class Canbus : public Component {
void set_can_id(uint32_t can_id) { this->can_id_ = can_id; }
void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; }
void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; }
void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; }
virtual CanStatus get_status() { return {}; }
void add_trigger(CanbusTrigger *trigger);
/**
@@ -100,14 +124,36 @@ class Canbus : public Component {
template<typename... Ts> friend class CanbusSendAction;
std::vector<CanbusTrigger *> triggers_{};
uint32_t can_id_;
bool use_extended_id_;
CanSpeed bit_rate_;
uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py
CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py
bool use_extended_id_{false};
uint8_t events_to_log_{0};
bool bus_off_{false};
CallbackManager<void(uint32_t can_id, bool extended_id, bool rtr, const std::vector<uint8_t> &data)>
callback_manager_{};
// interval in which to check for new can bus events (bus-off, passive, active)
static constexpr uint32_t EVENT_CHECK_INTERVAL_MS = 100;
uint32_t last_event_check_time_{0};
// time to wait after logging an event before logging the next event(s)
static constexpr uint32_t EVENT_LOG_THROTTLE_MS = 1000;
uint32_t last_event_log_time_{0};
// time span without bus-off events before the bus is declared recovered
static constexpr uint32_t EVENT_LOG_BUS_OFF_HOLDOFF_MS = 1000;
uint32_t last_bus_off_time_{0};
#ifdef ESPHOME_LOG_HAS_VERBOSE
static constexpr uint32_t STATE_LOG_INTERVAL_MS = 1000;
uint32_t last_state_log_time_{0};
#endif
void log_events_(CanEventFlags events, uint32_t now);
virtual bool setup_internal() = 0;
virtual Error send_message(struct CanFrame *frame) = 0;
virtual Error read_message(struct CanFrame *frame) = 0;
virtual CanEventFlags get_events() { return CanEventFlags{}; }
};
template<typename... Ts> class CanbusSendAction final : public Action<Ts...>, public Parented<Canbus> {
+2 -26
View File
@@ -4,7 +4,6 @@ import esphome.codegen as cg
from esphome.components import web_server_base, wifi
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
from esphome.components.wifi import DOMAIN as WIFI_DOMAIN
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_AP,
@@ -16,7 +15,6 @@ from esphome.const import (
PLATFORM_LN882X,
PLATFORM_RP2,
PLATFORM_RTL87XX,
PlatformFramework,
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
@@ -78,17 +76,7 @@ def _final_validate(config: ConfigType) -> None:
"Add 'ap:' to your WiFi configuration to enable the captive portal."
)
# Register socket needs for DNS server and additional HTTP connections
# - 1 UDP socket for DNS server
# - 3 TCP sockets for captive portal detection probes + configuration requests
# OS captive portal detection makes multiple probe requests that stay in TIME_WAIT.
# Need headroom for actual user configuration requests.
# LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts.
# The listening socket is registered by web_server_base (shared HTTP server).
from esphome.components import socket
socket.consume_sockets(3, "captive_portal")(config)
socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config)
web_server_base.consume_captive_dns_sockets(config, DOMAIN)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -108,16 +96,4 @@ async def to_code(config: ConfigType) -> None:
if config[CONF_COMPRESSION] == "gzip":
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2):
cg.add_library("DNSServer", None)
# Only compile the ESP-IDF DNS server when using ESP-IDF framework
FILTER_SOURCE_FILES = filter_source_files_from_platform(
{
"dns_server_esp32_idf.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
}
)
web_server_base.add_captive_dns_library()
@@ -6,9 +6,6 @@
#include "esphome/core/string_ref.h"
#include "esphome/components/wifi/scan_list.h"
#include "esphome/components/wifi/wifi_component.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#include "captive_index.h"
namespace esphome::captive_portal {
@@ -81,20 +78,6 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
void CaptivePortal::setup() {
// Disable loop by default - will be enabled when captive portal starts
this->disable_loop();
#ifdef USE_PROVISIONING
// The captive portal is a provisioning surface: once the provisioning window
// has closed, stop serving it. WiFi's own closed-callback shuts down the
// access point the portal runs on, and the gated fallback in WiFiComponent's
// loop() ensures neither is started again afterwards.
if (provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
if (this->active_) {
ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal");
this->end();
}
});
}
#endif
}
void CaptivePortal::start() {
this->base_->init();
@@ -102,17 +85,7 @@ void CaptivePortal::start() {
this->base_->add_handler_without_auth(this);
}
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();
#if defined(USE_ESP32)
// Create DNS server instance for ESP-IDF
this->dns_server_ = make_unique<DNSServer>();
this->dns_server_->start(ip);
#elif defined(USE_ARDUINO)
this->dns_server_ = make_unique<DNSServer>();
this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError);
this->dns_server_->start(53, ESPHOME_F("*"), ip);
#endif
this->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip());
this->initialized_ = true;
this->active_ = true;
@@ -1,16 +1,11 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_CAPTIVE_PORTAL
#include <memory>
#if defined(USE_ESP32)
#include "dns_server_esp32_idf.h"
#elif defined(USE_ARDUINO)
#include <DNSServer.h>
#endif
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/components/web_server_base/web_server_base.h"
#include "esphome/components/web_server_base/captive_dns.h"
namespace esphome::captive_portal {
@@ -19,17 +14,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
CaptivePortal(web_server_base::WebServerBase *base);
void setup() override;
void dump_config() override;
void loop() override {
#if defined(USE_ESP32)
if (this->dns_server_ != nullptr) {
this->dns_server_->process_next_request();
}
#elif defined(USE_ARDUINO)
if (this->dns_server_ != nullptr) {
this->dns_server_->processNextRequest();
}
#endif
}
void loop() override { this->dns_.loop(); }
float get_setup_priority() const override;
void start();
bool is_active() const { return this->active_; }
@@ -37,10 +22,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
this->active_ = false;
this->disable_loop(); // Stop processing DNS requests
this->base_->deinit();
if (this->dns_server_ != nullptr) {
this->dns_server_->stop();
this->dns_server_ = nullptr;
}
this->dns_.stop();
}
bool canHandle(AsyncWebServerRequest *request) const override {
@@ -60,9 +42,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component {
web_server_base::WebServerBase *base_;
bool initialized_{false};
bool active_{false};
#if defined(USE_ARDUINO) || defined(USE_ESP32)
std::unique_ptr<DNSServer> dns_server_{nullptr};
#endif
web_server_base::CaptiveDNS dns_;
};
extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
+13 -30
View File
@@ -46,7 +46,6 @@ from esphome.const import (
CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC,
CONF_TARGET_TEMPERATURE_STATE_TOPIC,
CONF_TEMPERATURE_STEP,
CONF_TRIGGER_ID,
CONF_VISUAL,
CONF_WEB_SERVER,
)
@@ -164,14 +163,6 @@ def visual_temperature_step(value: Any) -> ConfigType:
)
# Actions
StateTrigger = climate_ns.class_(
"StateTrigger", automation.Trigger.template(Climate.operator("ref"))
)
ControlTrigger = climate_ns.class_(
"ControlTrigger", automation.Trigger.template(ClimateCall.operator("ref"))
)
_CLIMATE_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
@@ -250,16 +241,8 @@ _CLIMATE_SCHEMA = (
cv.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.publish_topic
),
cv.Optional(CONF_ON_CONTROL): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ControlTrigger),
}
),
cv.Optional(CONF_ON_STATE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger),
}
),
cv.Optional(CONF_ON_CONTROL): automation.validate_automation(),
cv.Optional(CONF_ON_STATE): automation.validate_automation(),
}
)
)
@@ -288,6 +271,16 @@ def climate_schema(
return _CLIMATE_SCHEMA.extend(schema)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_STATE, "add_on_state_callback", [(Climate.operator("ref"), "x")]
),
automation.CallbackAutomation(
CONF_ON_CONTROL, "add_on_control_callback", [(ClimateCall.operator("ref"), "x")]
),
)
@setup_entity("climate")
async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
visual = config.get(CONF_VISUAL, {})
@@ -443,17 +436,7 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
)
)
for conf in config.get(CONF_ON_STATE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(Climate.operator("ref"), "x")], conf
)
for conf in config.get(CONF_ON_CONTROL, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(ClimateCall.operator("ref"), "x")], conf
)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
-22
View File
@@ -1,22 +0,0 @@
#pragma once
#include "esphome/core/automation.h"
#include "climate.h"
namespace esphome::climate {
class ControlTrigger final : public Trigger<ClimateCall &> {
public:
ControlTrigger(Climate *climate) {
climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); });
}
};
class StateTrigger final : public Trigger<Climate &> {
public:
StateTrigger(Climate *climate) {
climate->add_on_state_callback([this](Climate &x) { this->trigger(x); });
}
};
} // namespace esphome::climate
+11
View File
@@ -8,12 +8,17 @@ BYTE_ORDER_BIG = "big_endian"
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_ADC_AVERAGING = "adc_averaging"
CONF_ADC_TIME = "adc_time"
CONF_ALLOWED_IPS = "allowed_ips"
CONF_AQI = "aqi"
CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration"
CONF_B_CONSTANT = "b_constant"
CONF_BLUE_CHANNEL = "blue_channel"
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
CONF_BYTE_ORDER = "byte_order"
CONF_CHANNEL_COLORS = "channel_colors"
CONF_CLEAR_CHANNEL = "clear_channel"
CONF_CLIMATE_ID = "climate_id"
CONF_CO2_EQUIVALENT = "co2_equivalent"
CONF_COLOR_DEPTH = "color_depth"
@@ -26,6 +31,7 @@ CONF_DESCRIPTION = "description"
CONF_DRAW_ROUNDING = "draw_rounding"
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection"
CONF_ENABLED = "enabled"
CONF_GREEN_CHANNEL = "green_channel"
CONF_GYROSCOPE_ODR = "gyroscope_odr"
CONF_GYROSCOPE_RANGE = "gyroscope_range"
CONF_HOLD_STATE = "hold_state"
@@ -44,19 +50,23 @@ CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive"
CONF_ON_SCAN_END = "on_scan_end"
CONF_ON_STATE_CHANGE = "on_state_change"
CONF_ON_STOP = "on_stop"
CONF_PARITY = "parity"
CONF_RECEIVER_FREQUENCY = "receiver_frequency"
CONF_RECONNECT_INTERVAL = "reconnect_interval"
CONF_RED_CHANNEL = "red_channel"
CONF_REQUEST_HEADERS = "request_headers"
CONF_ROLE = "role"
CONF_ROWS = "rows"
CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_SERIAL_NUMBER = "serial_number"
CONF_SHA256 = "sha256"
CONF_SLOT = "slot"
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
CONF_STOP_BITS = "stop_bits"
CONF_TARGET_COUNT = "target_count"
CONF_USE_PSRAM = "use_psram"
CONF_VERIFY_SSL = "verify_ssl"
CONF_VOC_INDEX = "voc_index"
CONF_VOLUME_INCREMENT = "volume_increment"
CONF_VOLUME_INITIAL = "volume_initial"
@@ -72,3 +82,4 @@ KEY_METADATA = "metadata"
UNIT_AMPERE_HOUR = "Ah"
UNIT_COUNTS = "#"
UNIT_MILLIAMPERE_HOUR = "mAh"
+99 -34
View File
@@ -18,21 +18,40 @@ static constexpr int CSE7761_UREF = 42563; // RmsUc
static constexpr int CSE7761_IREF = 52241; // RmsIAC
static constexpr int CSE7761_PREF = 44513; // PowerPAC
// Empirical Sonoff POW CT factor taken from Tasmota
static constexpr uint32_t CURRENT_TRANSFORMER_COEFFICIENT_SCALE = 5;
// PGA 16x on both current channels
static constexpr uint16_t SYSCON_SHUNT = 0xFF04;
// All PGAs 1x, ADC2ON
static constexpr uint16_t SYSCON_CURRENT_TRANSFORMER = 0xFE00;
// System clock (3.579545MHz) / 8, divided by Ufreq gives the line frequency in Hz
static constexpr float CSE7761_UFREQ_CLOCK = 3579545.0f / 8.0f;
static constexpr uint8_t CSE7761_REG_SYSCON = 0x00; // (2) System Control Register (0x0A04)
static constexpr uint8_t CSE7761_REG_EMUCON = 0x01; // (2) Metering control register (0x0000)
static constexpr uint8_t CSE7761_REG_HFCONST = 0x02; // (2) Pulse frequency register (0x1000)
static constexpr uint8_t CSE7761_REG_EMUCON2 = 0x13; // (2) Metering control register 2 (0x0001)
static constexpr uint8_t CSE7761_REG_PULSE1SEL = 0x1D; // (2) Pin function output select register (0x3210)
static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000)
static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000)
static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000)
static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000)
static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000)
static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register
static constexpr uint8_t CSE7761_REG_UFREQ = 0x23; // (2) Voltage frequency (0x0000)
static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000)
static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000)
static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000)
static constexpr uint8_t CSE7761_REG_POWERFACTOR = 0x27; // (3) Power factor of the selected channel (0x7FFFFF)
static constexpr uint8_t CSE7761_REG_ENERGY_PA = 0x28; // (3) Channel A active energy pulse count (0x000000)
static constexpr uint8_t CSE7761_REG_ENERGY_PB = 0x29; // (3) Channel B active energy pulse count (0x000000)
static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000)
static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000)
static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register
static constexpr uint8_t CSE7761_REG_COEFFCHKSUM = 0x6F; // (2) Coefficient checksum
static constexpr uint8_t CSE7761_REG_RMSIAC = 0x70; // (2) Channel A effective current conversion coefficient
// Datasheet: Wh = pulses * EnergyXC * HFConst * 1000 / 2^41 with K1 = K2 = 1
static constexpr float CSE7761_ENERGY_WH_SCALE = 1000.0f / 2199023255552.0f;
static constexpr uint8_t CSE7761_SPECIAL_COMMAND = 0xEA; // Start special command
static constexpr uint8_t CSE7761_CMD_RESET = 0x96; // Reset command, after receiving the command, the chip resets
static constexpr uint8_t CSE7761_CMD_CLOSE_WRITE = 0xDC; // Close write operation
@@ -57,7 +76,18 @@ void CSE7761Component::dump_config() {
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
ESP_LOGCONFIG(TAG, " Current input: %s",
LOG_STR_ARG(this->is_current_transformer_() ? LOG_STR("current transformer") : LOG_STR("shunt")));
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Voltage", this->voltage_sensor_);
LOG_SENSOR(" ", "Current 1", this->current_sensor_1_);
LOG_SENSOR(" ", "Current 2", this->current_sensor_2_);
LOG_SENSOR(" ", "Power 1", this->power_sensor_1_);
LOG_SENSOR(" ", "Power 2", this->power_sensor_2_);
LOG_SENSOR(" ", "Frequency", this->frequency_sensor_);
LOG_SENSOR(" ", "Power Factor 1", this->power_factor_sensor_1_);
LOG_SENSOR(" ", "Energy 1", this->energy_sensors_[0]);
LOG_SENSOR(" ", "Energy 2", this->energy_sensors_[1]);
}
void CSE7761Component::update() {
@@ -73,7 +103,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) {
buffer[1] = reg;
uint32_t len = 2;
if (data) {
if (data < 0xFF) {
if (data <= 0xFF) {
buffer[2] = data & 0xFF;
len = 3;
} else {
@@ -81,11 +111,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) {
buffer[3] = data & 0xFF;
len = 4;
}
uint8_t crc = 0;
for (uint32_t i = 0; i < len; i++) {
crc += buffer[i];
}
buffer[len] = ~crc;
buffer[len] = ~sum_bytes(buffer, len);
len++;
}
@@ -115,46 +141,44 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) {
}
rcvd--;
if (static_cast<uint8_t>(~sum_bytes(buffer, rcvd, 0xA5 + reg)) != buffer[rcvd]) {
return false;
}
uint32_t result = 0;
// CRC check
uint8_t crc = 0xA5 + reg;
for (uint32_t i = 0; i < rcvd; i++) {
result = (result << 8) | buffer[i];
crc += buffer[i];
}
crc = ~crc;
if (crc != buffer[rcvd]) {
return false;
}
*value = result;
return true;
}
uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) {
bool result = false; // Start loop
uint8_t retry = 3; // Retry up to three times
uint32_t value = 0; // Default no value
while (!result && retry > 0) {
retry--;
if (this->read_once_(reg, size, &value))
return value;
bool CSE7761Component::read_(uint8_t reg, uint8_t size, uint32_t *value) {
for (uint8_t retry = 0; retry < 3; retry++) {
if (this->read_once_(reg, size, value))
return true;
}
ESP_LOGE(TAG, "Reading register %hhu failed!", reg);
return value;
return false;
}
uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) {
uint32_t value = 0;
return this->read_(reg, size, &value) ? value : 0;
}
uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) {
const uint32_t scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1;
uint32_t coeff = 0;
switch (unit) {
case RMS_UC:
coeff = this->data_.coefficient[RMS_UC];
return coeff ? 0x400000 * 100 / coeff : 0;
case RMS_IAC:
coeff = this->data_.coefficient[RMS_IAC];
coeff = this->data_.coefficient[RMS_IAC] * scale;
return coeff ? (0x800000 * 100 / coeff) * 10 : 0; // Stay within 32 bits
case POWER_PAC:
coeff = this->data_.coefficient[POWER_PAC];
coeff = this->data_.coefficient[POWER_PAC] * scale;
return coeff ? 0x80000000 / coeff : 0;
}
return 0;
@@ -167,21 +191,38 @@ bool CSE7761Component::chip_init_() {
calc_chksum += this->data_.coefficient[i];
}
calc_chksum = ~calc_chksum;
const bool energy_configured = this->energy_sensors_[0] != nullptr || this->energy_sensors_[1] != nullptr;
uint16_t coeff_chksum = this->read_(CSE7761_REG_COEFFCHKSUM, 2);
if ((calc_chksum != coeff_chksum) || (!calc_chksum)) {
ESP_LOGD(TAG, "Default calibration");
this->data_.coefficient[RMS_IAC] = CSE7761_IREF;
this->data_.coefficient[RMS_UC] = CSE7761_UREF;
this->data_.coefficient[POWER_PAC] = CSE7761_PREF;
if (energy_configured) {
ESP_LOGW(TAG, "No energy calibration, energy unavailable");
}
} else if (energy_configured) {
uint32_t hf_const = 0;
if (!this->read_(CSE7761_REG_HFCONST, 2, &hf_const)) {
ESP_LOGW(TAG, "HFConst read failed, energy unavailable");
}
// The pulse counters follow the raw power, so they need the same front end factor as power
const float input_scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1.0f;
for (uint8_t channel = 0; channel < 2; channel++) {
uint32_t scale = this->data_.coefficient[ENERGY_AC + channel] * hf_const;
this->energy_[channel].wh_per_pulse = static_cast<float>(scale) * CSE7761_ENERGY_WH_SCALE * input_scale;
}
}
this->write_(CSE7761_SPECIAL_COMMAND, CSE7761_CMD_ENABLE_WRITE);
uint8_t sys_status = this->read_(CSE7761_REG_SYSSTATUS, 1);
if (sys_status & 0x10) { // Write enable to protected registers (WREN)
this->write_(CSE7761_REG_SYSCON | 0x80, 0xFF04);
this->write_(CSE7761_REG_SYSCON | 0x80,
this->is_current_transformer_() ? SYSCON_CURRENT_TRANSFORMER : SYSCON_SHUNT);
this->write_(CSE7761_REG_EMUCON | 0x80, 0x1183);
this->write_(CSE7761_REG_EMUCON2 | 0x80, 0x0FC1);
// WaveEN|ZxEN, needed for Ufreq
this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1);
this->write_(CSE7761_REG_PULSE1SEL | 0x80, 0x3290);
} else {
ESP_LOGD(TAG, "Write failed at chip_init");
@@ -199,14 +240,15 @@ void CSE7761Component::get_data_() {
uint32_t value = this->read_(CSE7761_REG_RMSU, 3);
this->data_.voltage_rms = (value >= 0x800000) ? 0 : value;
// No load threshold of 10mA with a shunt; the raw value stands for 5 times more with a current transformer
value = this->read_(CSE7761_REG_RMSIA, 3);
this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value;
value = this->read_(CSE7761_REG_POWERPA, 4);
// PowerPA is two's complement signed 32-bit per datasheet
this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast<int32_t>(value);
value = this->read_(CSE7761_REG_RMSIB, 3);
this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA
this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value;
value = this->read_(CSE7761_REG_POWERPB, 4);
// PowerPB is two's complement signed 32-bit per datasheet
this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast<int32_t>(value);
@@ -218,12 +260,35 @@ void CSE7761Component::get_data_() {
this->voltage_sensor_->publish_state(voltage);
}
uint32_t raw_reg = 0;
if (this->frequency_sensor_ != nullptr && this->read_(CSE7761_REG_UFREQ, 2, &raw_reg)) {
// Ufreq highest bit set means no signal
float frequency = (raw_reg == 0 || raw_reg >= 0x8000) ? 0.0f : CSE7761_UFREQ_CLOCK / raw_reg;
this->frequency_sensor_->publish_state(frequency);
}
if (this->power_factor_sensor_1_ != nullptr && this->read_(CSE7761_REG_POWERFACTOR, 3, &raw_reg)) {
// ADC2ON makes the chip report channel A
int24_t pf{static_cast<int32_t>(raw_reg)};
this->power_factor_sensor_1_->publish_state(static_cast<float>(static_cast<int32_t>(pf)) / 0x800000);
}
for (uint8_t channel = 0; channel < 2; channel++) {
// Active power = PowerPA * PowerPAC * 1000 / 0x80000000
float active_power =
static_cast<float>(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W
float amps = static_cast<float>(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A
ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps);
sensor::Sensor *energy_sensor = this->energy_sensors_[channel];
CSE7761EnergyChannel &energy = this->energy_[channel];
uint8_t energy_reg = (channel == 0) ? CSE7761_REG_ENERGY_PA : CSE7761_REG_ENERGY_PB;
uint32_t raw = 0;
if (energy_sensor != nullptr && energy.wh_per_pulse > 0.0f && this->read_(energy_reg, 3, &raw)) {
// 24-bit counter, not cleared on read
energy.pulses += (raw - energy.last_pulses) & 0xFFFFFF;
energy.last_pulses = raw;
energy_sensor->publish_state(energy.pulses * energy.wh_per_pulse);
}
if (channel == 0) {
if (this->power_sensor_1_ != nullptr) {
this->power_sensor_1_->publish_state(active_power);
+28 -1
View File
@@ -3,11 +3,11 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
#include "esphome/core/datatypes.h"
namespace esphome::cse7761 {
struct CSE7761DataStruct {
uint32_t frequency = 0;
uint32_t voltage_rms = 0;
uint32_t current_rms[2] = {0};
int32_t active_power[2] = {0};
@@ -15,14 +15,32 @@ struct CSE7761DataStruct {
bool ready = false;
};
struct CSE7761EnergyChannel {
uint32_t last_pulses{0};
uint32_t pulses{0};
float wh_per_pulse{0.0f};
};
enum class CSE7761CurrentInput : uint8_t {
CSE7761_CURRENT_INPUT_SHUNT,
CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER,
};
/// This class implements support for the CSE7761 UART power sensor.
class CSE7761Component final : public PollingComponent, public uart::UARTDevice {
public:
explicit CSE7761Component(CSE7761CurrentInput current_input) : current_input_(current_input) {}
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_frequency_sensor(sensor::Sensor *frequency_sensor) { frequency_sensor_ = frequency_sensor; }
void set_power_factor_1_sensor(sensor::Sensor *power_factor_sensor_1) {
power_factor_sensor_1_ = power_factor_sensor_1;
}
void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; }
void set_current_1_sensor(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; }
void set_energy_1_sensor(sensor::Sensor *energy_sensor_1) { this->energy_sensors_[0] = energy_sensor_1; }
void set_active_power_2_sensor(sensor::Sensor *power_sensor_2) { power_sensor_2_ = power_sensor_2; }
void set_current_2_sensor(sensor::Sensor *current_sensor_2) { current_sensor_2_ = current_sensor_2; }
void set_energy_2_sensor(sensor::Sensor *energy_sensor_2) { this->energy_sensors_[1] = energy_sensor_2; }
void setup() override;
void dump_config() override;
void update() override;
@@ -30,14 +48,23 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice
protected:
// Sensors
sensor::Sensor *voltage_sensor_{nullptr};
sensor::Sensor *frequency_sensor_{nullptr};
sensor::Sensor *power_factor_sensor_1_{nullptr};
sensor::Sensor *power_sensor_1_{nullptr};
sensor::Sensor *current_sensor_1_{nullptr};
sensor::Sensor *power_sensor_2_{nullptr};
sensor::Sensor *current_sensor_2_{nullptr};
sensor::Sensor *energy_sensors_[2]{nullptr, nullptr};
CSE7761DataStruct data_;
CSE7761EnergyChannel energy_[2];
const CSE7761CurrentInput current_input_;
bool is_current_transformer_() const {
return this->current_input_ == CSE7761CurrentInput::CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER;
}
void write_(uint8_t reg, uint16_t data);
bool read_once_(uint8_t reg, uint8_t size, uint32_t *value);
bool read_(uint8_t reg, uint8_t size, uint32_t *value);
uint32_t read_(uint8_t reg, uint8_t size);
uint32_t coefficient_by_unit_(uint32_t unit);
bool chip_init_();
+54 -13
View File
@@ -2,15 +2,22 @@ import esphome.codegen as cg
from esphome.components import sensor, uart
import esphome.config_validation as cv
from esphome.const import (
CONF_FREQUENCY,
CONF_ID,
CONF_VOLTAGE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_FREQUENCY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_HERTZ,
UNIT_VOLT,
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.types import ConfigType
@@ -26,17 +33,41 @@ CONF_CURRENT_1 = "current_1"
CONF_CURRENT_2 = "current_2"
CONF_ACTIVE_POWER_1 = "active_power_1"
CONF_ACTIVE_POWER_2 = "active_power_2"
CONF_ENERGY_1 = "energy_1"
CONF_ENERGY_2 = "energy_2"
CONF_POWER_FACTOR_1 = "power_factor_1"
CONF_CURRENT_INPUT = "current_input"
CSE7761CurrentInput = cse7761_ns.enum("CSE7761CurrentInput", is_class=True)
CURRENT_INPUTS = {
"shunt": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_SHUNT,
"current_transformer": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER,
}
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CSE7761Component),
cv.Optional(CONF_CURRENT_INPUT, default="shunt"): cv.enum(
CURRENT_INPUTS, lower=True
),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_FREQUENCY): sensor.sensor_schema(
unit_of_measurement=UNIT_HERTZ,
accuracy_decimals=2,
device_class=DEVICE_CLASS_FREQUENCY,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_POWER_FACTOR_1): sensor.sensor_schema(
accuracy_decimals=2,
device_class=DEVICE_CLASS_POWER_FACTOR,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CURRENT_1): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
@@ -61,6 +92,18 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_ENERGY_1): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
cv.Optional(CONF_ENERGY_2): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT_HOURS,
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
),
}
)
.extend(cv.polling_component_schema("60s"))
@@ -79,19 +122,17 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
for key in [
CONF_VOLTAGE,
CONF_CURRENT_1,
CONF_CURRENT_2,
CONF_ACTIVE_POWER_1,
CONF_ACTIVE_POWER_2,
]:
if key not in config:
continue
conf = config[key]
sens = await sensor.new_sensor(conf)
cg.add(getattr(var, f"set_{key}_sensor")(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
await sensors(CONF_FREQUENCY, var.set_frequency_sensor)
await sensors(CONF_POWER_FACTOR_1, var.set_power_factor_1_sensor)
await sensors(CONF_CURRENT_1, var.set_current_1_sensor)
await sensors(CONF_CURRENT_2, var.set_current_2_sensor)
await sensors(CONF_ACTIVE_POWER_1, var.set_active_power_1_sensor)
await sensors(CONF_ACTIVE_POWER_2, var.set_active_power_2_sensor)
await sensors(CONF_ENERGY_1, var.set_energy_1_sensor)
await sensors(CONF_ENERGY_2, var.set_energy_2_sensor)
+5 -7
View File
@@ -67,10 +67,7 @@ bool CSE7766Component::check_byte_() {
}
if (index == CSE7766_RAW_DATA_SIZE - 1) {
uint8_t checksum = 0;
for (uint8_t i = 2; i < CSE7766_RAW_DATA_SIZE - 1; i++) {
checksum += this->raw_data_[i];
}
const uint8_t checksum = sum_bytes(this->raw_data_ + 2, CSE7766_RAW_DATA_SIZE - 3);
if (checksum != this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]) {
ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum,
@@ -205,9 +202,10 @@ void CSE7766Component::parse_data_() {
float pf = NAN;
if (apparent_power > 0) {
pf = power / apparent_power;
if (pf < 0 || pf > 1) {
ESP_LOGD(TAG, "Impossible power factor: %.4f not in interval [0, 1]", pf);
pf = NAN;
if (pf > 1) {
// Noise near the chip's resolution limit, seen during inrush, pushes the ratio past unity
ESP_LOGD(TAG, "Power factor %.4f above 1, clamped", pf);
pf = 1.0f;
}
} else if (apparent_power == 0 && power == 0) {
// No load, report ideal power factor
+107 -18
View File
@@ -1,25 +1,40 @@
#include "daikin.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
namespace esphome::daikin {
ESPHOME_LOG_TAG(TAG, "daikin.climate");
static constexpr uint32_t BOOST_TIMEOUT_ID = 0;
void DaikinClimate::setup() {
climate_ir::ClimateIR::setup();
// A boost preset restored from flash gets its timer too
this->update_boost_timer_(false);
}
void DaikinClimate::control(const climate::ClimateCall &call) {
const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST;
climate_ir::ClimateIR::control(call);
this->update_boost_timer_(was_boost);
}
void DaikinClimate::transmit_state() {
uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7, 0x11, 0xDA, 0x27, 0x00,
uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0x00, 0x11, 0xDA, 0x27, 0x00,
0x42, 0x49, 0x05, 0xA2, 0x11, 0xDA, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00};
this->set_presets_(remote_state);
remote_state[21] = this->operation_mode_();
remote_state[22] = this->temperature_();
uint16_t fan_speed = this->fan_speed_();
remote_state[24] = fan_speed >> 8;
remote_state[25] = fan_speed & 0xff;
// Calculate checksum
for (int i = 16; i < 34; i++) {
remote_state[34] += remote_state[i];
}
remote_state[7] = sum_bytes(remote_state, 7);
remote_state[34] = sum_bytes(remote_state + 16, 18);
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
@@ -136,18 +151,52 @@ uint8_t DaikinClimate::temperature_() const {
case climate::CLIMATE_MODE_DRY:
return 0xc0;
default:
uint8_t temperature = (uint8_t) roundf(clamp<float>(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX));
return temperature << 1;
return static_cast<uint8_t>(
roundf(clamp<float>(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX) * 2.0f));
}
}
bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
uint8_t checksum = 0;
for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) {
checksum += frame[i];
void DaikinClimate::set_presets_(uint8_t *remote_state) const {
switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) {
case climate::CLIMATE_PRESET_BOOST:
remote_state[29] = DAIKIN_PRESET_POWERFUL_ON;
break;
case climate::CLIMATE_PRESET_SLEEP:
remote_state[29] = DAIKIN_PRESET_QUIET_ON;
break;
case climate::CLIMATE_PRESET_ECO:
remote_state[32] = DAIKIN_PRESET_ECONO_ON;
break;
case climate::CLIMATE_PRESET_ACTIVITY:
remote_state[32] = DAIKIN_PRESET_SENSOR_ON;
break;
case climate::CLIMATE_PRESET_COMFORT:
remote_state[6] = DAIKIN_PRESET_COMFORT_ON; // the only preset carried by the first frame
break;
default:
break;
}
if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum)
return false;
}
void DaikinClimate::update_boost_timer_(bool was_boost) {
const bool is_boost = this->preset == climate::CLIMATE_PRESET_BOOST;
if (is_boost && !was_boost) {
this->set_timeout(BOOST_TIMEOUT_ID, DAIKIN_BOOST_DURATION_MS, [this]() { this->cancel_boost_mode_(); });
} else if (was_boost && !is_boost) {
this->cancel_timeout(BOOST_TIMEOUT_ID);
}
}
void DaikinClimate::cancel_boost_mode_() {
if (this->preset != climate::CLIMATE_PRESET_BOOST)
return;
ESP_LOGD(TAG, "Boost ended");
this->preset = climate::CLIMATE_PRESET_NONE;
this->publish_state();
}
bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST;
uint8_t mode = frame[5];
// Temperature is given in degrees celcius * 2
// only update for states that use the temperature
@@ -206,16 +255,42 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) {
this->fan_mode = climate::CLIMATE_FAN_QUIET;
break;
}
// Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid
// A comfort frame whose own state frame never came must not attach to a later transmission
const uint32_t now = App.get_loop_component_start_time();
const PendingComfort comfort = now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS
? this->pending_comfort_
: PendingComfort::PENDING_COMFORT_UNKNOWN;
this->pending_comfort_ = PendingComfort::PENDING_COMFORT_UNKNOWN;
if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) {
this->preset = climate::CLIMATE_PRESET_BOOST;
} else if (frame[13] & DAIKIN_PRESET_QUIET_ON) {
this->preset = climate::CLIMATE_PRESET_SLEEP;
} else if (frame[16] & DAIKIN_PRESET_ECONO_ON) {
this->preset = climate::CLIMATE_PRESET_ECO;
} else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) {
this->preset = climate::CLIMATE_PRESET_ACTIVITY;
} else if (comfort == PendingComfort::PENDING_COMFORT_ON) {
this->preset = climate::CLIMATE_PRESET_COMFORT;
} else if (comfort == PendingComfort::PENDING_COMFORT_OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) {
// Without a comfort frame in this transmission a current comfort preset is left alone
this->preset = climate::CLIMATE_PRESET_NONE;
}
this->update_boost_timer_(was_boost);
this->publish_state();
return true;
}
bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE] = {};
uint8_t frame[DAIKIN_STATE_FRAME_SIZE] = {};
if (!data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) {
return false;
}
for (uint8_t pos = 0; pos < DAIKIN_STATE_FRAME_SIZE; pos++) {
uint8_t frame_size = DAIKIN_STATE_FRAME_SIZE;
for (uint8_t pos = 0; pos < frame_size; pos++) {
uint8_t byte = 0;
for (int8_t bit = 0; bit < 8; bit++) {
if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) {
@@ -224,7 +299,7 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
}
}
state_frame[pos] = byte;
frame[pos] = byte;
if (pos == 0) {
// frame header
if (byte != 0x11)
@@ -243,11 +318,25 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) {
return false;
} else if (pos == 4) {
// frame type
if (byte != 0x00)
if (byte == DAIKIN_FRAME_TYPE_COMFORT) {
frame_size = DAIKIN_COMFORT_FRAME_SIZE;
} else if (byte != DAIKIN_FRAME_TYPE_STATE) {
return false;
}
}
}
return this->parse_state_frame_(state_frame);
if (frame[frame_size - 1] != sum_bytes(frame, frame_size - 1)) {
return false;
}
if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) {
// Nothing changes yet: the state frame that follows applies it once it has passed its own checksum
this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::PENDING_COMFORT_ON
: PendingComfort::PENDING_COMFORT_OFF;
this->pending_comfort_at_ = App.get_loop_component_start_time();
return true;
}
return this->parse_state_frame_(frame);
}
} // namespace esphome::daikin
+34 -6
View File
@@ -27,6 +27,17 @@ const uint8_t DAIKIN_FAN_3 = 0x50;
const uint8_t DAIKIN_FAN_4 = 0x60;
const uint8_t DAIKIN_FAN_5 = 0x70;
// Presets: bit flags in byte 6 (comfort), byte 29 (powerful, quiet) and byte 32 (sensor, econo)
const uint8_t DAIKIN_PRESET_COMFORT_ON = 0x10;
const uint8_t DAIKIN_PRESET_POWERFUL_ON = 0x01;
const uint8_t DAIKIN_PRESET_QUIET_ON = 0x20;
const uint8_t DAIKIN_PRESET_ECONO_ON = 0x04;
const uint8_t DAIKIN_PRESET_SENSOR_ON = 0x02;
// The unit leaves powerful mode by itself after 20 minutes; mirror that in the entity
const uint32_t DAIKIN_BOOST_DURATION_MS = 20 * 60 * 1000;
// The three frames of one transmission arrive well inside this window
const uint32_t DAIKIN_TRANSMISSION_MS = 1000;
// IR Transmission
const uint32_t DAIKIN_IR_FREQUENCY = 38000;
const uint32_t DAIKIN_HEADER_MARK = 3360;
@@ -36,27 +47,44 @@ const uint32_t DAIKIN_ONE_SPACE = 1370;
const uint32_t DAIKIN_ZERO_SPACE = 360;
const uint32_t DAIKIN_MESSAGE_SPACE = 32300;
// State Frame size
// Frame types (byte 4) and sizes
const uint8_t DAIKIN_FRAME_TYPE_STATE = 0x00;
const uint8_t DAIKIN_FRAME_TYPE_COMFORT = 0xC5;
const uint8_t DAIKIN_STATE_FRAME_SIZE = 19;
const uint8_t DAIKIN_COMFORT_FRAME_SIZE = 8;
class DaikinClimate final : public climate_ir::ClimateIR {
public:
DaikinClimate()
: climate_ir::ClimateIR(DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 1.0f, true, true,
{climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW,
climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {}
: climate_ir::ClimateIR(
DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 0.5f, true, true,
{climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW,
climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH},
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL,
climate::CLIMATE_SWING_BOTH},
{climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_COMFORT,
climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_ACTIVITY, climate::CLIMATE_PRESET_NONE}) {}
protected:
void setup() override;
void control(const climate::ClimateCall &call) override;
// Transmit via IR the state of this climate controller.
void transmit_state() override;
uint8_t operation_mode_() const;
uint16_t fan_speed_() const;
uint8_t temperature_() const;
void set_presets_(uint8_t *remote_state) const;
/// Arms or cancels the boost timer when the preset moves into or out of boost.
void update_boost_timer_(bool was_boost);
void cancel_boost_mode_();
// Handle received IR Buffer
bool on_receive(remote_base::RemoteReceiveData data) override;
bool parse_state_frame_(const uint8_t frame[]);
/// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame
enum class PendingComfort : uint8_t { PENDING_COMFORT_UNKNOWN, PENDING_COMFORT_OFF, PENDING_COMFORT_ON };
PendingComfort pending_comfort_{PendingComfort::PENDING_COMFORT_UNKNOWN};
uint32_t pending_comfort_at_{0};
};
} // namespace esphome::daikin
+1 -4
View File
@@ -246,10 +246,7 @@ climate::ClimateTraits DaikinArcClimate::traits() {
}
bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) {
uint8_t checksum = 0;
for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) {
checksum += frame[i];
}
const uint8_t checksum = sum_bytes(frame, DAIKIN_STATE_FRAME_SIZE - 1);
if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) {
ESP_LOGI(TAG, "checksum error");
return false;
+4
View File
@@ -6,6 +6,8 @@ from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CONF_SUB_UNIT = "sub_unit"
daikin_brc_ns = cg.esphome_ns.namespace("daikin_brc")
DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR)
@@ -13,6 +15,7 @@ DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).extend(
{
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
cv.Optional(CONF_SUB_UNIT, default=0): cv.int_range(min=0, max=5),
}
)
@@ -20,3 +23,4 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).ext
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT]))
cg.add(var.set_sub_unit(config[CONF_SUB_UNIT]))
+10 -7
View File
@@ -1,5 +1,6 @@
#include "daikin_brc.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
namespace esphome::daikin_brc {
@@ -19,13 +20,18 @@ void DaikinBrcClimate::transmit_state() {
0xDA, 0x17, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x20, 0x00};
// The low 3 bits of the frame header byte address the indoor unit, in the preamble and the state frame
remote_state[3] |= this->sub_unit_;
remote_state[10] |= this->sub_unit_;
remote_state[6] += this->sub_unit_; // preamble checksum covers byte 3
remote_state[12] = this->alt_mode_();
remote_state[13] = this->mode_button_;
remote_state[14] = this->operation_mode_();
remote_state[17] = this->temperature_();
remote_state[18] = this->fan_speed_swing_();
// Calculate checksum
// Calculate state frame checksum
for (int i = DAIKIN_BRC_PREAMBLE_SIZE; i < DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1; i++) {
remote_state[DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1] += remote_state[i];
}
@@ -160,10 +166,7 @@ uint8_t DaikinBrcClimate::temperature_() {
}
bool DaikinBrcClimate::parse_state_frame_(const uint8_t frame[]) {
uint8_t checksum = 0;
for (int i = 0; i < (DAIKIN_BRC_STATE_FRAME_SIZE - 1); i++) {
checksum += frame[i];
}
const uint8_t checksum = sum_bytes(frame, DAIKIN_BRC_STATE_FRAME_SIZE - 1);
if (frame[DAIKIN_BRC_STATE_FRAME_SIZE - 1] != checksum) {
ESP_LOGCONFIG(TAG, "Bad CheckSum %x", checksum);
return false;
@@ -256,8 +259,8 @@ bool DaikinBrcClimate::on_receive(remote_base::RemoteReceiveData data) {
if (byte != 0x17)
return false;
} else if (pos == 3) {
// frame header
if (byte != 0x18)
// frame header, with this unit's address in the low 3 bits
if (byte != (0x18 | this->sub_unit_))
return false;
} else if (pos == 4) {
// frame type
@@ -60,6 +60,8 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR {
this->fahrenheit_ = value;
this->temperature_step_ = value ? 0.5f : 1.0f;
}
/// Which indoor unit (0 to 5) this climate addresses on a multi-unit system
void set_sub_unit(uint8_t value) { this->sub_unit_ = value; }
protected:
uint8_t mode_button_ = 0x00;
@@ -75,6 +77,7 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR {
bool on_receive(remote_base::RemoteReceiveData data) override;
bool parse_state_frame_(const uint8_t frame[]);
bool fahrenheit_{false};
uint8_t sub_unit_{0};
};
} // namespace esphome::daikin_brc
+1 -6
View File
@@ -129,12 +129,7 @@ void DalyBmsComponent::decode_data_(std::vector<uint8_t> data) {
while ((it = std::find(it, data.end(), 0xA5)) != data.end()) {
if (data.end() - it >= DALY_FRAME_SIZE && it[1] == 0x01) {
uint8_t checksum;
int sum = 0;
for (int i = 0; i < 12; i++) {
sum += it[i];
}
checksum = sum;
const uint8_t checksum = sum_bytes(&*it, 12);
if (checksum == it[12]) {
switch (it[2]) {
@@ -1,13 +1,12 @@
import base64
from pathlib import Path
import re
import secrets
from typing import Any
from ruamel.yaml import YAML
from esphome import git
import esphome.codegen as cg
from esphome.components.noise import generate_encryption_key
from esphome.components.packages import validate_source_shorthand
import esphome.config_validation as cv
from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI
@@ -175,8 +174,7 @@ def import_config(
"esphome": esphome_core,
}
if encryption:
noise_psk = secrets.token_bytes(32)
key = base64.b64encode(noise_psk).decode()
key = generate_encryption_key()
config["api"] = {"encryption": {"key": key}}
output = dump(config)
+38 -29
View File
@@ -143,6 +143,8 @@ def validate_wakeup_pin(
processed_pins: list[ConfigType] = [{CONF_PIN: value}]
else:
processed_pins = list(value)
if not processed_pins:
raise cv.Invalid("'wakeup_pin' needs at least one entry")
for i, pin_config in enumerate(processed_pins):
# now validate each item
@@ -154,29 +156,34 @@ def validate_wakeup_pin(
def validate_config(config: ConfigType) -> ConfigType:
# right now only BK72XX supports the list format for wakeup pins
if CORE.is_bk72xx:
if CONF_WAKEUP_PIN_MODE in config:
wakeup_pins = config.get(CONF_WAKEUP_PIN, [])
if len(wakeup_pins) > 1:
raise cv.Invalid(
"You need to remove the global wakeup_pin_mode and define it per pin"
)
if wakeup_pins:
if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]:
raise cv.Invalid(
"Specify wakeup_pin_mode either at the top level under deep_sleep "
"or under the pin entry, not both"
)
wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE)
elif (
isinstance(config.get(CONF_WAKEUP_PIN), list)
and len(config[CONF_WAKEUP_PIN]) > 1
):
wakeup_pins = config.get(CONF_WAKEUP_PIN)
has_pin_mode = CONF_WAKEUP_PIN_MODE in config
if has_pin_mode and wakeup_pins is None:
raise cv.Invalid(
"Your platform does not support providing multiple entries in wakeup_pin"
"'wakeup_pin_mode' requires 'wakeup_pin'", path=[CONF_WAKEUP_PIN_MODE]
)
multiple_pins = wakeup_pins is not None and len(wakeup_pins) > 1
# wakeup_pin is limited to ESP32 and BK72XX, and only BK72XX accepts a list
if not CORE.is_bk72xx:
if multiple_pins:
raise cv.Invalid(
"ESP32 does not support multiple entries for 'wakeup_pin'",
path=[CONF_WAKEUP_PIN],
)
elif has_pin_mode:
if multiple_pins:
raise cv.Invalid(
"Remove the global 'wakeup_pin_mode' and define it per pin",
path=[CONF_WAKEUP_PIN_MODE],
)
if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]):
raise cv.Invalid(
"Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' "
"or under the pin entry, not both",
path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE],
)
# Move the global mode into the single pin entry, which to_code() reads
pin[CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE)
return config
@@ -203,11 +210,11 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod:
if CORE.is_bk72xx:
max_duration = core.TimePeriod(hours=36)
if value > max_duration:
raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX")
raise cv.Invalid("'sleep_duration' cannot be more than 36 hours on BK72XX")
elif CORE.using_zephyr:
max_duration = core.TimePeriod(days=49)
if value > max_duration:
raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr")
raise cv.Invalid("'sleep_duration' cannot be more than 49 days on Zephyr")
return value
@@ -290,7 +297,10 @@ CONFIG_SCHEMA = cv.All(
cv.positive_time_period_milliseconds,
_validate_sleep_duration,
),
cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin,
cv.Optional(CONF_WAKEUP_PIN): cv.All(
cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]),
validate_wakeup_pin,
),
cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All(
cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]),
cv.enum(WAKEUP_PIN_MODES, upper=True),
@@ -347,11 +357,10 @@ async def to_code(config: ConfigType) -> None:
if CONF_SLEEP_DURATION in config:
cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION]))
if CONF_WAKEUP_PIN in config:
pins_as_list = config.get(CONF_WAKEUP_PIN, [])
if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None:
if CORE.is_bk72xx:
cg.add(var.init_wakeup_pins(len(pins_as_list)))
for item in pins_as_list:
cg.add(var.init_wakeup_pins(len(wakeup_pins)))
for item in wakeup_pins:
cg.add(
var.add_wakeup_pin(
await cg.gpio_pin_expression(item[CONF_PIN]),
@@ -361,7 +370,7 @@ async def to_code(config: ConfigType) -> None:
)
)
else:
pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN])
pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN])
cg.add(var.set_wakeup_pin(pin))
if CONF_WAKEUP_PIN_MODE in config:
cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE]))
+2 -4
View File
@@ -1,5 +1,6 @@
#include "delonghi.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
namespace esphome::delonghi {
@@ -102,10 +103,7 @@ uint8_t DelonghiClimate::temperature_() {
}
bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) {
uint8_t checksum = 0;
for (int i = 0; i < (DELONGHI_STATE_FRAME_SIZE - 1); i++) {
checksum += frame[i];
}
const uint8_t checksum = sum_bytes(frame, DELONGHI_STATE_FRAME_SIZE - 1);
if (frame[DELONGHI_STATE_FRAME_SIZE - 1] != checksum) {
return false;
}
+14 -16
View File
@@ -21,7 +21,6 @@ from esphome.const import (
CONF_PAGES,
CONF_ROTATION,
CONF_TO,
CONF_TRIGGER_ID,
CONF_UPDATE_INTERVAL,
CONF_WIDTH,
SCHEDULER_DONT_RUN,
@@ -39,14 +38,13 @@ DisplayPage = display_ns.class_("DisplayPage")
DisplayPagePtr = DisplayPage.operator("ptr")
DisplayRef = Display.operator("ref")
DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action)
DisplayOnPageChangeTrigger = display_ns.class_(
"DisplayOnPageChangeTrigger", automation.Trigger
)
CONF_ON_PAGE_CHANGE = "on_page_change"
CONF_SHOW_TEST_CARD = "show_test_card"
CONF_UNSPECIFIED = "unspecified"
_request_page_change_slot = cg.slot_counter("DISPLAY_PAGE_CHANGE_CALLBACK_COUNT")
DISPLAY_ROTATIONS = {
0: display_ns.DISPLAY_ROTATION_0_DEGREES,
90: display_ns.DISPLAY_ROTATION_90_DEGREES,
@@ -99,9 +97,6 @@ FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend(
),
cv.Optional(CONF_ON_PAGE_CHANGE): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
DisplayOnPageChangeTrigger
),
cv.Optional(CONF_FROM): cv.use_id(DisplayPage),
cv.Optional(CONF_TO): cv.use_id(DisplayPage),
}
@@ -137,15 +132,18 @@ async def setup_display_core_(var, config):
pages.append(page)
cg.add(var.set_pages(pages))
for conf in config.get(CONF_ON_PAGE_CHANGE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
if CONF_FROM in conf:
page = await cg.get_variable(conf[CONF_FROM])
cg.add(trigger.set_from(page))
if CONF_TO in conf:
page = await cg.get_variable(conf[CONF_TO])
cg.add(trigger.set_to(page))
await automation.build_automation(
trigger, [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], conf
_request_page_change_slot(str(var))
checks = []
for key, name in ((CONF_FROM, "from"), (CONF_TO, "to")):
if (page_id := conf.get(key)) is not None:
page = await cg.get_variable(page_id)
checks.append(f"{name} == ::{page}")
await automation.build_callback_automation(
var,
"add_on_page_change_callback",
[(DisplayPagePtr, "from"), (DisplayPagePtr, "to")],
conf,
when=" && ".join(checks) or None,
)
if config.get(CONF_SHOW_TEST_CARD):
cg.add(var.show_test_card())
+7 -10
View File
@@ -678,12 +678,14 @@ void Display::set_pages(std::vector<DisplayPage *> pages) {
}
void Display::show_page(DisplayPage *page) {
this->previous_page_ = this->page_;
#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT
DisplayPage *previous = this->page_;
this->page_ = page;
if (this->previous_page_ != this->page_) {
for (auto *t : on_page_change_triggers_)
t->process(this->previous_page_, this->page_);
}
if (previous != page)
this->page_change_callback_.call(previous, page);
#else
this->page_ = page;
#endif
}
void Display::do_update_() {
@@ -700,11 +702,6 @@ void Display::do_update_() {
this->clear_clipping_();
}
void DisplayOnPageChangeTrigger::process(DisplayPage *from, DisplayPage *to) {
if ((this->from_ == nullptr || this->from_ == from) && (this->to_ == nullptr || this->to_ == to))
this->trigger(from, to);
}
void Display::strftime(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format,
ESPTime time) {
char buffer[64];
+12 -18
View File
@@ -7,6 +7,8 @@
#include "esphome/core/color.h"
#include "esphome/core/automation.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/time.h"
#include "esphome/core/log.h"
#include "display_color_utils.h"
@@ -173,7 +175,6 @@ enum RegularPolygonDrawing {
class Display;
class DisplayPage;
class DisplayOnPageChangeTrigger;
/** Optimized display writer that uses function pointers for stateless lambdas.
*
@@ -294,7 +295,7 @@ using display_writer_t = DisplayWriter<Display>;
prefix type "\n" \
"%s Rotations: %d °\n" \
"%s Dimensions: %dpx x %dpx", \
prefix, log_display_obj->rotation_, prefix, log_display_obj->get_width(), \
prefix, log_display_obj->get_rotation(), prefix, log_display_obj->get_width(), \
log_display_obj->get_height()); \
}
@@ -702,7 +703,11 @@ class Display : public PollingComponent {
const DisplayPage *get_active_page() const { return this->page_; }
void add_on_page_change_trigger(DisplayOnPageChangeTrigger *t) { this->on_page_change_triggers_.push_back(t); }
#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT
template<typename F> void add_on_page_change_callback(F &&callback) {
this->page_change_callback_.add(std::forward<F>(callback));
}
#endif
/// Internal method to set the display rotation with.
virtual void set_rotation(DisplayRotation rotation);
@@ -710,7 +715,7 @@ class Display : public PollingComponent {
// Internal method to set display auto clearing.
void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; }
DisplayRotation get_rotation() const { return this->rotation_; }
virtual DisplayRotation get_rotation() const { return this->rotation_; }
/** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays,
* returns the type the display is currently configured to.
@@ -809,8 +814,9 @@ class Display : public PollingComponent {
DisplayRotation rotation_{DISPLAY_ROTATION_0_DEGREES};
display_writer_t writer_{};
DisplayPage *page_{nullptr};
DisplayPage *previous_page_{nullptr};
std::vector<DisplayOnPageChangeTrigger *> on_page_change_triggers_;
#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT
StaticCallbackManager<DISPLAY_PAGE_CHANGE_CALLBACK_COUNT, void(DisplayPage *, DisplayPage *)> page_change_callback_;
#endif
bool auto_clear_enabled_{true};
std::vector<Rect> clipping_rectangle_;
uint8_t wdt_pixel_counter_{0};
@@ -850,18 +856,6 @@ template<typename... Ts> class DisplayPageShowAction final : public Action<Ts...
}
};
class DisplayOnPageChangeTrigger final : public Trigger<DisplayPage *, DisplayPage *> {
public:
explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); }
void process(DisplayPage *from, DisplayPage *to);
void set_from(DisplayPage *p) { this->from_ = p; }
void set_to(DisplayPage *p) { this->to_ = p; }
protected:
DisplayPage *from_{nullptr};
DisplayPage *to_{nullptr};
};
const LogString *text_align_to_string(TextAlign textalign);
} // namespace esphome::display
+35
View File
@@ -0,0 +1,35 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@linkedupbits"]
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
DOMAIN = "ds3231"
CONF_DS3231_ID = "ds3231_id"
ds3231_ns = cg.esphome_ns.namespace("ds3231")
DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)})
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x68))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
automation.register_apply_action(
"ds3231.force_temperature_conversion",
automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}),
automation.ApplyCall("force_temperature_conversion()"),
)

Some files were not shown because too many files have changed in this diff Show More