Compare commits

..
Author SHA1 Message Date
J. Nick Koston 5d7d0b62d8 Merge branch 'analyze-memory-esp8266-rodata' into analyze-memory-esp8266-strings
# Conflicts:
#	esphome/analyze_memory/__init__.py
2026-11-15 10:57:56 -06:00
J. Nick Koston 662c3682f9 [analyze_memory] Name the object when readelf reads only one 2026-11-15 10:57:38 -06:00
J. Nick Koston 4a9ac483cf [analyze_memory] Log readelf errors from the string scan 2026-11-15 10:30:22 -06:00
J. Nick Koston b748e41cd6 Merge branch 'analyze-memory-esp8266-rodata' into analyze-memory-esp8266-strings 2026-11-15 10:30:12 -06:00
J. Nick Koston c55c6d610c [analyze_memory] Decide linked copies from linked users only, log readelf errors 2026-11-15 10:29:58 -06:00
J. Nick Koston 9d0ffabb5a [analyze_memory] Skip unreadable string pointers, escape every control character 2026-11-15 10:00:03 -06:00
J. Nick Koston 427af0782a Merge branch 'analyze-memory-esp8266-rodata' into analyze-memory-esp8266-strings 2026-11-15 09:59:42 -06:00
J. Nick Koston 01ebdc5369 [analyze_memory] Decide liveness from every user, escape all control characters 2026-11-15 09:59:30 -06:00
J. Nick Koston a7719dccf8 [analyze_memory] Bounds-check string pointers, list only users of the unnamed bytes, escape previews 2026-11-15 09:57:34 -06:00
J. Nick Koston 2956c9fe69 [analyze_memory] Name every function that points at a literal, say the total covers known users 2026-11-15 04:42:41 -06:00
J. Nick Koston ed528d8fb5 [analyze_memory] Skip pointers from undumped sections, test both passes together 2026-11-15 04:15:02 -06:00
J. Nick Koston 04e9951e10 Merge branch 'analyze-memory-esp8266-rodata' into analyze-memory-esp8266-strings 2026-11-15 04:14:29 -06:00
J. Nick Koston 357a023c38 [analyze_memory] Drop an inline constant only when every weak user is overridden 2026-11-15 04:14:16 -06:00
J. Nick Koston 15c12a1940 [analyze_memory] Ignore string users whose linked copy may be another object's 2026-11-15 03:51:05 -06:00
J. Nick Koston 0556ddc355 [analyze_memory] Find literal tails after other data, warn when .rodata cannot be dumped 2026-11-15 03:50:29 -06:00
J. Nick Koston 5dd872e9b3 Merge branch 'analyze-memory-esp8266-rodata' into analyze-memory-esp8266-strings 2026-11-15 03:50:00 -06:00
J. Nick Koston 93a1328738 [analyze_memory] Warn when readelf cannot read the objects, keep its partial output 2026-11-15 03:49:52 -06:00
J. Nick Koston 472c7335fc Merge branch 'analyze-memory-esp8266-rodata' into analyze-memory-esp8266-strings
# Conflicts:
#	esphome/analyze_memory/__init__.py
2026-11-15 03:35:45 -06:00
J. Nick Koston 4ba019395b [analyze_memory] Skip constants whose linked copy is unknown, detect jump tables from unnamed bytes only 2026-11-15 03:35:15 -06:00
J. Nick Koston 96562837f1 Merge branch 'analyze-memory-esp8266-rodata' into analyze-memory-esp8266-strings
# Conflicts:
#	esphome/analyze_memory/__init__.py
2026-11-15 03:22:16 -06:00
J. Nick Koston 2f984a48b9 [analyze_memory] Count literals after other data, attribute literals reached through unnamed tables 2026-11-15 03:21:54 -06:00
J. Nick Koston d3dd10da12 [analyze_memory] Share relocation and dump parsing, name native library objects 2026-11-15 03:21:45 -06:00
J. Nick Koston 60ae039690 [analyze_memory] Share relocation and dump parsing, track string users per component 2026-11-15 03:05:53 -06:00
J. Nick Koston 6959075536 Merge branch 'analyze-memory-esp8266-rodata' into analyze-memory-esp8266-strings
# Conflicts:
#	esphome/analyze_memory/__init__.py
2026-11-15 03:00:11 -06:00
J. Nick Koston da34aa9f7a [analyze_memory] Resolve startup and IRAM code sections, dedupe inline copies only across objects 2026-11-15 02:59:41 -06:00
J. Nick Koston 073773cfd8 [analyze_memory] Attribute ESP8266 string literals in RAM to components 2026-11-15 02:55:16 -06:00
J. Nick Koston 2b27ba016b [analyze_memory] Report object paths with forward slashes on every OS 2026-11-15 02:53:02 -06:00
J. Nick Koston e5e85609a9 [analyze_memory] Use tab escapes in the objdump test fixture 2026-11-15 02:47:16 -06:00
J. Nick Koston 180f952fd0 [analyze_memory] Keep alias names, dedupe only inline copies, preview only unnamed bytes 2026-11-15 02:44:53 -06:00
J. Nick Koston c607199b23 [analyze_memory] Scan only objects with unnamed bytes and show the strings a constant points to 2026-11-15 02:35:58 -06:00
J. Nick Koston 058a5be777 [analyze_memory] Count ESP8266 .rodata as RAM and list unnamed constants 2026-11-15 02:29:01 -06:00
Hajo Noerenberg 1dff22f578 [cc1101] Add long packet support, plus a lambda packet_length for unknown-length packets (#17578) 2026-11-14 21:43:21 -06:00
Vinicius FortunaandClyde Stubbs c2eb540b37 [sx126x] Refactor/sx126x radio lifecycle (#22197)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
2026-11-15 13:17:23 +11:00
J. Nick Kostonandclydebarrow fad21e3f14 [cstxxx] Share the Hynitron 16-bit register touchscreen code (#22718)
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
2026-11-15 02:11:09 +00:00
Rodrigoah 904f0df920 [whirlpool] Add the Hitachi HCRA31NEWH model (#22704) 2026-11-14 14:23:51 -06:00
Chris 7128d22f07 [ethernet] Notify IP state listeners from the main loop (#22657) 2026-11-14 13:22:11 -06:00
Miguel Angel Nubla 825e2c3dd0 [wifi] Leave the AP before restarting on ESP32 (#22715) 2026-11-14 18:05:42 +00:00
82 changed files with 6574 additions and 1651 deletions

No files matched your search

+2 -1
View File
@@ -149,6 +149,7 @@ esphome/components/cst226/* @clydebarrow
esphome/components/cst328/* @latonita
esphome/components/cst816/* @clydebarrow
esphome/components/cst9220/* @clydebarrow
esphome/components/cstxxx/* @clydebarrow @latonita
esphome/components/ct_clamp/* @jesserockz
esphome/components/current_based/* @djwmarcx
esphome/components/cw2017/* @exciton
@@ -741,7 +742,7 @@ esphome/components/web_server_idf/* @dentra
esphome/components/weikai/* @DrCoolZic
esphome/components/weikai_i2c/* @DrCoolZic
esphome/components/weikai_spi/* @DrCoolZic
esphome/components/whirlpool/* @arpena @glmnet
esphome/components/whirlpool/* @arpena @glmnet @Rodrigoah
esphome/components/whynter/* @aeonsablaze
esphome/components/wiegand/* @ssieb
esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard
+628 -19
View File
@@ -1,7 +1,9 @@
"""Memory usage analyzer for ESPHome compiled binaries."""
from collections import Counter, defaultdict
from collections.abc import Container
from dataclasses import dataclass, field
from functools import cached_property
import logging
from pathlib import Path
import re
@@ -55,6 +57,7 @@ _COMPONENT_PREFIX_EXTERNAL = "[external]"
_COMPONENT_PREFIX_LIB = "[lib]"
_COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core"
_COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api"
_COMPONENT_ARDUINO_CORE = "arduino_core"
# Placement new storage suffix (generated by codegen Pvariable)
_PSTORAGE_SUFFIX = "__pstorage"
@@ -83,6 +86,28 @@ _NAMESPACE_STD = "std::"
# Type alias for symbol information: (symbol_name, size, component)
SymbolInfoType = tuple[str, int, str]
# Only ESP8266 has this section; its .rodata is copied to RAM at boot like .data
_ESP8266_FLASH_CODE_SECTION = ".irom0.text"
# readelf -SW row: [Nr] Name Type Address Off Size ES Flg
_OBJ_SECTION_PATTERN = re.compile(
r"^\s*\[\s*(\d+)\]\s+(\S+)\s+(\S+)\s+[\da-f]+\s+[\da-f]+\s+([\da-f]+)\s+[\da-f]+\s+(\S*)"
)
_RELOC_SECTION_PATTERN = re.compile(r"^Relocation section '\.rela(\S+)'")
# String literal section; group 1 is the function for -fdata-sections names
_STRING_SECTION_PATTERN = re.compile(r"^\.rodata(?:\.(.+))?\.str\d+\.\d+$")
# Data section of one symbol; group 1 is the symbol
_DATA_SECTION_PATTERN = re.compile(r"^\.(?:rodata|data)\.(.+)$")
# Code section; group 1 is the function for -ffunction-sections names, after any
# placement prefix (.text.startup.<fn>, .iram.text.<fn> for IRAM_ATTR)
_CODE_SECTION_PATTERN = re.compile(
r"^\.(?:literal|text|irom0\.text|iram\.text|iram0\.text)"
r"(?:\.(?:startup|unlikely|hot|exit)(?=\.|$))?(?:\.(.+))?$"
)
# readelf -x row: " 0x00000000 0000803f 0000803f 0000803f 0000803f ...?...?...?...?"
_HEX_DUMP_PREFIX = " 0x"
_HEX_DUMP_COLUMNS = slice(13, 48)
# RAM sections - symbols in these sections consume RAM
RAM_SECTIONS = frozenset([".data", ".bss"])
@@ -119,6 +144,31 @@ class SDKSymbol:
demangled: str = "" # Demangled name (populated after analysis)
@dataclass
class UnnamedConstant:
"""Constant without a symbol in an object's .rodata (RAM on ESP8266)."""
size: int
source_file: str
component: str
functions: list[str] # Functions that reference it, mangled
jump_table: bool # Holds code addresses (a switch table), not a value
data: bytes # Leading bytes, to recognize the value
strings: list[str] = field(default_factory=list) # String literals it points to
# Every function that uses the section, which decides whether the link kept it
users: list[str] = field(default_factory=list)
@dataclass
class StringLiteral:
"""A string literal kept in RAM (.rodata on ESP8266)."""
text: str
size: int # Bytes in RAM; strings shared or merged into a longer one count once
# Component -> functions or objects that use it, mangled
users: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
@dataclass
class ComponentMemory:
"""Tracks memory usage for a component."""
@@ -152,6 +202,201 @@ class ComponentMemory:
return self.data_size + self.bss_size
@dataclass
class _RodataSection:
"""An object's .rodata section and the byte ranges its symbols cover."""
name: str
size: int
covered: list[tuple[int, int]] = field(default_factory=list) # (offset, size)
@cached_property
def _named(self) -> frozenset[int]:
return frozenset(
offset
for start, size in self.covered
for offset in range(start, min(start + size, self.size))
)
def is_unnamed(self, offset: int) -> bool:
return 0 <= offset < self.size and offset not in self._named
@property
def unnamed_size(self) -> int:
return self.size - len(self._named)
def _parse_string_sections(output: str) -> dict[str, dict[str, tuple[str, str | None]]]:
"""Find string literal sections in ``readelf -SW`` output.
Returns:
Object path -> section index -> (section name, function it is named after)
"""
sections: dict[str, dict[str, tuple[str, str | None]]] = defaultdict(dict)
file_path = ""
for line in output.splitlines():
if line.startswith("File: "):
file_path = line[6:]
elif (match := _OBJ_SECTION_PATTERN.match(line)) is not None:
index, name, section_type, size_hex, flags = match.groups()
if (
section_type == "PROGBITS"
and "S" in flags
and int(size_hex, 16)
and (string := _STRING_SECTION_PATTERN.match(name))
):
sections[file_path][index] = (name, string.group(1))
return sections
def _ram_string_size(text: bytes, rodata: bytes, literals: Container[bytes]) -> int:
"""Bytes a literal takes in RAM: 0 if dropped or only the tail of another literal."""
needle = text + b"\0"
start = rodata.find(needle)
while start != -1:
head = rodata.rfind(b"\0", 0, start) + 1
end = start + len(text)
# Only the tail of a longer literal of ours if one starts before it; other
# bytes there are data, or a literal from an object that was not scanned
if not any(rodata[k:end] in literals for k in range(head, start)):
return len(needle)
start = rodata.find(needle, start + 1)
return 0
def _parse_hex_dumps(output: str) -> dict[str, bytearray]:
"""Collect the bytes of each ``readelf -x`` section dump by section name."""
contents: dict[str, bytearray] = defaultdict(bytearray)
section: str | None = None
for line in output.splitlines():
if line.startswith("Hex dump of section"):
section = line.split("'")[1]
elif section is not None and line.startswith(_HEX_DUMP_PREFIX):
contents[section] += bytes.fromhex(line[_HEX_DUMP_COLUMNS].replace(" ", ""))
elif line and not line.startswith(" "):
section = None
return contents
def _parse_relocations(output: str) -> list[tuple[str, int, str, int]]:
"""List (relocated section, offset, target, addend) from ``readelf -rW`` output."""
relocations: list[tuple[str, int, str, int]] = []
section: str | None = None
for line in output.splitlines():
if line.startswith("Relocation section"):
match = _RELOC_SECTION_PATTERN.match(line)
section = match.group(1) if match else None
elif line[:1].isdigit() and section is not None:
# Offset Info Type Sym.Value Sym.Name + Addend
fields = line.split()
if len(fields) < 5:
continue
addend = int(fields[6], 16) if len(fields) > 6 else 0
if len(fields) > 6 and fields[5] == "-":
addend = -addend
relocations.append((section, int(fields[0], 16), fields[4], addend))
elif line and line[0] != " ":
section = None
return relocations
def _pointer_target(referrer: bytes, offset: int, addend: int) -> int | None:
"""Offset a relocated pointer at ``offset`` points to, or None if not dumped."""
if offset < 0 or offset + 4 > len(referrer):
return None
# Xtensa keeps part of the addend in the relocated word
return addend + int.from_bytes(referrer[offset : offset + 4], "little")
def _pointed_literal(
referrer: bytes, offset: int, addend: int, strings: bytes
) -> bytes | None:
"""Read the string literal a relocated pointer refers to, or None if out of range."""
start = _pointer_target(referrer, offset, addend)
if start is None or not 0 <= start < len(strings):
return None
end = strings.find(b"\0", start)
return bytes(strings[start : end if end != -1 else len(strings)])
def _section_user(section: str) -> str | None:
"""The function or data symbol a -ffunction-sections/-fdata-sections name is for."""
match = _CODE_SECTION_PATTERN.match(section) or _DATA_SECTION_PATTERN.match(section)
return match.group(1) if match else None
@dataclass
class _FunctionDefinitions:
"""Where each function is defined across the scanned objects."""
weak: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
strong: set[str] = field(default_factory=set) # Global, non-weak
count: Counter[str] = field(default_factory=Counter) # Objects defining it
def linked_copy_unknown(self, file_path: str, functions: list[str]) -> bool:
"""True if another object's copy of these functions may be the linked one."""
if not functions:
return False
if self.weak[file_path].issuperset(functions):
# Inline copies: dropped only once a strong definition replaces every user
return self.strong.issuperset(functions)
# Same-named local functions in several objects; the ELF can't tell them apart
return all(f not in self.strong and self.count[f] > 1 for f in functions)
def _parse_unnamed_rodata(
output: str,
) -> tuple[dict[str, dict[str, _RodataSection]], _FunctionDefinitions]:
"""Find .rodata sections with bytes no symbol covers in ``readelf -SW -sW`` output.
Returns:
Object path -> section index -> section, and where functions are defined
"""
sections: dict[str, dict[str, _RodataSection]] = defaultdict(dict)
definitions = _FunctionDefinitions()
file_path = ""
for line in output.splitlines():
if line.startswith("File: "):
file_path = line[6:]
elif match := _OBJ_SECTION_PATTERN.match(line):
index, name, section_type, size_hex, flags = match.groups()
# Merged sections (string literals) are shared between objects
if (
section_type == "PROGBITS"
and name.startswith(".rodata")
and "A" in flags
and "M" not in flags
):
sections[file_path][index] = _RodataSection(name, int(size_hex, 16))
else:
# Num: Value Size Type Bind Vis Ndx Name
fields = line.split()
if len(fields) < 8:
continue
if fields[3] == "OBJECT" and (
section := sections[file_path].get(fields[6])
):
section.covered.append((int(fields[1], 16), int(fields[2], 0)))
elif fields[3] == "FUNC" and fields[6] != "UND":
definitions.count[fields[7]] += 1
if fields[4] == "WEAK":
definitions.weak[file_path].add(fields[7])
elif fields[4] == "GLOBAL":
definitions.strong.add(fields[7])
unnamed = {
path: kept
for path, file_sections in sections.items()
if (
kept := {
index: section
for index, section in file_sections.items()
if section.unnamed_size
}
)
}
return unnamed, definitions
class MemoryAnalyzer:
"""Analyzes memory usage from ELF files."""
@@ -231,6 +476,10 @@ class MemoryAnalyzer:
self._heuristic_to_lib: dict[str, str] = {}
# Function call counts: mangled_name -> call_count
self._function_call_counts: Counter[str] = Counter()
# True on ESP8266, where .rodata is RAM; it is then counted as .data
self.rodata_in_ram = False
self._unnamed_constants: list[UnnamedConstant] = []
self._string_literals: list[StringLiteral] = []
def analyze(self) -> dict[str, ComponentMemory]:
"""Analyze the ELF file and return component memory usage."""
@@ -240,6 +489,7 @@ class MemoryAnalyzer:
self._scan_source_symbols()
self._categorize_symbols()
self._analyze_cswtch_symbols()
self._analyze_object_rodata()
self._analyze_sdk_libraries()
self._analyze_function_calls()
return dict(self.components)
@@ -253,18 +503,17 @@ class MemoryAnalyzer:
check=True,
)
# Parse section headers
for line in result.stdout.splitlines():
# Look for section entries
if not (match := _READELF_SECTION_PATTERN.match(line)):
continue
raw_sections = [
(match.group(1), int(match.group(2), 16))
for line in result.stdout.splitlines()
if (match := _READELF_SECTION_PATTERN.match(line))
]
self.rodata_in_ram = any(
name == _ESP8266_FLASH_CODE_SECTION for name, _ in raw_sections
)
section_name = match.group(1)
size_hex = match.group(2)
size = int(size_hex, 16)
# Map to standard section name
mapped_section = map_section_name(section_name)
for section_name, size in raw_sections:
mapped_section = self._standard_section(map_section_name(section_name))
if not mapped_section:
continue
@@ -272,6 +521,12 @@ class MemoryAnalyzer:
self.sections[mapped_section] = MemorySection(mapped_section)
self.sections[mapped_section].total_size += size
def _standard_section(self, section: str | None) -> str | None:
"""Count .rodata as .data where it is RAM (ESP8266)."""
if section == ".rodata" and self.rodata_in_ram:
return ".data"
return section
def _parse_symbols(self) -> None:
"""Parse symbols from ELF file."""
result = subprocess.run(
@@ -289,14 +544,18 @@ class MemoryAnalyzer:
continue
section, name, size, address = symbol_info
section = self._standard_section(section)
if section not in self.sections:
continue
# Every alias is in the ELF, even when its size is counted once
self._elf_symbol_names.add(name)
# Skip duplicate symbols at the same address (e.g., C1/C2 constructors)
if address in seen_addresses or section not in self.sections:
if address in seen_addresses:
continue
self.sections[section].symbols.append((name, size, ""))
self.sections[section].symbol_size += size
self._elf_symbol_names.add(name)
seen_addresses.add(address)
def _categorize_symbols(self) -> None:
@@ -925,10 +1184,12 @@ class MemoryAnalyzer:
Path to the directory containing .o files, or None if not found.
"""
# The ELF is typically at .pioenvs/<env>/firmware.elf
# Object files are in .pioenvs/<env>/src/ and .pioenvs/<env>/lib*/
# PlatformIO puts object files in .pioenvs/<env>/src/ and .pioenvs/<env>/lib*/,
# the native ESP8266 build in .pioenvs/<env>/obj/src/ and obj/lib/
pioenvs_dir = self.elf_path.parent
if pioenvs_dir.exists() and any(pioenvs_dir.glob("src/*.o")):
return pioenvs_dir
for obj_dir in (pioenvs_dir, pioenvs_dir / "obj"):
if any(obj_dir.glob("src/*.o")):
return obj_dir
return None
@staticmethod
@@ -1110,6 +1371,12 @@ class MemoryAnalyzer:
):
return f"{_COMPONENT_PREFIX_LIB}{self._lib_hash_to_name[parts[1]]}"
# Native ESP8266 build: lib/<owner>__<Name>/... and the Arduino core in core/
if len(parts) >= 3 and parts[0] == "lib":
return f"{_COMPONENT_PREFIX_LIB}{parts[1].rsplit('__', 1)[-1].lower()}"
if len(parts) >= 2 and parts[0] == "core":
return _COMPONENT_ARDUINO_CORE
# Other framework/library files - return the first path component
# e.g., FrameworkArduino/... -> FrameworkArduino
return parts[0] if parts else source_file
@@ -1191,9 +1458,7 @@ class MemoryAnalyzer:
and other_mem is not None
):
other_mem.add_section_size(section_name, -size)
if component not in self.components:
self.components[component] = ComponentMemory(component)
self.components[component].add_section_size(section_name, size)
self._add_component_size(component, section_name, size)
# Sort by size descending
self._cswtch_symbols.sort(key=lambda x: x[1], reverse=True)
@@ -1205,6 +1470,350 @@ class MemoryAnalyzer:
total_size,
)
def _analyze_object_rodata(self) -> None:
"""Attribute .rodata without a symbol, which is RAM on ESP8266, to components."""
if not self.rodata_in_ram:
return
if (obj_dir := self._find_object_files_dir()) is None:
_LOGGER.warning(
"No object files next to the ELF, so constants in RAM are not listed"
)
return
objects = [str(f) for f in sorted(obj_dir.rglob("*.o"))]
result = run_tool([self.readelf_path, "-SW", "-sW", *objects])
if result is None:
_LOGGER.warning("readelf failed, so constants in RAM are not listed")
return
if result.returncode != 0:
# readelf still prints every object it could read
_LOGGER.warning(
"readelf could not read every object: %s", result.stderr.strip()
)
output = result.stdout
# readelf names each file only when given several
if len(objects) == 1:
output = f"File: {objects[0]}\n{output}"
self._attribute_unnamed_constants(output, obj_dir)
self._attribute_string_literals(output, obj_dir)
names = {n for c in self._unnamed_constants for n in c.functions} | {
n
for literal in self._string_literals
for users in literal.users.values()
for n in users
}
self._batch_demangle_symbols(sorted(names - self._demangle_cache.keys()))
def _attribute_unnamed_constants(self, output: str, obj_dir: Path) -> None:
"""Attribute constants GCC copies into objects built from constant initializers."""
unnamed, definitions = _parse_unnamed_rodata(output)
# Inline (weak) functions are emitted, with their constants, in every
# object that uses them; the linker keeps one copy
inline_copies: dict[tuple[tuple[str, ...], int], str] = {}
for file_path, sections in unnamed.items():
rel_path = Path(file_path).relative_to(obj_dir).as_posix()
component = self._source_file_to_component(rel_path)
for constant in self._scan_object_constants(
file_path, sections, rel_path, component
):
# Only linked users can show which object's copy survived
live = [u for u in constant.users if u in self._elf_symbol_names]
if definitions.linked_copy_unknown(file_path, live):
continue
if definitions.weak[file_path].issuperset(live):
key = (tuple(live), constant.size)
if inline_copies.setdefault(key, file_path) != file_path:
continue
self._unnamed_constants.append(constant)
self._charge_ram(component, constant.size)
self._unnamed_constants.sort(key=lambda c: c.size, reverse=True)
def _attribute_string_literals(self, output: str, obj_dir: Path) -> None:
"""Attribute string literals in RAM to the components whose code uses them.
The linker merges identical literals across objects, so each is counted
once, from the bytes it really takes in the ELF's .rodata.
"""
result = run_tool([self.readelf_path, "-x", ".rodata", str(self.elf_path)])
rodata = _parse_hex_dumps(result.stdout).get(".rodata") if result else None
if not rodata:
_LOGGER.warning(
"No .rodata dump of %s, so string literals are not listed",
self.elf_path,
)
return
rodata = bytes(rodata)
_, definitions = _parse_unnamed_rodata(output)
# Literal -> component -> functions or data using it
users: dict[bytes, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
for file_path, sections in _parse_string_sections(output).items():
component = self._source_file_to_component(
Path(file_path).relative_to(obj_dir).as_posix()
)
for text, found in self._scan_object_strings(file_path, sections).items():
kept = {
user
for user in found & self._elf_symbol_names
if not definitions.linked_copy_unknown(file_path, [user])
}
if text and kept:
users[text][component] |= kept
literals = (
StringLiteral(
text.decode("utf-8", "backslashreplace"),
_ram_string_size(text, rodata, users),
by_component,
)
for text, by_component in users.items()
)
self._string_literals = sorted(
(s for s in literals if s.size), key=lambda s: (-s.size, s.text)
)
for literal in self._string_literals:
# A literal used by several components is not charged to any of them
if len(literal.users) == 1:
self._charge_ram(next(iter(literal.users)), literal.size)
def _scan_object_strings(
self, file_path: str, sections: dict[str, tuple[str, str | None]]
) -> dict[bytes, set[str]]:
"""Map each string literal of one object to the functions or data using it.
Args:
file_path: Object file to read
sections: String sections by index, with the function each is named after
"""
result = run_tool(
[self.readelf_path, "-rW"]
+ [arg for index in sections for arg in ("-x", index)]
+ [file_path]
)
if result is None or result.returncode != 0:
_LOGGER.warning(
"readelf failed reading strings of %s: %s",
file_path,
result.stderr.strip() if result else "not run",
)
return {}
contents = _parse_hex_dumps(result.stdout)
users: dict[bytes, set[str]] = defaultdict(set)
# Section -> the function it is named after, None for the shared section
owners = dict(sections.values())
for name, function in owners.items():
# A section named after a function holds its literals; others may use them too
if function:
for text in bytes(contents[name]).split(b"\0")[:-1]:
users[text].add(function)
relocations = _parse_relocations(result.stdout)
# Functions using each unnamed table, for literals reached through a pointer in it
table_users: dict[str, set[str]] = defaultdict(set)
for section, _, target, _ in relocations:
if (
target.startswith(".rodata")
and not _STRING_SECTION_PATTERN.match(target)
and (function := _section_user(section))
and _CODE_SECTION_PATTERN.match(section)
):
table_users[target].add(function)
# Other users are found from the pointers they hold
refs = [
(section, found, offset, target, addend)
for section, offset, target, addend in relocations
if target in owners
and (
found := {u} if (u := _section_user(section)) else table_users[section]
)
and found != {owners[target]}
]
unnamed = sum(
1
for section, _, target, _ in relocations
if target in owners
and owners[target] is None
and not _section_user(section)
and not table_users[section]
)
if unnamed:
_LOGGER.debug(
"%s: %d string pointers with no named user", file_path, unnamed
)
if not refs:
return users
result = run_tool(
[self.readelf_path]
+ [arg for name in sorted({r[0] for r in refs}) for arg in ("-x", name)]
+ [file_path]
)
if result is None or result.returncode != 0:
_LOGGER.warning(
"readelf failed reading string users of %s: %s",
file_path,
result.stderr.strip() if result else "not run",
)
return users
referrers = _parse_hex_dumps(result.stdout)
for section, found, offset, target, addend in refs:
# A pointer that can't be read names no literal
text = _pointed_literal(
referrers.get(section, b""), offset, addend, contents[target]
)
if text is not None:
users[text] |= found
return users
def _scan_object_constants(
self,
file_path: str,
sections: dict[str, _RodataSection],
rel_path: str,
component: str,
) -> list[UnnamedConstant]:
"""Return the unnamed constants of one object that the link kept.
Args:
file_path: Object file to read
sections: Candidate sections by index
rel_path: Object path shown in the report
component: Component the object belongs to
"""
result = run_tool(
[self.readelf_path, "-rW"]
+ [arg for index in sections for arg in ("-x", index)]
+ [file_path]
)
if result is None or result.returncode != 0:
_LOGGER.warning(
"readelf failed reading constants of %s: %s",
file_path,
result.stderr.strip() if result else "not run",
)
return []
by_name = {section.name: section for section in sections.values()}
contents = _parse_hex_dumps(result.stdout)
# Every function using the section decides whether the link kept it
users: dict[str, set[str]] = defaultdict(set)
# Functions shown as using the unnamed bytes
functions: dict[str, set[str]] = defaultdict(set)
# (code section, offset, addend, target, function) into sections with named bytes
mixed: list[tuple[str, int, int, str, str]] = []
jump_tables: set[str] = set()
# Section -> (offset, string section, addend) of each pointer to a literal
pointers: dict[str, list[tuple[int, str, int]]] = defaultdict(list)
for section, offset, target, addend in _parse_relocations(result.stdout):
if target in by_name:
code = _CODE_SECTION_PATTERN.match(section)
if code and code.group(1):
users[target].add(code.group(1))
if by_name[target].covered:
mixed.append((section, offset, addend, target, code.group(1)))
else:
functions[target].add(code.group(1))
elif section in by_name and by_name[section].is_unnamed(offset):
if _CODE_SECTION_PATTERN.match(target):
jump_tables.add(section)
elif ".str" in target:
pointers[section].append((offset, target, addend))
self._add_mixed_section_users(file_path, mixed, by_name, functions)
strings = self._read_pointed_strings(file_path, pointers, contents)
return [
UnnamedConstant(
size=section.unnamed_size,
source_file=rel_path,
component=component,
functions=sorted(functions[name]),
jump_table=name in jump_tables,
data=bytes(
byte
for offset, byte in enumerate(contents[name])
if section.is_unnamed(offset)
)[:16],
strings=strings.get(name, []),
users=sorted(users[name]),
)
for name, section in by_name.items()
# Dropped by --gc-sections unless a function that uses it was kept
if not self._elf_symbol_names.isdisjoint(users[name])
]
def _add_mixed_section_users(
self,
file_path: str,
mixed: list[tuple[str, int, int, str, str]],
by_name: dict[str, _RodataSection],
functions: dict[str, set[str]],
) -> None:
"""Name only the functions that point at the unnamed bytes of a mixed section."""
if not mixed:
return
result = run_tool(
[self.readelf_path]
+ [arg for name in sorted({m[0] for m in mixed}) for arg in ("-x", name)]
+ [file_path]
)
code = _parse_hex_dumps(result.stdout) if result else {}
for section, offset, addend, target, function in mixed:
start = _pointer_target(code.get(section, b""), offset, addend)
# A pointer that can't be read, or lands outside the section, keeps the user
section_size = by_name[target].size
if (
start is None
or not 0 <= start < section_size
or by_name[target].is_unnamed(start)
):
functions[target].add(function)
def _read_pointed_strings(
self,
file_path: str,
pointers: dict[str, list[tuple[int, str, int]]],
contents: dict[str, bytearray],
) -> dict[str, list[str]]:
"""Resolve each constant's pointers to string literals, in offset order."""
if not pointers:
return {}
targets = sorted({t for refs in pointers.values() for _, t, _ in refs})
result = run_tool(
[self.readelf_path]
+ [arg for target in targets for arg in ("-x", target)]
+ [file_path]
)
if result is None or result.returncode != 0:
_LOGGER.warning(
"readelf failed reading strings of %s: %s",
file_path,
result.stderr.strip() if result else "not run",
)
return {}
literals = _parse_hex_dumps(result.stdout)
resolved: dict[str, list[str]] = {}
for name, refs in pointers.items():
texts = [
_pointed_literal(
contents[name], offset, addend, literals.get(target, b"")
)
for offset, target, addend in sorted(refs)
]
# One pointer that can't be read makes the list misleading; show bytes instead
if all(text is not None for text in texts):
resolved[name] = [t.decode("utf-8", "backslashreplace") for t in texts]
return resolved
def _charge_ram(self, component: str, size: int) -> None:
"""Charge RAM bytes that have no ELF symbol to a component."""
self._add_component_size(component, ".data", size)
if (data_section := self.sections.get(".data")) is not None:
data_section.symbol_size += size
def _add_component_size(self, component: str, section: str, size: int) -> None:
"""Add size to a component, creating it under its own name."""
if component not in self.components:
self.components[component] = ComponentMemory(component)
self.components[component].add_section_size(section, size)
def _analyze_function_calls(self) -> None:
"""Count function call sites by parsing disassembly output.
+88 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable
import heapq
import json
from operator import itemgetter
from pathlib import Path
import sys
@@ -23,7 +24,7 @@ from . import (
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
if TYPE_CHECKING:
from . import ComponentMemory
from . import ComponentMemory, StringLiteral
def _format_pstorage_name(name: str) -> str:
@@ -46,6 +47,8 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
)
# Lower threshold for RAM symbols (RAM is more constrained)
RAM_SYMBOL_SIZE_THRESHOLD: int = 24
# String literals listed per component in the RAM strings report
STRINGS_PER_COMPONENT: int = 10
# Number of top symbols to show in the largest symbols report
TOP_SYMBOLS_LIMIT: int = 30
# Width for symbol name display in top symbols report
@@ -204,6 +207,79 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}"
)
def _add_unnamed_constants_analysis(self, lines: list[str]) -> None:
"""Add the unnamed constants (ESP8266 .rodata in RAM) section."""
self._add_section_header(lines, "Unnamed Constants in RAM (.rodata)")
total_size = sum(c.size for c in self._unnamed_constants)
lines.append(
f"Total: {len(self._unnamed_constants)} constant(s), {total_size:,} B. "
"GCC copies these into objects built from constant initializers;"
)
lines.append(
"PROGMEM tables, assigned fields or a user-provided constructor avoid the copy."
)
lines.append("")
for constant in self._unnamed_constants:
kind = "switch table" if constant.jump_table else "constant"
lines.append(
f"{constant.size:>6,} B {kind:<12} {constant.component} ({constant.source_file})"
)
lines.extend(
f"{'':>9} used by {self._demangle_symbol(function)}"
for function in constant.functions
)
if constant.strings:
# Escaped, so control characters in a literal can't disturb the terminal
shown = ", ".join(
json.dumps(text[:40]) for text in constant.strings[:4]
)
more = ", ..." if len(constant.strings) > 4 else ""
lines.append(f"{'':>9} points to {shown}{more}")
elif constant.data and not constant.jump_table:
more = " ..." if constant.size > len(constant.data) else ""
lines.append(f"{'':>9} bytes: {constant.data.hex(' ')}{more}")
lines.append("")
def _add_string_literals_analysis(self, lines: list[str]) -> None:
"""Add the string literals in RAM (ESP8266 .rodata) section, by component."""
self._add_section_header(lines, "String Literals in RAM (.rodata)")
total_size = sum(s.size for s in self._string_literals)
lines.append(
f"Total: {len(self._string_literals)} string(s), {total_size:,} B. "
"LOG_STR, ESPHOME_F or a PROGMEM table keeps a literal in flash."
)
lines.append(
"Only literals whose user is known are counted; one used by several components is "
"listed under each and charged to none."
)
lines.append("")
by_component: dict[str, list[StringLiteral]] = defaultdict(list)
for literal in self._string_literals:
for component in literal.users:
by_component[component].append(literal)
totals = {c: sum(s.size for s in ls) for c, ls in by_component.items()}
for component, literals in sorted(
by_component.items(), key=lambda x: (-totals[x[0]], x[0])
):
comp_total = totals[component]
lines.append(f"{component} ({comp_total:,} B, {len(literals)} strings):")
for literal in literals[: self.STRINGS_PER_COMPONENT]:
text = json.dumps(literal.text)
if len(text) > 50:
text = f'{text[:47]}..."'
users = sorted(
self._demangle_symbol(u) for u in literal.users[component]
)
used_by = users[0][:60] + (
f" (+{len(users) - 1})" if len(users) > 1 else ""
)
lines.append(f" {literal.size:>5,} B {text:<50} {used_by}")
if len(literals) > self.STRINGS_PER_COMPONENT:
lines.append(
f" ... and {len(literals) - self.STRINGS_PER_COMPONENT} more"
)
lines.append("")
def _add_cswtch_analysis(self, lines: list[str]) -> None:
"""Add CSWTCH (GCC switch table lookup) analysis section."""
self._add_section_header(lines, "CSWTCH Analysis (GCC Switch Table Lookups)")
@@ -394,6 +470,12 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
f"{total_flash:>{self.COL_TOTAL_FLASH - 2},} B | {total_ram:>{self.COL_TOTAL_RAM - 2},} B"
)
if self.rodata_in_ram:
lines.append("")
lines.append(
"ESP8266 keeps .rodata in RAM, so constants are counted in RAM (data)."
)
# Show unattributed RAM (SDK/framework overhead)
unattributed_bss, unattributed_data, unattributed_total = (
self.get_unattributed_ram()
@@ -657,6 +739,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(f" ... and {len(large_ram_syms) - 10} more")
lines.append("")
if self._unnamed_constants:
self._add_unnamed_constants_analysis(lines)
if self._string_literals:
self._add_string_literals_analysis(lines)
# CSWTCH (GCC switch table) analysis
if self._cswtch_symbols:
self._add_cswtch_analysis(lines)
@@ -738,7 +825,6 @@ def main():
build_dir = sys.argv[1]
# Load build directory
import json
from esphome.platformio.toolchain import IDEData
+21 -64
View File
@@ -36,7 +36,6 @@ from esphome.cpp_generator import (
LambdaExpression,
MockObj,
MockObjClass,
Statement,
TemplateArgsType,
call_lambda,
)
@@ -705,16 +704,11 @@ def apply_static_function(
statements: list[str],
share: bool = False,
suffix: str = "fn",
add_global_: Callable[[Statement], object] | None = None,
) -> MockObj:
"""Emit the static function for ``id_``; ``suffix`` names a second one, ``share`` reuses an identical one.
``add_global_`` places the declaration (e.g. at a ``LatePvariable``); such a function is never shared.
"""
name = _apply_function_name(id_, suffix)
if add_global_ is not None:
return cg.static_function(name, return_type, params, statements, add_global_)
return cg.shared_static_function(name, return_type, params, statements, share=share)
"""Emit the static function for ``id_``; ``suffix`` names a second one, ``share`` reuses an identical one."""
return cg.shared_static_function(
_apply_function_name(id_, suffix), return_type, params, statements, share=share
)
def _apply_function_name(id_: ID, suffix: str = "fn") -> str:
@@ -1909,31 +1903,19 @@ def _emit_inline_run(
"""
if len(run) == 1 and run[0].emit is not None:
return run[0].emit()
statements = _run_statements(run)
id_ = run[0].id_.copy()
id_.type = ApplyRunAction
return cg.new_Pvariable(
id_, cg.TemplateArguments(_run_function(id_, run, args), *template_arg)
)
def _run_function(
id_: ID,
run: list[_InlineAction],
args: TemplateArgsType,
stoppable: bool = True,
add_global_: Callable[[Statement], object] | None = None,
) -> MockObj:
"""Emit the function that runs ``run``; a stoppable one takes the run count first and
returns once a stop clears it. ``add_global_`` is passed to ``cg.static_function``."""
params = _apply_params(args)
statements = _run_statements(run)
if stoppable:
params = [(cg.RawExpression("const int &"), _RUNNING_PARAM), *params]
else:
statements = [line for line in statements if line != _STOP_CHECK]
return apply_static_function(
id_, cg.void, params, statements, add_global_=add_global_
fn = apply_static_function(
id_,
cg.void,
[
(cg.RawExpression("const int &"), _RUNNING_PARAM),
*_apply_params(args),
],
statements,
)
return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg))
async def _build_action_plan(
@@ -2116,10 +2098,10 @@ def _automation_from_plan(
def _is_direct(plan: list[MockObj | list[_InlineAction]], config: ConfigType) -> bool:
"""Whether an automation is all inline actions without a manual id, so it needs no objects.
"""Whether a callback automation is all inline actions, so it needs no objects.
Without a Trigger or a manual id nothing can reach such an automation to stop it, so a
caller that creates no Trigger needs no stop checks either.
Without a Trigger or a manual id nothing can reach such an automation to stop it, so
no stop checks are needed either. Only for callers that create no Trigger.
"""
return (
not config[CONF_AUTOMATION_ID].is_manual
@@ -2132,15 +2114,13 @@ def _direct_function(
plan: list[MockObj | list[_InlineAction]],
args: TemplateArgsType,
config: ConfigType,
id_: ID | None = None,
stoppable: bool = False,
add_global_: Callable[[Statement], object] | None = None,
) -> MockObj | None:
"""The function that runs ``plan`` if it is direct, named after ``id_`` (default: its
first action); see ``_run_function``."""
if not _is_direct(plan, config):
return None
return _run_function(id_ or plan[0][0].id_, plan[0], args, stoppable, add_global_)
statements = [line for line in _run_statements(plan[0]) if line != _STOP_CHECK]
return apply_static_function(
plan[0][0].id_, cg.void, _apply_params(args), statements
)
def _when_call(
@@ -2160,29 +2140,6 @@ async def build_automation(
return await new_automation(args, config, trigger)
async def build_automation_or_run_function(
trigger: MockObj,
args: TemplateArgsType,
config: ConfigType,
add_global_: Callable[[Statement], object] | None = None,
) -> MockObj | None:
"""Build ``config`` on ``trigger`` like ``build_automation`` and return None; if every
action is inline, return the stoppable function ``esphome__<trigger id>__fn`` instead."""
plan = await _build_automation_plan(args, config)
if (
fn := _direct_function(
plan,
args,
config,
id_=trigger.base,
stoppable=True,
add_global_=add_global_,
)
) is None:
_automation_from_plan(plan, args, config, trigger)
return fn
async def build_trigger_callback(
args: TemplateArgsType,
config: ConfigType,
-1
View File
@@ -12,7 +12,6 @@ from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
Expression,
FlashStringLiteral,
LatePvariable,
LineComment,
LogStringLiteral,
MockObj,
+42 -7
View File
@@ -1,3 +1,5 @@
from typing import Any
from esphome import automation, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
@@ -13,6 +15,7 @@ from esphome.const import (
CONF_VALUE,
CONF_WAIT_TIME,
)
from esphome.types import ConfigType
CODEOWNERS = ["@lygris", "@gabest11"]
DEPENDENCIES = ["spi"]
@@ -22,6 +25,8 @@ MULTI_CONF = True
ns = cg.esphome_ns.namespace("cc1101")
CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice)
CC1101Listener = ns.class_("CC1101Listener")
PACKET_TYPE = cg.std_vector.template(cg.uint8)
RX_FIFO_SIZE = 64
# Config keys
CONF_RX_ATTENUATION = "rx_attenuation"
@@ -241,20 +246,34 @@ CONFIG_MAP = {
cv.Optional(CONF_BS_POST_KI): cv.enum(BS_POST_KI, upper=False),
cv.Optional(CONF_BS_POST_KP): cv.enum(BS_POST_KP, upper=False),
cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean,
cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t,
cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean,
cv.Optional(CONF_WHITENING, default=False): cv.boolean,
}
def _validate_packet_mode(config):
if config.get(CONF_PACKET_MODE, False):
def _is_long_packet(packet_length: Any) -> bool:
"""A packet_length that does not fit the RX FIFO, or is a lambda."""
return cg.is_template(packet_length) or (packet_length or 0) > RX_FIFO_SIZE
def _validate_packet_mode(config: ConfigType) -> ConfigType:
packet_mode = config.get(CONF_PACKET_MODE, False)
packet_length = config.get(CONF_PACKET_LENGTH)
if packet_mode:
if CONF_GDO0_PIN not in config:
raise cv.Invalid("gdo0_pin is required when packet_mode is enabled")
if CONF_PACKET_LENGTH not in config:
if packet_length is None:
raise cv.Invalid("packet_length is required when packet_mode is enabled")
if config[CONF_PACKET_LENGTH] > 64:
raise cv.Invalid("packet_length must be <= 64 (FIFO size)")
elif cg.is_template(packet_length):
raise cv.Invalid("packet_mode is required when packet_length is a lambda")
if (
packet_mode
and _is_long_packet(packet_length)
and config.get(CONF_CRC_ENABLE, False)
):
raise cv.Invalid(
"crc_enable is not supported with packet_length > 64 or a lambda"
)
return config
@@ -263,6 +282,9 @@ CONFIG_SCHEMA = cv.All(
{
cv.GenerateID(): cv.declare_id(CC1101Component),
cv.Optional(CONF_GDO0_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_PACKET_LENGTH): cv.templatable(
cv.int_range(min=0, max=1024)
),
cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True),
}
)
@@ -282,11 +304,24 @@ async def to_code(config):
cg.add(getattr(var, f"set_{key}")(config[key]))
await cg.add_pin(var.set_gdo0_pin, config.get(CONF_GDO0_PIN))
if (packet_length := config.get(CONF_PACKET_LENGTH)) is not None:
# packet_mode can also be switched on at run time, so this does not check it
if _is_long_packet(packet_length):
cg.add_define("USE_CC1101_LONG_PACKET")
if cg.is_template(packet_length):
lambda_ = await cg.process_lambda(
packet_length,
[(PACKET_TYPE.operator("ref").operator("const"), "x")],
return_type=cg.int32,
)
cg.add(var.set_packet_length_lambda(lambda_))
else:
cg.add(var.set_packet_length(packet_length))
if CONF_ON_PACKET in config:
await automation.build_automation(
var.get_packet_trigger(),
[
(cg.std_vector.template(cg.uint8), "x"),
(PACKET_TYPE, "x"),
(cg.float_, "freq_offset"),
(cg.float_, "rssi"),
(cg.uint8, "lqi"),
+353 -33
View File
@@ -1,8 +1,10 @@
#include "cc1101.h"
#include "cc1101pa.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <algorithm>
#include <cmath>
#include <cstring>
@@ -14,9 +16,15 @@ namespace esphome::cc1101 {
ESPHOME_LOG_TAG(TAG, "cc1101");
static constexpr size_t TX_FIFO_SIZE = 64;
static constexpr size_t RX_FIFO_SIZE = 64;
// RX FIFO threshold for FIFO_THR = 7 in long packet mode
static constexpr uint8_t RX_FIFO_THRESHOLD = 32;
// The crystal takes about 150 us to start after CSn goes low
static constexpr uint32_t WAKEUP_DELAY_US = 200;
static float freq_offset_hz(uint8_t freqest) { return static_cast<int8_t>(freqest) * (XTAL_FREQUENCY / (1 << 14)); }
static float rssi_dbm(int8_t rssi) { return (rssi * RSSI_STEP) - RSSI_OFFSET; }
// MDMCFG2 MOD_FORMAT values
PROGMEM_STRING_TABLE(ModulationStrings, "2-FSK", "GFSK", "UNUSED", "ASK/OOK", "4-FSK", "UNUSED", "UNUSED", "MSK");
@@ -114,8 +122,6 @@ CC1101Component::CC1101Component() {
memset(this->pa_table_, 0, sizeof(this->pa_table_));
}
void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_loop_soon_any_context(); }
void CC1101Component::setup() {
this->spi_setup();
@@ -193,7 +199,220 @@ void CC1101Component::call_listeners_(const std::vector<uint8_t> &packet, float
this->packet_trigger_.trigger(packet, freq_offset, rssi, lqi);
}
float CC1101Component::symbol_rate_() const {
return (((256.0f + this->state_.DRATE_M) * (1 << this->state_.DRATE_E)) / (1 << 28)) * XTAL_FREQUENCY;
}
#ifdef USE_CC1101_LONG_PACKET
bool CC1101Component::is_long_packet_mode_() const {
return this->packet_mode_ && (this->packet_length_ > RX_FIFO_SIZE || this->packet_length_func_ != nullptr);
}
void CC1101Component::arm_length_(uint16_t length) {
// Over 255 bytes or unknown starts in infinite mode (datasheet section 15.2.2). PKTLEN must not be 0, so a
// multiple of 256 stays in infinite mode and ends on the MCU byte count alone.
this->packet_expected_length_ = length;
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(length > 0 && length < 256 ? LengthConfig::LENGTH_CONFIG_FIXED
: LengthConfig::LENGTH_CONFIG_INFINITE);
this->state_.PKTLEN = length % 256 == 0 ? 0xFF : static_cast<uint8_t>(length % 256);
}
void CC1101Component::reset_long_packet_rx_() {
this->packet_.clear();
this->packet_last_byte_ms_ = 0;
this->high_freq_.stop();
this->arm_length_(this->packet_length_func_ != nullptr ? 0 : this->packet_length_);
}
void CC1101Component::restart_long_packet_rx_() {
this->enter_idle_();
this->enter_rx_();
this->disable_loop();
}
void CC1101Component::maybe_switch_long_packet_to_fixed_() {
const uint16_t expected = this->packet_expected_length_;
if (this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED) || expected == 0 ||
expected % 256 == 0) {
return;
}
const uint16_t received = this->packet_.size();
if (expected - received >= 256) {
return;
}
// PKTLEN keeps the (length % 256) set at the start, as in TI Design Note DN500 section 2.3
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED);
this->write_(Register::PKTCTRL0);
ESP_LOGVV(TAG, "Switched long packet RX to fixed mode: received=%u expected=%u pktlen=%u", received, expected,
this->state_.PKTLEN);
}
void CC1101Component::finish_long_packet_() {
this->packet_.resize(this->packet_expected_length_);
this->read_(Register::FREQEST);
this->read_(Register::LQI);
const float freq_offset = freq_offset_hz(this->state_.FREQEST);
const uint8_t lqi = this->state_.LQI & STATUS_LQI_MASK;
// No CRC check: crc_enable is rejected for long packets, CRC_OK is only set after the last payload byte.
// Restart RX before dispatching so a callback may change settings; swapping back keeps the reserved capacity
// unless a callback reserved a larger buffer.
std::vector<uint8_t> packet;
packet.swap(this->packet_);
this->restart_long_packet_rx_();
this->call_listeners_(packet, freq_offset, rssi_dbm(this->long_packet_rssi_), lqi);
if (packet.capacity() > this->packet_.capacity()) {
packet.clear();
this->packet_.swap(packet);
}
}
void CC1101Component::handle_long_packet_(uint8_t rx_bytes) {
// Leave one byte in the FIFO unless it holds the rest of the packet (datasheet section 20)
uint16_t to_read = rx_bytes > 0 ? rx_bytes - 1 : 0;
if (this->packet_expected_length_ > 0) {
const uint16_t remaining = this->packet_expected_length_ - this->packet_.size();
if (remaining <= rx_bytes) {
to_read = remaining;
}
}
if (to_read == 0) {
if (this->packet_.empty()) {
return;
}
// Discard a packet when no byte arrived for the air time of PACKET_IDLE_TIMEOUT_BYTES
const uint32_t timeout_ms =
std::max(static_cast<uint32_t>(std::ceil((8.0f * PACKET_IDLE_TIMEOUT_BYTES * 1000.0f) / this->symbol_rate_())),
PACKET_IDLE_TIMEOUT_MIN_MS);
if (App.get_loop_component_start_time() - this->packet_last_byte_ms_ > timeout_ms) {
ESP_LOGW(TAG, "Packet receive timeout, discarding packet: received=%u expected=%u fifo=%u",
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_, rx_bytes);
this->restart_long_packet_rx_();
}
return;
}
const size_t old_size = this->packet_.size();
if (old_size == 0) {
// The RSSI register follows the live signal; sample it while the packet is still on air
this->read_(Register::RSSI);
this->long_packet_rssi_ = this->state_.RSSI;
// The FIFO threshold signal does not fire for the bytes after the last threshold crossing; poll until done
this->high_freq_.start();
}
// Read through an aligned buffer: for an unaligned target the ESP32 SPI driver allocates a bounce buffer
alignas(4) uint8_t staged[RX_FIFO_SIZE];
this->read_(Register::FIFO, staged, to_read);
this->packet_.resize(old_size + to_read);
std::memcpy(this->packet_.data() + old_size, staged, to_read);
this->packet_last_byte_ms_ = App.get_loop_component_start_time();
ESP_LOGVV(TAG, "Drained RX FIFO: read=%u received=%u expected=%u fifo=%u", to_read,
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_, rx_bytes);
if (this->packet_expected_length_ == 0 && this->packet_length_func_ != nullptr) {
const int32_t length = this->packet_length_func_(this->packet_);
if (length < 0) {
ESP_LOGVV(TAG, "Packet length lambda discarded packet: received=%u", static_cast<uint16_t>(this->packet_.size()));
this->restart_long_packet_rx_();
return;
}
if (length > MAX_PACKET_LENGTH) {
ESP_LOGW(TAG, "Packet length exceeds maximum, discarding packet: length=%" PRId32 " max=%u", length,
MAX_PACKET_LENGTH);
this->restart_long_packet_rx_();
return;
}
if (length > 0) {
this->arm_length_(static_cast<uint16_t>(length));
// A packet shorter than the first FIFO drain is already complete; finish_long_packet_() truncates it
if (this->packet_expected_length_ <= this->packet_.size()) {
this->finish_long_packet_();
return;
}
this->write_(Register::PKTLEN);
this->write_(Register::PKTCTRL0);
ESP_LOGVV(TAG, "Resolved packet length: received=%u expected=%u pktlen=%u",
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_, this->state_.PKTLEN);
}
}
// Only reachable while the length is still unknown; a known length caps every read
if (this->packet_.size() > MAX_PACKET_LENGTH) {
ESP_LOGW(TAG, "Packet length exceeds maximum, discarding packet: length=%u max=%u",
static_cast<uint16_t>(this->packet_.size()), MAX_PACKET_LENGTH);
this->restart_long_packet_rx_();
return;
}
this->maybe_switch_long_packet_to_fixed_();
if (this->packet_expected_length_ > 0 && this->packet_.size() >= this->packet_expected_length_) {
this->finish_long_packet_();
}
}
void CC1101Component::loop_long_packet_() {
if (this->sleeping_ || this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO) ||
this->gdo0_pin_ == nullptr) {
this->high_freq_.stop();
this->disable_loop();
return;
}
// While GDO0 (RX FIFO threshold) is high, the FIFO holds at least RX_FIFO_THRESHOLD bytes, so drain without
// reading RXBYTES. At high data rates RXBYTES changes between two reads, so waiting for two reads that agree
// and retrying on the next loop pass lets the FIFO overflow. MARCSTATE does not change during RX.
for (uint8_t i = 0; i < RX_FIFO_SIZE / RX_FIFO_THRESHOLD + 2 && this->gdo0_pin_->digital_read(); i++) {
this->read_(Register::MARCSTATE);
if (static_cast<State>(this->state_.MARC_STATE) == State::RXFIFO_OVERFLOW) {
ESP_LOGW(TAG, "RX FIFO overflow, discarding packet: received=%u expected=%u",
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_);
this->restart_long_packet_rx_();
return;
}
this->handle_long_packet_(RX_FIFO_THRESHOLD);
if (this->packet_.empty()) {
return; // finished or discarded
}
}
// Below the threshold the next crossing raises GDO0 again, and after the packet the count no longer changes,
// so retrying on the next loop pass is safe here. RXBYTES can be misread while bytes arrive (TI SWRZ020), so
// only trust two consecutive reads that agree.
this->read_(Register::RXBYTES);
uint8_t rx_bytes = this->state_.RXBYTES;
bool stable = false;
for (uint8_t retries = RXBYTES_STABLE_RETRIES; retries > 0 && !stable; retries--) {
const uint8_t previous = rx_bytes;
this->read_(Register::RXBYTES);
rx_bytes = this->state_.RXBYTES;
stable = rx_bytes == previous;
}
if (!stable) {
return;
}
rx_bytes = this->state_.NUM_RXBYTES;
// More than the FIFO holds can only be a misread; the staged drain buffer is FIFO sized
if (this->state_.RXFIFO_OVERFLOW || rx_bytes > RX_FIFO_SIZE) {
ESP_LOGW(TAG, "RX FIFO overflow, discarding packet: received=%u expected=%u fifo=%u",
static_cast<uint16_t>(this->packet_.size()), this->packet_expected_length_, rx_bytes);
this->restart_long_packet_rx_();
return;
}
if (rx_bytes == 0 && this->packet_.empty() && !this->gdo0_pin_->digital_read()) {
this->disable_loop();
return;
}
this->handle_long_packet_(rx_bytes);
}
#endif
void CC1101Component::loop() {
#ifdef USE_CC1101_LONG_PACKET
if (this->is_long_packet_mode_()) {
this->loop_long_packet_();
return;
}
#endif
this->disable_loop();
if (this->sleeping_ || this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO) ||
this->gdo0_pin_ == nullptr || !this->gdo0_pin_->digital_read()) {
@@ -221,7 +440,7 @@ void CC1101Component::loop() {
payload_length = this->state_.PKTLEN;
expected_rx = payload_length;
}
if (payload_length == 0 || payload_length > 64 || rx_bytes != expected_rx) {
if (payload_length == 0 || payload_length > RX_FIFO_SIZE || rx_bytes != expected_rx) {
ESP_LOGW(TAG, "Invalid packet: rx_bytes %u, payload_length %u", rx_bytes, payload_length);
this->enter_idle_();
this->strobe_(Command::FRX);
@@ -235,8 +454,8 @@ void CC1101Component::loop() {
this->read_(Register::FREQEST);
this->read_(Register::RSSI);
this->read_(Register::LQI);
float freq_offset = static_cast<int8_t>(this->state_.FREQEST) * (XTAL_FREQUENCY / (1 << 14));
float rssi = (this->state_.RSSI * RSSI_STEP) - RSSI_OFFSET;
float freq_offset = freq_offset_hz(this->state_.FREQEST);
float rssi = rssi_dbm(this->state_.RSSI);
bool crc_ok = (this->state_.LQI & STATUS_CRC_OK_MASK) != 0;
uint8_t lqi = this->state_.LQI & STATUS_LQI_MASK;
if (this->state_.CRC_EN == 0 || crc_ok) {
@@ -256,7 +475,7 @@ void CC1101Component::loop() {
void CC1101Component::dump_config() {
int32_t freq = static_cast<int32_t>(this->state_.FREQ2 << 16 | this->state_.FREQ1 << 8 | this->state_.FREQ0) *
XTAL_FREQUENCY / (1 << 16);
float symbol_rate = (((256.0f + this->state_.DRATE_M) * (1 << this->state_.DRATE_E)) / (1 << 28)) * XTAL_FREQUENCY;
float symbol_rate = this->symbol_rate_();
float bw = XTAL_FREQUENCY / (8.0f * (4 + this->state_.CHANBW_M) * (1 << this->state_.CHANBW_E));
ESP_LOGCONFIG(TAG,
"CC1101:\n"
@@ -271,6 +490,20 @@ void CC1101Component::dump_config() {
LOG_STR_ARG(ModulationStrings::get_log_str(this->state_.MOD_FORMAT & 0x07, 0)), symbol_rate, bw,
this->output_power_effective_);
LOG_PIN(" CS Pin: ", this->cs_);
LOG_PIN(" GDO0 Pin: ", this->gdo0_pin_);
if (this->packet_mode_) {
#ifdef USE_CC1101_LONG_PACKET
if (this->packet_length_func_ != nullptr) {
ESP_LOGCONFIG(TAG, " Packet Length: lambda");
} else
#endif
if (this->packet_length_ == 0) {
ESP_LOGCONFIG(TAG, " Packet Length: variable");
} else {
ESP_LOGCONFIG(TAG, " Packet Length: %u", this->packet_length_);
}
}
}
void CC1101Component::begin_tx() {
@@ -316,6 +549,14 @@ void CC1101Component::reset() {
void CC1101Component::set_idle() {
ESP_LOGV(TAG, "Setting IDLE state");
this->enter_idle_();
#ifdef USE_CC1101_LONG_PACKET
if (this->is_long_packet_mode_()) {
// Drop a partial packet so a pending loop pass cannot finish it and re-enter RX
this->strobe_(Command::FRX);
this->reset_long_packet_rx_();
this->disable_loop();
}
#endif
}
void CC1101Component::power_down() {
@@ -393,7 +634,18 @@ void CC1101Component::enter_idle_() {
}
}
bool CC1101Component::enter_rx_() { return this->enter_calibrated_(State::RX, Command::RX); }
bool CC1101Component::enter_rx_() {
#ifdef USE_CC1101_LONG_PACKET
if (this->is_long_packet_mode_()) {
// Every RX entry starts a fresh long packet; callers leave the chip in IDLE, where SFRX is allowed
this->strobe_(Command::FRX);
this->reset_long_packet_rx_();
this->write_(Register::PKTLEN);
this->write_(Register::PKTCTRL0);
}
#endif
return this->enter_calibrated_(State::RX, Command::RX);
}
bool CC1101Component::enter_tx_() { return this->enter_calibrated_(State::TX, Command::TX); }
@@ -456,12 +708,22 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) {
CC1101Error CC1101Component::transmit_packet(const uint8_t *data, size_t len) {
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
ESP_LOGW(TAG, "Cannot transmit packet: packet mode is not FIFO");
return CC1101Error::PARAMS;
}
#ifdef USE_CC1101_LONG_PACKET
// Long packet TX needs chunked FIFO refills and is not implemented
if (this->is_long_packet_mode_()) {
ESP_LOGW(TAG, "Cannot transmit packet: long packet mode is RX-only");
return CC1101Error::PARAMS;
}
#endif
const bool variable = this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE);
// In variable length mode the FIFO also holds the length byte
if (len + (variable ? 1 : 0) > TX_FIFO_SIZE) {
ESP_LOGE(TAG, "Packet of %u bytes does not fit the TX FIFO", static_cast<unsigned>(len));
ESP_LOGW(TAG,
"Cannot transmit packet: long packet TX is not implemented, payload length %u exceeds TX FIFO limit %u",
static_cast<unsigned>(len), static_cast<unsigned>(TX_FIFO_SIZE));
return CC1101Error::PARAMS;
}
@@ -834,19 +1096,60 @@ void CC1101Component::set_bs_post_kp(BsPostKp value) {
}
}
void CC1101Component::set_packet_mode(bool value) {
this->state_.PKT_FORMAT =
static_cast<uint8_t>(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL);
if (value) {
// Configure GDO0 for FIFO status (asserts on RX FIFO threshold or end of packet)
this->state_.GDO0_CFG = 0x01;
// Set max RX FIFO threshold to ensure we only trigger on end-of-packet
this->state_.FIFO_THR = 15;
// Don't append status bytes to FIFO - we read from registers instead
this->state_.APPEND_STATUS = 0;
} else {
// Configure GDO0 for serial data (async serial mode)
void CC1101Component::configure_packet_mode_() {
#ifdef USE_CC1101_LONG_PACKET
// Ends a long reception in progress, also when switching to a short packet mode
this->high_freq_.stop();
this->packet_.clear();
#endif
if (!this->packet_mode_) {
this->state_.PKT_FORMAT = static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_ASYNC_SERIAL);
this->state_.GDO0_CFG = 0x0D;
} else {
this->state_.PKT_FORMAT = static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO);
this->state_.APPEND_STATUS = 0;
#ifdef USE_CC1101_LONG_PACKET
if (this->is_long_packet_mode_()) {
// Threshold-only signal, reasserts after each partial FIFO drain
this->state_.GDO0_CFG = 0x00;
this->state_.FIFO_THR = 7;
// Reserve once so receiving never allocates; a lambda may read one FIFO drain past MAX_PACKET_LENGTH
this->packet_.reserve(this->packet_length_func_ != nullptr ? MAX_PACKET_LENGTH + RX_FIFO_SIZE
: this->packet_length_);
this->reset_long_packet_rx_();
} else
#endif
{
this->state_.GDO0_CFG = 0x01;
this->state_.FIFO_THR = 15;
if (this->packet_length_ == 0) {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE);
} else {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED);
this->state_.PKTLEN = static_cast<uint8_t>(this->packet_length_);
}
}
}
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTCTRL1);
this->write_(Register::PKTLEN);
this->write_(Register::IOCFG0);
this->write_(Register::FIFOTHR);
}
}
void CC1101Component::set_packet_mode(bool value) {
this->packet_mode_ = value;
#ifdef USE_CC1101_LONG_PACKET
// Long packet RX must drop the FIFO along with a partial packet, so go through IDLE and restart RX
if (this->initialized_ && this->is_long_packet_mode_()) {
this->reconfigure_packet_length_();
} else
#endif
{
this->configure_packet_mode_();
}
if (this->initialized_) {
if (this->gdo0_pin_ != nullptr) {
@@ -856,24 +1159,41 @@ void CC1101Component::set_packet_mode(bool value) {
this->gdo0_pin_->detach_interrupt();
}
}
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTCTRL1);
this->write_(Register::IOCFG0);
this->write_(Register::FIFOTHR);
}
}
void CC1101Component::set_packet_length(uint8_t value) {
if (value == 0) {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE);
} else {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED);
this->state_.PKTLEN = value;
void CC1101Component::set_packet_length(uint16_t value) {
#ifndef USE_CC1101_LONG_PACKET
if (value > RX_FIFO_SIZE) {
ESP_LOGW(TAG, "Packet length %u needs a configured packet_length above %u", value,
static_cast<unsigned>(RX_FIFO_SIZE));
return;
}
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTLEN);
#else
this->packet_length_func_ = nullptr;
#endif
this->packet_length_ = value;
this->reconfigure_packet_length_();
}
#ifdef USE_CC1101_LONG_PACKET
void CC1101Component::set_packet_length_lambda(packet_length_func_t func) {
this->packet_length_func_ = func;
this->reconfigure_packet_length_();
}
#endif
void CC1101Component::reconfigure_packet_length_() {
// While asleep the writes wait for wake_up() and the radio stays asleep, like set_frequency()
if (!this->initialized_ || this->sleeping_) {
this->configure_packet_mode_();
return;
}
// Like the other runtime setters: go through IDLE, and flush bytes of a reception in progress
this->enter_idle_();
this->strobe_(Command::FRX);
this->configure_packet_mode_();
this->enter_rx_();
}
void CC1101Component::set_crc_enable(bool value) {
+33 -2
View File
@@ -2,6 +2,7 @@
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "cc1101defs.h"
@@ -10,6 +11,7 @@
namespace esphome::cc1101 {
enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW, PLL_LOCK };
using packet_length_func_t = int32_t (*)(const std::vector<uint8_t> &);
class CC1101Listener {
public:
@@ -86,7 +88,10 @@ class CC1101Component final : public Component,
// Packet mode settings
void set_packet_mode(bool value);
void set_packet_length(uint8_t value);
void set_packet_length(uint16_t value);
#ifdef USE_CC1101_LONG_PACKET
void set_packet_length_lambda(packet_length_func_t func);
#endif
void set_crc_enable(bool value);
void set_whitening(bool value);
@@ -101,6 +106,7 @@ class CC1101Component final : public Component,
protected:
uint16_t chip_id_{0};
bool initialized_{false};
bool packet_mode_{false};
bool sleeping_{false};
bool registers_dirty_{false};
@@ -112,13 +118,38 @@ class CC1101Component final : public Component,
// GDO pin for packet reception
InternalGPIOPin *gdo0_pin_{nullptr};
static void gpio_intr(CC1101Component *arg);
// Inline like App.get_loop_component_start_time(): on RP2040 an out-of-line IRAM_ATTR function in the same
// file as that inline one fails with a section type conflict
static void IRAM_ATTR gpio_intr(CC1101Component *arg) { arg->enable_loop_soon_any_context(); }
// Packet handling
void call_listeners_(const std::vector<uint8_t> &packet, float freq_offset, float rssi, uint8_t lqi);
void configure_packet_mode_();
void reconfigure_packet_length_();
float symbol_rate_() const;
#ifdef USE_CC1101_LONG_PACKET
bool is_long_packet_mode_() const;
void arm_length_(uint16_t length);
void reset_long_packet_rx_();
void restart_long_packet_rx_();
void loop_long_packet_();
void handle_long_packet_(uint8_t rx_bytes);
void finish_long_packet_();
void maybe_switch_long_packet_to_fixed_();
#endif
Trigger<std::vector<uint8_t>, float, float, uint8_t> packet_trigger_;
std::vector<uint8_t> packet_;
std::vector<CC1101Listener *> listeners_;
#ifdef USE_CC1101_LONG_PACKET
packet_length_func_t packet_length_func_{nullptr};
uint32_t packet_last_byte_ms_{0};
#endif
uint16_t packet_length_{0};
#ifdef USE_CC1101_LONG_PACKET
uint16_t packet_expected_length_{0};
int8_t long_packet_rssi_{0}; // raw RSSI register
HighFrequencyLoopRequester high_freq_;
#endif
// Low-level Helpers
uint8_t strobe_(Command cmd);
+5
View File
@@ -15,6 +15,11 @@ static constexpr uint8_t PLL_LOCK_RETRIES = 3;
static constexpr uint8_t STATUS_CRC_OK_MASK = 0x80;
static constexpr uint8_t STATUS_LQI_MASK = 0x7F;
static constexpr uint16_t MAX_PACKET_LENGTH = 1024;
static constexpr uint16_t PACKET_IDLE_TIMEOUT_BYTES = 64;
static constexpr uint32_t PACKET_IDLE_TIMEOUT_MIN_MS = 5;
static constexpr uint8_t RXBYTES_STABLE_RETRIES = 4;
static constexpr uint8_t BUS_BURST = 0x40;
static constexpr uint8_t BUS_READ = 0x80;
static constexpr uint8_t BUS_WRITE = 0x00;
@@ -1,36 +1,15 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from esphome.components import cstxxx
from esphome.types import ConfigType
from .. import cst226_ns
CST226Touchscreen = cst226_ns.class_(
"CST226Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
AUTO_LOAD = ["cstxxx"]
CST226Touchscreen = cst226_ns.class_("CST226Touchscreen", cstxxx.CSTTouchscreen)
CST226ButtonListener = cst226_ns.class_("CST226ButtonListener")
CONFIG_SCHEMA = (
touchscreen.touchscreen_schema("100ms")
.extend(
{
cv.GenerateID(): cv.declare_id(CST226Touchscreen),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(i2c.i2c_device_schema(0x5A))
)
CONFIG_SCHEMA = cstxxx.cst_touchscreen_schema(CST226Touchscreen, 0x5A)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
await cstxxx.new_cst_touchscreen(config)
@@ -14,6 +14,10 @@ void CST226Touchscreen::setup() {
}
void CST226Touchscreen::update_touches() {
if (!this->setup_complete_) {
this->skip_update_ = true;
return;
}
uint8_t data[28];
if (!this->status_comm_result(this->read_bytes(CST226_REG_STATUS, data, sizeof data))) {
this->skip_update_ = true;
@@ -24,56 +28,39 @@ void CST226Touchscreen::update_touches() {
return;
}
this->update_button_state_(false);
if (data[6] != 0xAB || data[0] == 0xAB || data[5] == 0x80) {
if (data[cstxxx::SYNC_IDX] != cstxxx::SYNC_BYTE || data[0] == cstxxx::SYNC_BYTE ||
data[cstxxx::FINGER_COUNT_IDX] == 0x80) {
this->skip_update_ = true;
return;
}
uint8_t num_of_touches = data[5] & 0x7F;
uint8_t num_of_touches = data[cstxxx::FINGER_COUNT_IDX] & 0x7F;
if (num_of_touches == 0 || num_of_touches > 5) {
this->write_byte(0, 0xAB);
this->write_byte(CST226_REG_STATUS, cstxxx::SYNC_BYTE);
return;
}
size_t index = 0;
for (uint8_t i = 0; i != num_of_touches; i++) {
uint8_t id = data[index] >> 4;
int16_t x = (data[index + 1] << 4) | ((data[index + 3] >> 4) & 0x0F);
int16_t y = (data[index + 2] << 4) | (data[index + 3] & 0x0F);
int16_t z = data[index + 4];
this->add_raw_touch_position_(id, x, y, z);
ESP_LOGV(TAG, "Read touch %d: %d/%d", id, x, y);
index += 5;
if (i == 0)
index += 2;
auto touch = cstxxx::decode_touch(data, i);
this->add_raw_touch_position_(touch.id, touch.x, touch.y, touch.z);
ESP_LOGV(TAG, "Read touch %d: %d/%d", touch.id, touch.x, touch.y);
}
}
bool CST226Touchscreen::read16_(uint16_t addr, uint8_t *data, size_t len) {
if (this->read_register16(addr, data, len) != i2c::ERROR_OK) {
ESP_LOGE(TAG, "Read data from 0x%04X failed", addr);
this->mark_failed();
return false;
}
return true;
}
void CST226Touchscreen::continue_setup_() {
uint8_t buffer[8];
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
if (!this->setup_check(this->write_byte_reg16(0xD1, 0xD1), LOG_STR("Write byte to 0xD1 failed")))
this->setup_interrupt_pin_(gpio::INTERRUPT_FALLING_EDGE);
if (!this->enter_command_mode_())
return;
delay(10);
if (this->read16_(0xD204, buffer, 4)) {
uint16_t chip_id = buffer[2] + (buffer[3] << 8);
uint16_t project_id = buffer[0] + (buffer[1] << 8);
ESP_LOGCONFIG(TAG, "Chip ID %X, project ID %x", chip_id, project_id);
}
if (!this->check_firmware_())
return;
if (!this->setup_check(this->read_words_(cstxxx::REG_CHIP_INFO, this->project_id_, this->chip_id_),
LOG_STR("Failed to read chip ID")))
return;
if (this->x_raw_max_ == 0 || this->y_raw_max_ == 0) {
if (this->read16_(0xD1F8, buffer, 4)) {
this->x_raw_max_ = buffer[0] + (buffer[1] << 8);
this->y_raw_max_ = buffer[2] + (buffer[3] << 8);
uint16_t x_max, y_max;
if (this->read_words_(cstxxx::REG_RESOLUTION, x_max, y_max)) {
this->x_raw_max_ = x_max;
this->y_raw_max_ = y_max;
if (this->swap_x_y_)
std::swap(this->x_raw_max_, this->y_raw_max_);
} else {
@@ -81,8 +68,10 @@ void CST226Touchscreen::continue_setup_() {
this->y_raw_max_ = this->display_->get_native_height();
}
}
this->status_comm_check(this->write_register16(cstxxx::REG_NORMAL_MODE, nullptr, 0) == i2c::ERROR_OK);
this->setup_complete_ = true;
}
void CST226Touchscreen::update_button_state_(bool state) {
if (this->button_touched_ == state)
return;
@@ -96,6 +85,10 @@ void CST226Touchscreen::dump_config() {
LOG_I2C_DEVICE(this);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
ESP_LOGCONFIG(TAG,
" Chip ID: 0x%04X, Project ID: 0x%04X\n"
" X/Y raw max: %d/%d",
this->chip_id_, this->project_id_, this->x_raw_max_, this->y_raw_max_);
}
} // namespace esphome::cst226
@@ -1,11 +1,12 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/components/cstxxx/cstxxx.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <vector>
namespace esphome::cst226 {
static const uint8_t CST226_REG_STATUS = 0x00;
@@ -15,28 +16,23 @@ class CST226ButtonListener {
virtual void update_button(bool state) = 0;
};
class CST226Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice {
class CST226Touchscreen final : public cstxxx::CSTTouchscreen {
public:
void setup() override;
void update_touches() override;
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
bool can_proceed() override { return this->setup_complete_ || this->is_failed(); }
void register_button_listener(CST226ButtonListener *listener) { this->button_listeners_.push_back(listener); }
protected:
bool read16_(uint16_t addr, uint8_t *data, size_t len);
void continue_setup_();
void update_button_state_(bool state);
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{};
uint8_t chip_id_{};
bool setup_complete_{};
std::vector<CST226ButtonListener *> button_listeners_;
bool button_touched_{};
uint16_t chip_id_{};
uint16_t project_id_{};
std::vector<CST226ButtonListener *> button_listeners_;
};
} // namespace esphome::cst226
@@ -1,37 +1,15 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from esphome.components import cstxxx
from esphome.types import ConfigType
from .. import cst328_ns
CST328Touchscreen = cst328_ns.class_(
"CST328Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
AUTO_LOAD = ["cstxxx"]
CST328Touchscreen = cst328_ns.class_("CST328Touchscreen", cstxxx.CSTTouchscreen)
CST328ButtonListener = cst328_ns.class_("CST328ButtonListener")
CONFIG_SCHEMA = (
touchscreen.touchscreen_schema("100ms")
.extend(
{
cv.GenerateID(): cv.declare_id(CST328Touchscreen),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(i2c.i2c_device_schema(0x1A))
)
CONFIG_SCHEMA = cstxxx.cst_touchscreen_schema(CST328Touchscreen, 0x1A)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
await cstxxx.new_cst_touchscreen(config)
@@ -5,31 +5,8 @@ namespace esphome::cst328 {
ESPHOME_LOG_TAG(TAG, "cst328.touchscreen");
static const uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet
static const uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less
static const uint16_t CST328_FW_CRC = 0xCACA; // Expected firmware CRC value
static const uint8_t CST328_SYNC_BYTE = 0xAB; // Sync byte used in communication
static const uint8_t ZERO_BYTE = 0;
#define I2C_WARN_ON_ERROR(x, log_tag, format, ...) \
do { \
i2c::ErrorCode err_rc_ = (x); \
if (err_rc_ != i2c::ERROR_OK) { \
ESP_LOGW(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \
this->status_set_warning(format); \
} \
} while (0)
#define I2C_FAIL_ON_ERROR(x, log_tag, format, ...) \
do { \
i2c::ErrorCode err_rc_ = (x); \
if (err_rc_ != i2c::ERROR_OK) { \
ESP_LOGE(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \
this->mark_failed(); \
return; \
} \
} while (0)
static constexpr uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet
static constexpr uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less
void CST328Touchscreen::setup() {
ESP_LOGCONFIG(TAG, "Setting up CST328 Touchscreen...");
@@ -50,50 +27,36 @@ void CST328Touchscreen::reset_device_() {
void CST328Touchscreen::continue_setup_() {
ESP_LOGV(TAG, "Continuing CST328 setup...");
uint8_t data_byte{0};
uint8_t buf[24]{};
I2C_FAIL_ON_ERROR(this->write_register16(CST_WM_DEBUG_INFO, buf, 0), TAG, "Failed to enter debug/info mode");
I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_CRC_AND_BOOT_TIME, buf, 4), TAG,
"Failed to read FW CRC and boot time");
uint16_t fw_crc = buf[2] + (buf[3] << 8);
if (fw_crc != CST328_FW_CRC) {
ESP_LOGE(TAG, "Error: Firmware CRC mismatch, expected 0x%04X but got 0x%04X", CST328_FW_CRC, fw_crc);
this->mark_failed();
if (!this->enter_command_mode_() || !this->check_firmware_())
return;
if (!this->setup_check(this->read_words_(cstxxx::REG_CHIP_INFO, this->project_id_, this->chip_id_),
LOG_STR("Failed to read chip and project ID")))
return;
}
I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_CHIP_TYPE_AND_PROJECT_ID, buf, 4), TAG,
"Failed to read chip and project ID");
this->chip_id_ = buf[2] + (buf[3] << 8);
this->project_id_ = buf[0] + (buf[1] << 8);
ESP_LOGD(TAG, "Chip ID %X, project ID %X", this->chip_id_, this->project_id_);
I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_REVISION, buf, 4), TAG, "Failed to read FW version");
this->fw_ver_major_ = buf[3];
this->fw_ver_minor_ = buf[2];
this->fw_build_ = buf[0] + (buf[1] << 8);
uint16_t fw_version;
if (!this->setup_check(this->read_words_(cstxxx::REG_FW_VERSION, this->fw_build_, fw_version),
LOG_STR("Failed to read FW version")))
return;
this->fw_ver_major_ = fw_version >> 8;
this->fw_ver_minor_ = fw_version & 0xFF;
ESP_LOGV(TAG, "FW version %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_);
if (i2c::ERROR_OK == this->read_register16(CST_REG_X_Y_RESOLUTION, buf, 4)) {
this->x_raw_max_ = buf[0] + (buf[1] << 8);
this->y_raw_max_ = buf[2] + (buf[3] << 8);
uint16_t x_max, y_max;
if (this->read_words_(cstxxx::REG_RESOLUTION, x_max, y_max)) {
this->x_raw_max_ = x_max;
this->y_raw_max_ = y_max;
} else {
this->x_raw_max_ = this->display_->get_native_width();
this->y_raw_max_ = this->display_->get_native_height();
}
I2C_WARN_ON_ERROR(this->write_register16(CST_WM_NORMAL, buf, 0), TAG, "Failed to enter normal mode");
I2C_WARN_ON_ERROR(this->read_register16(CST_REG_TOUCH_INFORMATION, &data_byte, 1), TAG, "Failed to read sync");
I2C_WARN_ON_ERROR(this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1), TAG,
"Failed to write sync");
uint8_t sync;
bool ok = this->write_register16(cstxxx::REG_NORMAL_MODE, nullptr, 0) == i2c::ERROR_OK;
ok &= this->read_byte_reg16(cstxxx::REG_TOUCH_DATA, &sync);
ok &= this->write_byte_reg16(cstxxx::REG_TOUCH_DATA, cstxxx::SYNC_BYTE);
this->status_comm_check(ok);
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
this->setup_interrupt_pin_(gpio::INTERRUPT_FALLING_EDGE);
this->setup_complete_ = true;
ESP_LOGV(TAG, "CST328 setup complete");
@@ -142,21 +105,15 @@ void CST328Touchscreen::update_touches() {
} else {
this->update_button_state_(true);
uint8_t data_idx = 0;
for (uint8_t i = 0; i < touch_cnt; i++) {
uint8_t id = touch_data[data_idx] >> 4;
int16_t x = (touch_data[data_idx + 1] << 4) | ((touch_data[data_idx + 3] >> 4) & 0x0F);
int16_t y = (touch_data[data_idx + 2] << 4) | (touch_data[data_idx + 3] & 0x0F);
int16_t z = touch_data[data_idx + 4];
this->add_raw_touch_position_(id, x, y, z);
data_idx += (i == 0) ? 7 : 5;
auto touch = cstxxx::decode_touch(touch_data, i);
this->add_raw_touch_position_(touch.id, touch.x, touch.y, touch.z);
}
}
bool cleanup_error = false;
cleanup_error |= !this->write_byte_reg16(CST_REG_TOUCH_FINGER_NUMBER, ZERO_BYTE);
cleanup_error |= !this->write_byte_reg16(CST_REG_TOUCH_INFORMATION, CST328_SYNC_BYTE);
cleanup_error |= !this->write_byte_reg16(cstxxx::REG_FINGER_COUNT, 0);
cleanup_error |= !this->write_byte_reg16(cstxxx::REG_TOUCH_DATA, cstxxx::SYNC_BYTE);
if (cleanup_error) {
ESP_LOGW(TAG, "Failed to clean up touch registers");
@@ -1,61 +1,45 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/components/cstxxx/cstxxx.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include <vector>
namespace esphome::cst328 {
static const uint8_t CST328_TOUCH_MAX_POINTS = 5;
static const uint8_t CST328_TOUCH_DATA_SIZE = CST328_TOUCH_MAX_POINTS * 5 + 2;
static constexpr uint8_t CST328_TOUCH_MAX_POINTS = 5;
static constexpr uint8_t CST328_TOUCH_DATA_SIZE = cstxxx::report_size(CST328_TOUCH_MAX_POINTS);
static const uint16_t CST_REG_TOUCH_INFORMATION = 0xD000;
static const uint16_t CST_REG_TOUCH_FINGER_NUMBER = 0xD005;
static const uint16_t CST_REG_FINGER_COUNT_IDX = CST_REG_TOUCH_FINGER_NUMBER - CST_REG_TOUCH_INFORMATION;
static const uint16_t CST_REG_X_Y_RESOLUTION = 0xD1F8;
static const uint16_t CST_REG_FW_CRC_AND_BOOT_TIME = 0xD1FC;
static const uint16_t CST_REG_CHIP_TYPE_AND_PROJECT_ID = 0xD204;
static const uint16_t CST_REG_FW_REVISION = 0xD208;
static const uint16_t CST_WM_DEBUG_INFO = 0xD101;
static const uint16_t CST_WM_NORMAL = 0xD109;
static constexpr uint16_t CST_REG_TOUCH_INFORMATION = cstxxx::REG_TOUCH_DATA;
static constexpr uint16_t CST_REG_FINGER_COUNT_IDX = cstxxx::FINGER_COUNT_IDX;
static constexpr uint16_t CST_REG_X_Y_RESOLUTION = cstxxx::REG_RESOLUTION;
static constexpr uint16_t CST_REG_FW_CRC_AND_BOOT_TIME = cstxxx::REG_FW_CHECK_CODE;
class CST328ButtonListener {
public:
virtual void update_button(bool state) = 0;
};
class CST328Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
class CST328Touchscreen : public cstxxx::CSTTouchscreen {
public:
void setup() override;
void register_button_listener(CST328ButtonListener *listener) { this->button_listeners_.push_back(listener); }
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
protected:
void update_touches() override;
void reset_device_();
void continue_setup_();
void update_button_state_(bool state);
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{};
std::vector<CST328ButtonListener *> button_listeners_;
bool button_touched_{};
uint16_t chip_id_{};
uint16_t project_id_{};
uint8_t fw_ver_major_{};
uint8_t fw_ver_minor_{};
std::vector<CST328ButtonListener *> button_listeners_;
uint16_t chip_id_{};
uint16_t project_id_{};
uint16_t fw_build_{};
bool setup_complete_{};
};
} // namespace esphome::cst328
@@ -1,35 +1,14 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from esphome.components import cstxxx
from esphome.types import ConfigType
from .. import cst9220_ns
CST9220Touchscreen = cst9220_ns.class_(
"CST9220Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
AUTO_LOAD = ["cstxxx"]
CONFIG_SCHEMA = (
touchscreen.touchscreen_schema("100ms")
.extend(
{
cv.GenerateID(): cv.declare_id(CST9220Touchscreen),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(i2c.i2c_device_schema(0x5A))
)
CST9220Touchscreen = cst9220_ns.class_("CST9220Touchscreen", cstxxx.CSTTouchscreen)
CONFIG_SCHEMA = cstxxx.cst_touchscreen_schema(CST9220Touchscreen, 0x5A)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
await cstxxx.new_cst_touchscreen(config)
@@ -1,7 +1,4 @@
#include "cst9220_touchscreen.h"
#include "esphome/core/helpers.h"
#include <cinttypes>
namespace esphome::cst9220 {
@@ -16,55 +13,29 @@ void CST9220Touchscreen::setup() {
}
void CST9220Touchscreen::continue_setup_() {
uint8_t buffer[4];
this->setup_interrupt_pin_(gpio::INTERRUPT_FALLING_EDGE);
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
// Enter command mode so the configuration registers can be read.
if (this->write_register16(REG_CMD_MODE, buffer, 0) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to enter command mode"));
this->mark_failed();
if (!this->enter_command_mode_())
return;
}
delay(10);
// The firmware check code confirms that valid firmware is loaded.
if (this->read_register16(REG_CHECKCODE, buffer, 4) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to read check code"));
this->mark_failed();
if (!this->check_firmware_())
return;
}
uint32_t checkcode = encode_uint32(buffer[3], buffer[2], buffer[1], buffer[0]);
if ((checkcode & 0xFFFF0000) != 0xCACA0000) {
ESP_LOGE(TAG, "Invalid firmware check code: 0x%08" PRIX32, checkcode);
this->status_set_error(LOG_STR("Invalid firmware check code"));
this->mark_failed();
return;
}
// Read the panel resolution unless the user supplied calibration values.
if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) {
uint16_t x_max, y_max;
if (this->read_words_(cstxxx::REG_RESOLUTION, x_max, y_max)) {
if (this->x_raw_max_ == this->x_raw_min_)
this->x_raw_max_ = encode_value_le<uint16_t>(buffer);
this->x_raw_max_ = x_max;
if (this->y_raw_max_ == this->y_raw_min_)
this->y_raw_max_ = encode_value_le<uint16_t>(&buffer[2]);
this->y_raw_max_ = y_max;
}
// Read the chip type and project id and validate the controller.
if (this->read_register16(REG_CHIP_INFO, buffer, 4) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to read chip ID"));
this->mark_failed();
if (!this->setup_check(this->read_words_(cstxxx::REG_CHIP_INFO, this->project_id_, this->chip_id_),
LOG_STR("Failed to read chip ID")))
return;
}
this->chip_id_ = encode_value_le<uint16_t>(&buffer[2]);
this->project_id_ = encode_value_le<uint16_t>(buffer);
if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) {
ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_);
this->status_set_error(LOG_STR("Unknown chip ID"));
this->mark_failed();
this->mark_failed(LOG_STR("Unknown chip ID"));
return;
}
@@ -84,7 +55,7 @@ void CST9220Touchscreen::update_touches() {
// Only an actual I2C failure should skip the update; a successful read with no
// touches is a real "all fingers lifted" state that must flow through so the
// base class can generate the release event.
if (!this->status_comm_result(this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) == i2c::ERROR_OK)) {
if (!this->status_comm_result(this->read_register16(cstxxx::REG_TOUCH_DATA, data, sizeof(data)) == i2c::ERROR_OK)) {
this->skip_update_ = true;
return;
}
@@ -92,30 +63,23 @@ void CST9220Touchscreen::update_touches() {
// A valid report carries the marker at offset 6. Writing an acknowledge back makes the controller return
// invalid frames during a long press, so none is written. An invalid frame keeps the previous points; while
// a finger is down, read again shortly so a lost release frame does not leave the touch held.
if (data[6] != TOUCH_ACK) {
if (data[cstxxx::SYNC_IDX] != cstxxx::SYNC_BYTE) {
this->skip_update_ = true;
if (!this->touches_.empty())
this->set_timeout(REREAD_TIMEOUT_ID, REREAD_DELAY_MS, [this] { this->store_.touched = true; });
return;
}
uint8_t num_touches = data[5] & 0x7F;
uint8_t num_touches = data[cstxxx::FINGER_COUNT_IDX] & 0x7F;
if (num_touches > CST9220_MAX_TOUCHES)
num_touches = CST9220_MAX_TOUCHES;
for (uint8_t i = 0; i < num_touches; i++) {
// The first point starts at offset 0; subsequent points are offset by the
// two status bytes that follow it.
const uint8_t *p = data + i * 5 + (i == 0 ? 0 : 2);
uint8_t id = p[0] >> 4;
uint8_t event = p[0] & 0x0F;
if (event != TOUCH_EVENT_DOWN)
auto touch = cstxxx::decode_touch(data, i);
if (touch.event != TOUCH_EVENT_DOWN)
continue;
// p[3] is shared: high nibble holds the X LSBs, low nibble the Y LSBs.
uint16_t x = (p[1] << 4) | (p[3] >> 4);
uint16_t y = (p[2] << 4) | (p[3] & 0x0F);
ESP_LOGV(TAG, "Read touch %d: %d/%d", id, x, y);
this->add_raw_touch_position_(id, x, y);
ESP_LOGV(TAG, "Read touch %d: %d/%d", touch.id, touch.x, touch.y);
this->add_raw_touch_position_(touch.id, touch.x, touch.y);
}
}
@@ -1,7 +1,6 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/components/cstxxx/cstxxx.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
@@ -10,41 +9,26 @@ namespace esphome::cst9220 {
ESPHOME_LOG_TAG(TAG, "cst9220.touchscreen");
// The CST92xx family uses 16-bit (big-endian) register addresses.
static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report
static const uint16_t REG_CMD_MODE = 0xD101; // enter command mode
static const uint16_t REG_CHECKCODE = 0xD1FC; // firmware check code
static const uint16_t REG_RESOLUTION = 0xD1F8; // panel resolution
static const uint16_t REG_CHIP_INFO = 0xD204; // chip type + project id
static constexpr uint8_t TOUCH_EVENT_DOWN = 0x06;
static const uint8_t TOUCH_ACK = 0xAB;
static const uint8_t TOUCH_EVENT_DOWN = 0x06;
static constexpr uint16_t CST9220_CHIP_ID = 0x9220;
static constexpr uint16_t CST9217_CHIP_ID = 0x9217;
static const uint16_t CST9220_CHIP_ID = 0x9220;
static const uint16_t CST9217_CHIP_ID = 0x9217;
// Maximum simultaneous touch points reported by the family.
static const uint8_t CST9220_MAX_TOUCHES = 5;
static constexpr uint8_t CST9220_MAX_TOUCHES = 5;
// Report layout: 5 bytes per touch point plus 5 bytes of status/ack overhead.
static const size_t CST9220_DATA_LENGTH = CST9220_MAX_TOUCHES * 5 + 5;
static constexpr size_t CST9220_DATA_LENGTH = CST9220_MAX_TOUCHES * 5 + 5;
class CST9220Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
class CST9220Touchscreen : public cstxxx::CSTTouchscreen {
public:
void setup() override;
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
protected:
void update_touches() override;
void continue_setup_();
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{};
uint16_t chip_id_{};
uint16_t project_id_{};
bool setup_complete_{};
};
} // namespace esphome::cst9220
+42
View File
@@ -0,0 +1,42 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@clydebarrow", "@latonita"]
DEPENDENCIES = ["i2c", "touchscreen"]
DOMAIN = "cstxxx"
cstxxx_ns = cg.esphome_ns.namespace("cstxxx")
CSTTouchscreen = cstxxx_ns.class_(
"CSTTouchscreen", touchscreen.Touchscreen, i2c.I2CDevice
)
def cst_touchscreen_schema(touchscreen_class: MockObjClass, address: int) -> cv.Schema:
"""Return the touchscreen schema of a Hynitron driver."""
return (
touchscreen.touchscreen_schema("100ms")
.extend(
{
cv.GenerateID(): cv.declare_id(touchscreen_class),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
)
.extend(i2c.i2c_device_schema(address))
)
async def new_cst_touchscreen(config: ConfigType) -> MockObj:
"""Create a Hynitron touchscreen and set its pins."""
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN))
return var
+13
View File
@@ -0,0 +1,13 @@
#include "cstxxx.h"
#include "esphome/core/log.h"
namespace esphome::cstxxx {
ESPHOME_LOG_TAG(TAG, "cstxxx");
void CSTTouchscreen::fail_check_code_(uint16_t check_code) {
ESP_LOGE(TAG, "Firmware check code 0x%04X", check_code);
this->mark_failed(LOG_STR("Invalid firmware check code"));
}
} // namespace esphome::cstxxx
+92
View File
@@ -0,0 +1,92 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/gpio.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::cstxxx {
static constexpr uint16_t REG_TOUCH_DATA = 0xD000;
static constexpr uint16_t REG_FINGER_COUNT = 0xD005;
static constexpr uint16_t REG_COMMAND_MODE = 0xD101;
static constexpr uint16_t REG_NORMAL_MODE = 0xD109;
static constexpr uint16_t REG_RESOLUTION = 0xD1F8;
static constexpr uint16_t REG_FW_CHECK_CODE = 0xD1FC;
static constexpr uint16_t REG_CHIP_INFO = 0xD204;
static constexpr uint16_t REG_FW_VERSION = 0xD208;
static constexpr uint8_t FINGER_COUNT_IDX = REG_FINGER_COUNT - REG_TOUCH_DATA;
static constexpr uint8_t SYNC_IDX = FINGER_COUNT_IDX + 1;
static constexpr uint8_t SYNC_BYTE = 0xAB;
static constexpr uint16_t FW_CHECK_CODE = 0xCACA;
/// Bytes of a touch report holding `points` records; two status bytes follow the first record.
constexpr size_t report_size(uint8_t points) { return points * 5 + 2; }
struct TouchRecord {
uint8_t id;
uint8_t event;
uint16_t x;
uint16_t y;
uint8_t z;
};
/// Decodes record `index` of a touch report.
inline TouchRecord decode_touch(const uint8_t *report, uint8_t index) {
const uint8_t *r = report + index * 5 + (index == 0 ? 0 : 2);
return {static_cast<uint8_t>(r[0] >> 4), static_cast<uint8_t>(r[0] & 0x0F),
static_cast<uint16_t>(r[1] << 4 | r[3] >> 4), static_cast<uint16_t>(r[2] << 4 | (r[3] & 0x0F)), r[4]};
}
class CSTTouchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
protected:
/// Sets up the interrupt pin, if configured, to fire on `type`.
void setup_interrupt_pin_(gpio::InterruptType type) {
if (this->interrupt_pin_ == nullptr)
return;
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, type);
}
/// Reads two little-endian words starting at `reg`.
bool ESPHOME_ALWAYS_INLINE read_words_(uint16_t reg, uint16_t &low, uint16_t &high) {
uint16_t words[2];
if (this->read_register16(reg, reinterpret_cast<uint8_t *>(words), sizeof(words)) != i2c::ERROR_OK)
return false;
low = convert_little_endian(words[0]);
high = convert_little_endian(words[1]);
return true;
}
/// Enters command mode, marking the component failed if the write fails.
bool enter_command_mode_() {
return this->setup_check(this->write_register16(REG_COMMAND_MODE, nullptr, 0) == i2c::ERROR_OK,
LOG_STR("Failed to enter command mode"));
}
/// Checks the firmware check code, marking the component failed if it is unreadable or wrong.
bool check_firmware_() {
uint16_t boot_time, check_code;
if (!this->setup_check(this->read_words_(REG_FW_CHECK_CODE, boot_time, check_code)))
return false;
if (check_code == FW_CHECK_CODE)
return true;
this->fail_check_code_(check_code);
return false;
}
/// Logs a wrong firmware check code and marks the component failed.
void fail_check_code_(uint16_t check_code);
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{};
bool setup_complete_{};
};
} // namespace esphome::cstxxx
@@ -7,6 +7,8 @@
#include "esphome/core/automation.h"
#include "esphome/components/network/ip_address.h"
#include <atomic>
#ifdef USE_ETHERNET
#ifdef USE_ESP32
@@ -366,6 +368,9 @@ class EthernetComponent final : public Component {
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
StaticVector<EthernetIPStateListener *, ESPHOME_ETHERNET_IP_STATE_LISTENERS> ip_state_listeners_;
#ifdef USE_ESP32
std::atomic<uint8_t> ip_state_pending_{0};
#endif
#endif
#ifdef USE_ETHERNET_CONNECT_TRIGGER
@@ -129,6 +129,14 @@ void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *me
void EthernetComponent::loop() {
const uint32_t now = App.get_loop_component_start_time();
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
// The IP events arrive on the event task; the listeners publish states, so notify them here
if (this->ip_state_pending_.load(std::memory_order_acquire) != 0) {
this->ip_state_pending_.store(0, std::memory_order_relaxed);
this->notify_ip_state_listeners_();
}
#endif
switch (this->state_) {
case EthernetComponentState::STOPPED:
if (this->started_) {
@@ -770,7 +778,8 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base
event_name = "ETH connected";
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP)
global_eth_component->notify_ip_state_listeners_();
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
global_eth_component->enable_loop_soon_any_context();
#endif
#if USE_NETWORK_IPV6
// Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the
@@ -798,6 +807,9 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
const esp_netif_ip_info_t *ip_info = &event->ip_info;
ESP_LOGV(TAG, "[Ethernet event] ETH Got IP " IPSTR, IP2STR(&ip_info->ip));
global_eth_component->got_ipv4_address_ = true;
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ = global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
@@ -805,9 +817,6 @@ void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_b
global_eth_component->connected_ = true;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif /* USE_NETWORK_IPV6 */
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#if USE_NETWORK_IPV6
@@ -819,6 +828,9 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
// after a link flap fires another event for the same address.
struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES];
global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s);
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->ip_state_pending_.store(1, std::memory_order_release);
#endif
#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0)
global_eth_component->connected_ =
global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT);
@@ -827,9 +839,6 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_
global_eth_component->connected_ = global_eth_component->got_ipv4_address_;
global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
global_eth_component->notify_ip_state_listeners_();
#endif
}
#endif /* USE_NETWORK_IPV6 */
+27 -115
View File
@@ -1,5 +1,3 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from esphome import automation
@@ -14,14 +12,12 @@ from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "script"
script_ns = cg.esphome_ns.namespace("script")
Script = script_ns.class_("Script")
AutomationScript = script_ns.class_(
"AutomationScript", Script, automation.Trigger.template()
)
FunctionBody = script_ns.class_("FunctionBody", Script)
FunctionScript = script_ns.class_("FunctionScript", Script)
FunctionPointerScript = script_ns.class_("FunctionPointerScript", Script)
Script = script_ns.class_("Script", automation.Trigger.template())
ScriptWaitAction = script_ns.class_("ScriptWaitAction", automation.Action, cg.Component)
SingleScript = script_ns.class_("SingleScript", Script)
RestartScript = script_ns.class_("RestartScript", Script)
QueueingScript = script_ns.class_("QueueingScript", Script, cg.Component)
ParallelScript = script_ns.class_("ParallelScript", Script)
CONF_SINGLE = "single"
CONF_QUEUED = "queued"
@@ -29,38 +25,12 @@ CONF_PARALLEL = "parallel"
CONF_MAX_RUNS = "max_runs"
CONF_SCRIPT_STOP = "script.stop"
def _mode(
name: str, *parents: cg.MockObjClass
) -> tuple[cg.MockObjClass, cg.MockObjClass]:
"""The class of a mode with an Automation, and its template on the class that runs."""
return (
script_ns.class_(f"{name}Script", AutomationScript, *parents),
script_ns.class_(f"{name}Mode", Script, *parents),
)
MODES = {
CONF_SINGLE: _mode("Single"),
CONF_RESTART: _mode("Restart"),
CONF_QUEUED: _mode("Queueing", cg.Component),
CONF_PARALLEL: _mode("Parallel"),
SCRIPT_MODES = {
CONF_SINGLE: SingleScript,
CONF_RESTART: RestartScript,
CONF_QUEUED: QueueingScript,
CONF_PARALLEL: ParallelScript,
}
SCRIPT_MODES = {mode: classes[0] for mode, classes in MODES.items()}
@dataclass
class ScriptData:
# By script id: the class script.wait is templated on, or the script.wait actions to
# create once the script's class is known
waits: dict[str, cg.MockObj | list[Callable[[cg.MockObj], None]]] = field(
default_factory=dict
)
def _data() -> ScriptData:
return CORE.data.setdefault(DOMAIN, ScriptData())
PARAMETER_TYPE_TRANSLATIONS = {
"string": "std::string",
@@ -133,20 +103,6 @@ def validate_parameter_name(value):
raise cv.Invalid(f"Script's parameter name cannot be {CONF_ID}")
def validate_parameter_names(value: Any) -> Any:
"""Reject reserved parameter names here: as dict keys of a schema they would only be
reported as "extra keys not allowed"."""
if isinstance(value, dict):
for name in value:
# Generated C++ code names its own variables like this
if str(name).startswith("esphome__"):
raise cv.Invalid(
"Names starting with 'esphome__' are reserved for generated code",
path=[name],
)
return value
ALLOWED_PARAM_TYPE_CHARSET = set("abcdefghijklmnopqrstuvwxyz0123456789_:*&[]<>")
@@ -214,9 +170,10 @@ CONFIG_SCHEMA = cv.All(
*SCRIPT_MODES, lower=True
),
cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100),
cv.Optional(CONF_PARAMETERS, default={}): cv.All(
validate_parameter_names,
cv.Schema({validate_parameter_name: validate_parameter_type}),
cv.Optional(CONF_PARAMETERS, default={}): cv.Schema(
{
validate_parameter_name: validate_parameter_type,
}
),
cv.Optional(CONF_ON_STOP): automation.validate_automation(single=True),
},
@@ -226,56 +183,28 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
# Register all variables first, so that scripts can use other scripts. Each is created
# once its actions are built, which decides its class, in the place it was declared.
waits = _data().waits
scripts = []
# Register all variables first, so that scripts can use other scripts
triggers = []
for conf in config:
template, func_args = parameters_to_template(conf[CONF_PARAMETERS])
id_ = conf[CONF_ID].copy()
id_.type = id_.type.template(template)
late = cg.LatePvariable(id_)
waits[id_.id] = []
script = late.var
trigger = cg.new_Pvariable(conf[CONF_ID], template)
# Add a human-readable name to the script
cg.add(script.set_name(cg.LogStringLiteral(id_.id)))
cg.add(trigger.set_name(cg.LogStringLiteral(conf[CONF_ID].id)))
if CONF_MAX_RUNS in conf:
cg.add(script.set_max_runs(conf[CONF_MAX_RUNS]))
cg.add(trigger.set_max_runs(conf[CONF_MAX_RUNS]))
if queued := conf[CONF_MODE] == CONF_QUEUED:
await cg.register_component(script, conf)
if conf[CONF_MODE] == CONF_QUEUED:
await cg.register_component(trigger, conf)
scripts.append((late, template, func_args, conf, queued))
triggers.append((trigger, func_args, conf))
if any(CONF_ON_STOP in conf for conf in config):
cg.add_define("USE_SCRIPT_ON_STOP")
for late, template, func_args, conf, queued in scripts:
# The class or its constructor names the function, so it is declared in the script's place
fn = await automation.build_automation_or_run_function(
late.var, func_args, conf, late.global_slot.add
)
script_class = wait_class = late.id.type
ctor_args = []
if fn is not None:
wait_class = MODES[conf[CONF_MODE]][1].template(
FunctionBody.template(template), *template
)
if queued:
# One class for all of them: a per-script class would also copy the
# Component vtable, which costs more flash than the pointer's RAM
script_class = FunctionPointerScript.template(wait_class, *template)
ctor_args = [fn]
else:
script_class = FunctionScript.template(wait_class, fn, *template)
late.define(script_class, *ctor_args)
for create_wait in waits[late.id.id]:
create_wait(wait_class)
waits[late.id.id] = wait_class
for trigger, func_args, conf in triggers:
await automation.build_automation(trigger, func_args, conf)
if (stop_conf := conf.get(CONF_ON_STOP)) is not None:
await automation.build_automation(
late.var.get_stop_trigger(), [], stop_conf
)
await automation.build_automation(trigger.get_stop_trigger(), [], stop_conf)
@automation.register_action(
@@ -362,25 +291,8 @@ automation.register_apply_action(
)
async def script_wait_action_to_code(config, action_id, template_arg, args):
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
script_waits = _data().waits[full_id.id]
pending = isinstance(script_waits, list)
# Created where it is built, once the class of the script it waits for is known
id_ = action_id.copy()
id_.type = id_.type.template(
full_id.type if pending else script_waits, *template_arg
)
late = cg.LatePvariable(id_)
def create(script_class: cg.MockObj) -> None:
late.define(
action_id.type, cg.TemplateArguments(script_class, *template_arg), paren
)
if pending:
script_waits.append(create)
else:
create(script_waits)
var = late.var
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
var = cg.new_Pvariable(action_id, template_arg, paren)
await cg.register_component(var, {})
return var
+34 -99
View File
@@ -24,7 +24,7 @@ class ScriptLogger {
};
/// The abstract base class for all script types.
template<typename... Ts> class Script : public ScriptLogger {
template<typename... Ts> class Script : public ScriptLogger, public Trigger<Ts...> {
public:
/** Execute a new instance of this script.
*
@@ -32,83 +32,44 @@ template<typename... Ts> class Script : public ScriptLogger {
*/
virtual void execute(Ts...) = 0;
/// Check if any instance of this script is currently running.
virtual bool is_running() = 0;
/// Stop all instances of this script. Fires the on_stop trigger once if any instance was running.
virtual void stop() = 0;
virtual bool is_running() { return this->is_action_running(); }
#ifdef USE_SCRIPT_ON_STOP
/// Stop all instances of this script. Fires the on_stop trigger once if any instance was running.
virtual void stop() {
if (this->is_action_running())
this->stop_running_();
}
Trigger<> *get_stop_trigger() { return &this->stop_trigger_; }
#else
/// Stop all instances of this script.
virtual void stop() { this->stop_action(); }
#endif
// Internal function to give scripts readable names.
void set_name(const LogString *name) { name_ = name; }
protected:
const LogString *name_{nullptr};
#ifdef USE_SCRIPT_ON_STOP
Trigger<> stop_trigger_;
#endif
};
/// Runs the script's actions through an Automation, for actions that can wait (delay, wait_until, ...).
template<typename... Ts> class AutomationScript : public Script<Ts...>, public Trigger<Ts...> {
protected:
bool body_running_() { return this->is_action_running(); }
int body_num_running_() { return this->automation_parent_->num_running(); }
inline void run_body_(const Ts &...x) ESPHOME_ALWAYS_INLINE { this->trigger(x...); }
void stop_body_() { this->stop_action(); }
};
/** Runs the script's actions as one generated function, when they are all inline.
*
* Like ApplyRunAction, the function gets the run count by reference and returns early once a
* stop clears it. The mode code built on this is shared; FunctionScript or FunctionPointerScript
* supplies the function.
*/
template<typename... Ts> class FunctionBody : public Script<Ts...> {
protected:
bool body_running_() { return this->num_running_ > 0; }
int body_num_running_() { return this->num_running_; }
inline void run_body_(const Ts &...x) ESPHOME_ALWAYS_INLINE {
this->num_running_++;
this->run_function(this->num_running_, x...);
if (this->num_running_ > 0)
this->num_running_--;
}
void stop_body_() { this->num_running_ = 0; }
virtual void run_function(const int &running, const Ts &...x) = 0;
int num_running_{0};
};
/// What all script modes share, on top of the class that runs the actions.
template<class Body, typename... Ts> class ScriptBase : public Body {
public:
bool is_running() override { return this->body_running_(); }
#ifdef USE_SCRIPT_ON_STOP
void stop() override {
if (this->body_running_())
this->stop_running_();
}
#else
void stop() override { this->stop_body_(); }
#endif
protected:
// Publish this script's name as the current source while the actions run (RAII, so nesting
// composes), so deferred work is attributed to it in blocking warnings.
// Run the action chain with this script's name published as the current source (RAII save/restore,
// so nesting composes), so deferred work inside the script is attributed to it in blocking
// warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame).
inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE {
ScopedSourceGuard source_guard{this->name_};
this->run_body_(x...);
this->trigger(x...);
}
// Stop the running actions early and fire the on_stop trigger.
void stop_running_() {
this->stop_body_();
this->stop_action();
#ifdef USE_SCRIPT_ON_STOP
this->stop_trigger_.trigger();
#endif
}
const LogString *name_{nullptr};
#ifdef USE_SCRIPT_ON_STOP
Trigger<> stop_trigger_;
#endif
};
/** A script type for which only a single instance at a time is allowed.
@@ -116,10 +77,10 @@ template<class Body, typename... Ts> class ScriptBase : public Body {
* If a new instance is executed while the previous one hasn't finished yet,
* a warning is printed and the new instance is discarded.
*/
template<class Body, typename... Ts> class SingleMode : public ScriptBase<Body, Ts...> {
template<typename... Ts> class SingleScript : public Script<Ts...> {
public:
void execute(Ts... x) override {
if (this->body_running_()) {
if (this->is_action_running()) {
this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' is already running! (mode: single)"),
LOG_STR_ARG(this->name_));
return;
@@ -134,16 +95,16 @@ template<class Body, typename... Ts> class SingleMode : public ScriptBase<Body,
* If a new instance is started but another one is already running, the existing
* script is stopped and the new instance starts from the beginning.
*/
template<class Body, typename... Ts> class RestartMode : public ScriptBase<Body, Ts...> {
template<typename... Ts> class RestartScript : public Script<Ts...> {
public:
void execute(Ts... x) override {
if (this->body_running_()) {
if (this->is_action_running()) {
this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), LOG_STR_ARG(this->name_));
this->stop_running_();
#ifdef USE_SCRIPT_ON_STOP
// on_stop may have executed this script again; only the newest run may continue
if (this->body_running_())
this->stop_body_();
if (this->is_action_running())
this->stop_action();
#endif
}
@@ -165,10 +126,10 @@ template<class Body, typename... Ts> class RestartMode : public ScriptBase<Body,
* - Subsequent executes while running are queued starting at position 0
* - Maximum total instances = max_runs_ (includes 1 running + (max_runs_ - 1) queued)
*/
template<class Body, typename... Ts> class QueueingMode : public ScriptBase<Body, Ts...>, public Component {
template<typename... Ts> class QueueingScript : public Script<Ts...>, public Component {
public:
void execute(Ts... x) override {
if (this->body_running_() || this->num_queued_ > 0) {
if (this->is_action_running() || this->num_queued_ > 0) {
// num_queued_ is the number of *queued* instances (waiting, not including currently running)
// max_runs_ is the maximum *total* instances (running + queued)
// So we reject when num_queued_ + 1 >= max_runs_ (queued + running >= max)
@@ -203,7 +164,7 @@ template<class Body, typename... Ts> class QueueingMode : public ScriptBase<Body
void stop() override {
#ifdef USE_SCRIPT_ON_STOP
// Queued runs are unfinished work too, even between two runs when nothing is running
const bool stopping = this->body_running_() || this->num_queued_ != 0;
const bool stopping = this->is_action_running() || this->num_queued_ != 0;
#endif
// Clear all queued items to free memory immediately
// Resetting the array automatically destroys all unique_ptrs and their contents
@@ -214,12 +175,12 @@ template<class Body, typename... Ts> class QueueingMode : public ScriptBase<Body
if (stopping)
this->stop_running_();
#else
ScriptBase<Body, Ts...>::stop();
Script<Ts...>::stop();
#endif
}
void loop() override {
if (this->num_queued_ != 0 && !this->body_running_()) {
if (this->num_queued_ != 0 && !this->is_action_running()) {
// Dequeue: decrement count, move tuple out (frees slot), advance read position
this->num_queued_--;
const size_t queue_capacity = static_cast<size_t>(this->max_runs_ - 1);
@@ -266,10 +227,10 @@ template<class Body, typename... Ts> class QueueingMode : public ScriptBase<Body
* If a new instance is started while previous ones haven't finished yet,
* the new one is executed in parallel to the other instances.
*/
template<class Body, typename... Ts> class ParallelMode : public ScriptBase<Body, Ts...> {
template<typename... Ts> class ParallelScript : public Script<Ts...> {
public:
void execute(Ts... x) override {
if (this->max_runs_ != 0 && this->body_num_running_() >= this->max_runs_) {
if (this->max_runs_ != 0 && this->automation_parent_->num_running() >= this->max_runs_) {
this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of parallel runs exceeded!"),
LOG_STR_ARG(this->name_));
return;
@@ -282,32 +243,6 @@ template<class Body, typename... Ts> class ParallelMode : public ScriptBase<Body
int max_runs_ = 0;
};
template<typename... Ts> using SingleScript = SingleMode<AutomationScript<Ts...>, Ts...>;
template<typename... Ts> using RestartScript = RestartMode<AutomationScript<Ts...>, Ts...>;
template<typename... Ts> using QueueingScript = QueueingMode<AutomationScript<Ts...>, Ts...>;
template<typename... Ts> using ParallelScript = ParallelMode<AutomationScript<Ts...>, Ts...>;
/// A script of mode `Mode` (built on FunctionBody) whose actions are the generated function Fn.
template<class Mode, auto Fn, typename... Ts> class FunctionScript final : public Mode {
protected:
void run_function(const int &running, const Ts &...x) override { Fn(running, x...); }
};
/** Like FunctionScript, but one class for all scripts of `Mode`, holding the function.
*
* For queued scripts, whose per-script class would copy the Component vtable too.
*/
template<class Mode, typename... Ts> class FunctionPointerScript final : public Mode {
public:
using fn_t = void (*)(const int &, const Ts &...);
explicit FunctionPointerScript(fn_t fn) : fn_(fn) {}
protected:
void run_function(const int &running, const Ts &...x) override { this->fn_(running, x...); }
const fn_t fn_;
};
/** Wait for a script to finish before continuing.
*
* Uses queue-based storage to safely handle concurrent executions.
+4 -2
View File
@@ -48,7 +48,7 @@ CONF_WHITENING_ENABLE = "whitening_enable"
CONF_WHITENING_INITIAL = "whitening_initial"
sx126x_ns = cg.esphome_ns.namespace("sx126x")
SX126x = sx126x_ns.class_("SX126x", cg.Component, spi.SPIDevice)
SX126x = sx126x_ns.class_("SX126x", cg.Component)
SX126xListener = sx126x_ns.class_("SX126xListener")
SX126xBw = sx126x_ns.enum("SX126xBw")
SX126xPacketType = sx126x_ns.enum("SX126xPacketType")
@@ -217,7 +217,9 @@ CONFIG_SCHEMA = (
cv.Required(CONF_RF_SWITCH): cv.boolean,
cv.Optional(CONF_SHAPING, default="NONE"): cv.enum(SHAPING),
cv.Optional(CONF_SPREADING_FACTOR, default=7): cv.int_range(min=6, max=12),
cv.Optional(CONF_SYNC_VALUE, default=[]): cv.ensure_list(cv.hex_uint8_t),
cv.Optional(CONF_SYNC_VALUE, default=[]): cv.All(
cv.ensure_list(cv.hex_uint8_t), cv.Length(max=8)
),
cv.Optional(CONF_TCXO_VOLTAGE, default="NONE"): cv.enum(TCXO_VOLTAGE),
cv.Optional(CONF_TCXO_DELAY, default="5ms"): cv.All(
cv.positive_time_period_microseconds,
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include "sx126x_reg.h"
#include <array>
#include <span>
#include <algorithm>
namespace esphome::sx126x {
enum SX126xBw : uint8_t {
// FSK
SX126X_BW_4800,
SX126X_BW_5800,
SX126X_BW_7300,
SX126X_BW_9700,
SX126X_BW_11700,
SX126X_BW_14600,
SX126X_BW_19500,
SX126X_BW_23400,
SX126X_BW_29300,
SX126X_BW_39000,
SX126X_BW_46900,
SX126X_BW_58600,
SX126X_BW_78200,
SX126X_BW_93800,
SX126X_BW_117300,
SX126X_BW_156200,
SX126X_BW_187200,
SX126X_BW_234300,
SX126X_BW_312000,
SX126X_BW_373600,
SX126X_BW_467000,
// LORA
SX126X_BW_7810,
SX126X_BW_10420,
SX126X_BW_15630,
SX126X_BW_20830,
SX126X_BW_31250,
SX126X_BW_41670,
SX126X_BW_62500,
SX126X_BW_125000,
SX126X_BW_250000,
SX126X_BW_500000,
};
// Requested settings are separate from the last successfully applied profile.
// Bounded sync storage makes profile snapshots allocation-free.
struct RadioProfile {
uint32_t bitrate{0};
uint32_t deviation{0};
uint32_t frequency{0};
uint32_t tcxo_delay{0};
uint16_t crc_polynomial{0};
uint16_t crc_initial{0};
uint16_t whitening_initial{0};
uint16_t preamble_detect{0};
uint16_t preamble_size{0};
SX126xBw bandwidth{SX126X_BW_125000};
uint8_t crc_size{0};
uint8_t tcxo_voltage{0};
uint8_t coding_rate{0};
uint8_t modulation{PACKET_TYPE_LORA};
uint8_t pa_ramp{0};
uint8_t shaping{0};
uint8_t spreading_factor{0};
int8_t pa_power{0};
uint8_t payload_length{0};
bool sx1261{false};
bool crc_enable{false};
bool crc_inverted{false};
bool whitening_enable{false};
bool rx_start{false};
bool rf_switch{false};
std::array<uint8_t, 8> sync_value{};
uint8_t sync_size{0};
bool sync_valid{true};
void set_sync_value(std::span<const uint8_t> bytes) {
this->sync_valid = bytes.size() <= this->sync_value.size();
if (!this->sync_valid) {
return;
}
std::copy(bytes.begin(), bytes.end(), this->sync_value.begin());
this->sync_size = bytes.size();
}
size_t max_packet_size() const { return this->payload_length > 0 ? this->payload_length : 255; }
};
// The parts of the last successfully applied profile that runtime operations read.
// SetPacketParams is encoded once; sends only change its payload length byte.
struct AppliedProfile {
uint32_t frequency{0};
std::array<uint8_t, 9> packet_params{};
uint8_t packet_params_size{0};
uint8_t payload_length{0};
uint8_t modulation{PACKET_TYPE_LORA};
bool rx_start{false};
size_t max_packet_size() const { return this->payload_length > 0 ? this->payload_length : 255; }
};
} // namespace esphome::sx126x
+169 -484
View File
@@ -2,534 +2,217 @@
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::sx126x {
ESPHOME_LOG_TAG(TAG, "sx126x");
static constexpr uint16_t RAMP[8] PROGMEM = {10, 20, 40, 80, 200, 800, 1700, 3400};
// Aligned 32 bit loads from flash are safe on ESP8266, so this table is read directly
static constexpr uint32_t BW_HZ[31] PROGMEM = {4800, 5800, 7300, 9700, 11700, 14600, 19500, 23400,
29300, 39000, 46900, 58600, 78200, 93800, 117300, 156200,
187200, 234300, 312000, 373600, 467000, 7810, 10420, 15630,
20830, 31250, 41670, 62500, 125000, 250000, 500000};
static constexpr uint8_t BW_LORA[10] PROGMEM = {LORA_BW_7810, LORA_BW_10420, LORA_BW_15630, LORA_BW_20830,
LORA_BW_31250, LORA_BW_41670, LORA_BW_62500, LORA_BW_125000,
LORA_BW_250000, LORA_BW_500000};
static constexpr uint8_t BW_FSK[21] PROGMEM = {
FSK_BW_4800, FSK_BW_5800, FSK_BW_7300, FSK_BW_9700, FSK_BW_11700, FSK_BW_14600, FSK_BW_19500,
FSK_BW_23400, FSK_BW_29300, FSK_BW_39000, FSK_BW_46900, FSK_BW_58600, FSK_BW_78200, FSK_BW_93800,
FSK_BW_117300, FSK_BW_156200, FSK_BW_187200, FSK_BW_234300, FSK_BW_312000, FSK_BW_373600, FSK_BW_467000};
static constexpr uint32_t RESET_DELAY_HIGH_US = 5000;
static constexpr uint32_t RESET_DELAY_LOW_US = 2000;
static constexpr uint32_t SWITCHING_DELAY_US = 1;
static constexpr uint32_t TRANSMIT_TIMEOUT_MS = 4000;
static constexpr uint32_t BUSY_TIMEOUT_MS = 20;
static constexpr uint32_t MAX_TRANSMIT_TIMEOUT_MS = 262143; // 24-bit SetTx timeout in 1/64 ms steps
static constexpr uint32_t RECONFIGURE_INTERVAL_ID = 0;
static constexpr uint32_t RECONFIGURE_INTERVAL_MS = 1000;
// OCP (Over Current Protection) values
static constexpr uint8_t OCP_80MA = 0x18; // 80 mA max current
static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current
// LoRa low data rate optimization threshold
static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms
uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
this->enable();
this->wait_busy_();
this->transfer_byte(RADIO_READ_BUFFER);
this->transfer_byte(offset);
uint8_t status = this->transfer_byte(0x00);
for (uint8_t &byte : packet) {
byte = this->transfer_byte(0x00);
}
this->disable();
return status;
}
void SX126x::write_fifo_(uint8_t offset, const uint8_t *data, size_t len) {
this->enable();
this->wait_busy_();
this->transfer_byte(RADIO_WRITE_BUFFER);
this->transfer_byte(offset);
for (size_t i = 0; i < len; i++) {
this->transfer_byte(data[i]);
}
this->disable();
delayMicroseconds(SWITCHING_DELAY_US);
}
uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->transfer_byte(opcode);
uint8_t status = this->transfer_byte(0x00);
for (int32_t i = 0; i < size; i++) {
data[i] = this->transfer_byte(0x00);
}
this->disable();
return status;
}
void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->transfer_byte(opcode);
for (int32_t i = 0; i < size; i++) {
this->transfer_byte(data[i]);
}
this->disable();
delayMicroseconds(SWITCHING_DELAY_US);
}
void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->write_byte(RADIO_READ_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
this->write_byte(0x00);
for (int32_t i = 0; i < size; i++) {
data[i] = this->transfer_byte(0x00);
}
this->disable();
}
void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->write_byte(RADIO_WRITE_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
for (int32_t i = 0; i < size; i++) {
this->transfer_byte(data[i]);
}
this->disable();
delayMicroseconds(SWITCHING_DELAY_US);
}
void IRAM_ATTR SX126x::gpio_intr(SX126x *arg) { arg->enable_loop_soon_any_context(); }
// Indexed by SX126xError.
PROGMEM_STRING_TABLE(SX126xErrorStrings, "none", "timeout", "invalid payload", "radio not ready", "bus timeout",
"radio busy", "cancelled");
void SX126x::setup() {
// Sized once so later packet reads never reallocate; read_packet() sets the length.
this->packet_.resize(SX126X_MAX_PACKET_SIZE);
// setup pins
this->busy_pin_->setup();
this->rst_pin_->setup();
this->dio1_pin_->setup();
if (this->dio1_pin_->is_internal()) {
static_cast<InternalGPIOPin *>(this->dio1_pin_)
->attach_interrupt(&SX126x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
GPIOPin *dio1 = this->radio_.get_dio1_pin();
this->radio_.bus().get_busy_pin()->setup();
this->radio_.get_rst_pin()->setup();
dio1->setup();
if (dio1->is_internal()) {
static_cast<InternalGPIOPin *>(dio1)->attach_interrupt(&SX126x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
// start spi
this->spi_setup();
this->radio_.bus().setup();
// configure rf
this->configure();
// configure rf; a failure is reported through mark_failed() only, with its own reason
const RadioEffects effects = this->radio_.configure(this->requested_profile_, this->version_);
if (effects.warning != nullptr) {
this->mark_failed(effects.warning);
return;
}
this->apply_(effects);
}
void SX126x::apply_(const RadioEffects &effects) {
// User code may only run once the radio has committed. A rejection during another
// operation's command sequence carries no status or callbacks.
ESPHOME_DEBUG_ASSERT(!this->radio_.busy() || (!effects.report && effects.listener == nullptr && !effects.received));
if (effects.sleep_loop) {
this->disable_loop();
} else if (this->radio_.state() != RadioState::RADIO_STATE_NEEDS_CONFIGURATION) {
this->enable_loop();
}
this->watch_configuration_();
// The packet buffer is borrowed from the status report until listeners return;
// loop() will not refill it meanwhile.
if (effects.received) {
this->rx_dispatching_ = true;
}
if (effects.report) {
this->status_comm_result(effects.warning == nullptr, effects.warning);
}
if (effects.listener != nullptr) {
effects.listener->on_transmit_complete(effects.result);
}
if (effects.received) {
for (auto *listener : this->listeners_) {
listener->on_packet(this->packet_, effects.rssi, effects.snr);
}
this->packet_trigger_.trigger(this->packet_, effects.rssi, effects.snr);
this->rx_dispatching_ = false;
}
}
void SX126x::configure() {
uint8_t buf[8];
// toggle chip reset
this->rst_pin_->digital_write(true);
delayMicroseconds(RESET_DELAY_HIGH_US);
this->rst_pin_->digital_write(false);
delayMicroseconds(RESET_DELAY_LOW_US);
this->rst_pin_->digital_write(true);
delayMicroseconds(RESET_DELAY_HIGH_US);
// wakeup
this->read_opcode_(RADIO_GET_STATUS, nullptr, 0);
// config tcxo
if (this->tcxo_voltage_ != TCXO_CTRL_NONE) {
uint32_t delay = this->tcxo_delay_ >> 6;
buf[0] = this->tcxo_voltage_;
buf[1] = (delay >> 16) & 0xFF;
buf[2] = (delay >> 8) & 0xFF;
buf[3] = (delay >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_TCXOMODE, buf, 4);
buf[0] = 0x7F;
this->write_opcode_(RADIO_CALIBRATE, buf, 1);
}
// clear errors
buf[0] = 0x00;
buf[1] = 0x00;
this->write_opcode_(RADIO_CLR_ERROR, buf, 2);
// rf switch
if (this->rf_switch_) {
buf[0] = 0x01;
this->write_opcode_(RADIO_SET_RFSWITCHMODE, buf, 1);
}
// check silicon version to make sure hw is ok
this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, sizeof(this->version_));
this->version_[sizeof(this->version_) - 1] = '\0';
if (strncmp(this->version_, "SX126", 5) != 0 && strncmp(this->version_, "LLCC68", 6) != 0) {
this->mark_failed(LOG_STR("Unsupported chip version"));
// Before setup() the bus is not set up, and after a failed setup the radio stays unused
if (!this->is_ready()) {
return;
}
// setup packet type
buf[0] = this->modulation_;
this->write_opcode_(RADIO_SET_PACKETTYPE, buf, 1);
// calibrate image
this->run_image_cal();
// set frequency
uint64_t freq = ((uint64_t) this->frequency_ << 25) / XTAL_FREQ;
buf[0] = (uint8_t) ((freq >> 24) & 0xFF);
buf[1] = (uint8_t) ((freq >> 16) & 0xFF);
buf[2] = (uint8_t) ((freq >> 8) & 0xFF);
buf[3] = (uint8_t) (freq & 0xFF);
this->write_opcode_(RADIO_SET_RFFREQUENCY, buf, 4);
// configure pa
int8_t pa_power = this->pa_power_;
if (this->sx1261_) {
// the following values were taken from section 13.1.14.1 table 13-21
// in rev 2.1 of the datasheet
if (pa_power == 15) {
uint8_t cfg[4] = {0x06, 0x00, 0x01, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
} else {
uint8_t cfg[4] = {0x04, 0x00, 0x01, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
}
pa_power = std::max(pa_power, (int8_t) -3);
pa_power = std::min(pa_power, (int8_t) 14);
buf[0] = OCP_80MA;
this->write_register_(REG_OCP, buf, 1);
} else {
// the following values were taken from section 13.1.14.1 table 13-21
// in rev 2.1 of the datasheet
uint8_t cfg[4] = {0x04, 0x07, 0x00, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
pa_power = std::max(pa_power, (int8_t) -3);
pa_power = std::min(pa_power, (int8_t) 22);
buf[0] = OCP_140MA;
this->write_register_(REG_OCP, buf, 1);
if (!this->radio_.can_configure()) {
ESP_LOGW(TAG, "Radio operation in progress, configuration ignored");
return;
}
buf[0] = pa_power;
buf[1] = this->pa_ramp_;
this->write_opcode_(RADIO_SET_TXPARAMS, buf, 2);
this->cold_sleep_ = false;
this->apply_(this->radio_.configure(this->requested_profile_, this->version_));
}
// configure modem
if (this->modulation_ == PACKET_TYPE_LORA) {
// set modulation params
float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_];
buf[0] = this->spreading_factor_;
buf[1] = progmem_read_byte(&BW_LORA[this->bandwidth_ - SX126X_BW_7810]);
buf[2] = this->coding_rate_;
buf[3] = (duration > LOW_DATA_RATE_OPTIMIZE_THRESHOLD) ? 0x01 : 0x00;
this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 4);
// set packet params and sync word
this->set_packet_params_(this->get_max_packet_size());
if (this->sync_value_.size() == 2) {
this->write_register_(REG_LORA_SYNCWORD, this->sync_value_.data(), this->sync_value_.size());
}
} else {
// set modulation params
uint32_t bitrate = ((uint64_t) XTAL_FREQ * 32) / this->bitrate_;
uint32_t fdev = ((uint64_t) this->deviation_ << 25) / XTAL_FREQ;
buf[0] = (bitrate >> 16) & 0xFF;
buf[1] = (bitrate >> 8) & 0xFF;
buf[2] = (bitrate >> 0) & 0xFF;
buf[3] = this->shaping_;
buf[4] = progmem_read_byte(&BW_FSK[this->bandwidth_ - SX126X_BW_4800]);
buf[5] = (fdev >> 16) & 0xFF;
buf[6] = (fdev >> 8) & 0xFF;
buf[7] = (fdev >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 8);
// set crc params
if (this->crc_enable_) {
buf[0] = this->crc_initial_ >> 8;
buf[1] = this->crc_initial_ & 0xFF;
this->write_register_(REG_CRC_INITIAL, buf, 2);
buf[0] = this->crc_polynomial_ >> 8;
buf[1] = this->crc_polynomial_ & 0xFF;
this->write_register_(REG_CRC_POLYNOMIAL, buf, 2);
}
// set whitening params
if (this->whitening_enable_) {
// according to the datasheet, section 12 table 12-1 "The user should not
// change the value of the 7 MSB of this register"
this->read_register_(REG_WHITENING_INITIAL, buf, 1);
buf[0] = (buf[0] & 0xFE) | ((this->whitening_initial_ >> 8) & 0x01);
buf[1] = this->whitening_initial_ & 0xFF;
this->write_register_(REG_WHITENING_INITIAL, buf, 2);
}
// set packet params and sync word
this->set_packet_params_(this->get_max_packet_size());
if (!this->sync_value_.empty()) {
this->write_register_(REG_GFSK_SYNCWORD, this->sync_value_.data(), this->sync_value_.size());
}
// A failed command leaves the radio needing configuration, which nothing else would do, so a node
// that only uses YAML would stay deaf. Cold sleep is asked for, so it waits for a mode command.
void SX126x::watch_configuration_() {
const bool needed = this->radio_.state() == RadioState::RADIO_STATE_NEEDS_CONFIGURATION && !this->cold_sleep_;
if (needed == this->reconfigure_pending_) {
return;
}
// switch to rx or sleep
if (this->rx_start_) {
this->set_mode_rx();
this->reconfigure_pending_ = needed;
if (needed) {
this->set_interval(RECONFIGURE_INTERVAL_ID, RECONFIGURE_INTERVAL_MS, [this]() { this->configure(); });
} else {
this->set_mode_sleep();
this->cancel_interval(RECONFIGURE_INTERVAL_ID);
}
}
size_t SX126x::get_max_packet_size() {
if (this->payload_length_ > 0) {
return this->payload_length_;
void SX126x::set_mode_rx() { this->change_mode_(ModeCommand::MODE_COMMAND_RX); }
void SX126x::set_mode_tx() { this->change_mode_(ModeCommand::MODE_COMMAND_TX); }
void SX126x::set_mode_standby(SX126xStandbyMode mode) { this->change_mode_(ModeCommand::MODE_COMMAND_STANDBY, mode); }
void SX126x::set_mode_sleep(bool cold) {
this->change_mode_(cold ? ModeCommand::MODE_COMMAND_COLD_SLEEP : ModeCommand::MODE_COMMAND_SLEEP);
}
void SX126x::run_image_cal() { this->change_mode_(ModeCommand::MODE_COMMAND_IMAGE_CAL); }
void SX126x::change_mode_(ModeCommand command, uint8_t arg) {
const bool sleep = command == ModeCommand::MODE_COMMAND_SLEEP || command == ModeCommand::MODE_COMMAND_COLD_SLEEP;
if (this->cold_sleep_) {
if (sleep) {
return;
}
// Cold sleep lost the configuration, so waking needs it applied again first
this->configure();
}
return SX126X_MAX_PACKET_SIZE;
if (!this->radio_.can_change_mode()) {
ESP_LOGW(TAG, "Radio unavailable, mode change ignored");
return;
}
this->cold_sleep_ = command == ModeCommand::MODE_COMMAND_COLD_SLEEP;
this->apply_(this->radio_.change_mode(command, arg));
}
void SX126x::set_packet_params_(uint8_t payload_length) {
uint8_t buf[9];
if (this->modulation_ == PACKET_TYPE_LORA) {
buf[0] = (this->preamble_size_ >> 8) & 0xFF;
buf[1] = (this->preamble_size_ >> 0) & 0xFF;
buf[2] = (this->payload_length_ > 0) ? 0x01 : 0x00;
buf[3] = payload_length;
buf[4] = (this->crc_enable_) ? 0x01 : 0x00;
buf[5] = 0x00;
this->write_opcode_(RADIO_SET_PACKETPARAMS, buf, 6);
} else {
uint16_t preamble_size = this->preamble_size_ * 8;
buf[0] = (preamble_size >> 8) & 0xFF;
buf[1] = (preamble_size >> 0) & 0xFF;
buf[2] = (this->preamble_detect_ > 0) ? ((this->preamble_detect_ - 1) | 0x04) : 0x00;
buf[3] = this->sync_value_.size() * 8;
buf[4] = 0x00;
buf[5] = (this->payload_length_ > 0) ? 0x00 : 0x01;
buf[6] = payload_length;
if (this->crc_enable_) {
buf[7] = (this->crc_inverted_ ? 0x04 : 0x00) + (this->crc_size_ & 0x02);
} else {
buf[7] = 0x01;
}
buf[8] = (this->whitening_enable_) ? 0x01 : 0x00;
this->write_opcode_(RADIO_SET_PACKETPARAMS, buf, 9);
}
}
size_t SX126x::get_max_packet_size() { return this->radio_.max_packet_size(); }
SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) {
// Callers such as a template number's on_value can run before setup() or after a failed setup
if (!this->is_ready()) {
ESP_LOGW(TAG, "Radio not ready, packet dropped");
return SX126xError::SX126X_ERROR_NOT_READY;
}
if (this->payload_length_ > 0 && this->payload_length_ != len) {
ESP_LOGE(TAG, "Packet size does not match config");
return SX126xError::INVALID_PARAMS;
}
if (len == 0 || len > this->get_max_packet_size()) {
ESP_LOGE(TAG, "Packet size out of range");
return SX126xError::INVALID_PARAMS;
}
SX126xError ret = SX126xError::NONE;
this->set_mode_standby(STDBY_XOSC);
if (this->payload_length_ == 0) {
this->set_packet_params_(len);
}
this->write_fifo_(0x00, data, len);
this->set_mode_tx();
// wait until transmit completes, typically the delay will be less than 100 ms
uint32_t start = millis();
while (!this->dio1_pin_->digital_read()) {
App.feed_wdt();
if (millis() - start > TRANSMIT_TIMEOUT_MS) {
ESP_LOGE(TAG, "Transmit packet failure");
ret = SX126xError::TIMEOUT;
break;
SX126xError result = this->start_transmit_(nullptr, data, len, TRANSMIT_TIMEOUT_MS);
if (result == SX126xError::SX126X_ERROR_NONE) {
// Wait for this send's own completion. A send without a listener cannot be cancelled,
// and no user code runs between these polls, so the send cannot be replaced.
result = SX126xError::SX126X_ERROR_NOT_READY;
while (this->radio_.is_transmitting()) {
const RadioEffects effects = this->radio_.poll_transmit(millis());
if (effects.finished) {
result = effects.result;
this->apply_(effects);
break;
}
App.feed_wdt();
}
}
uint8_t buf[2];
buf[0] = 0xFF;
buf[1] = 0xFF;
this->write_opcode_(RADIO_CLR_IRQSTATUS, buf, 2);
if (this->payload_length_ == 0) {
this->set_packet_params_(this->get_max_packet_size());
if (result != SX126xError::SX126X_ERROR_NONE) {
ESP_LOGW(
TAG, "Packet send failed: %s",
LOG_STR_ARG(SX126xErrorStrings::get_log_str(static_cast<uint8_t>(result), SX126xErrorStrings::LAST_INDEX)));
}
if (this->rx_start_) {
this->set_mode_rx();
} else {
this->set_mode_sleep();
}
return ret;
return result;
}
void SX126x::call_listeners_(const std::vector<uint8_t> &packet, float rssi, float snr) {
for (auto &listener : this->listeners_) {
listener->on_packet(packet, rssi, snr);
SX126xError SX126x::start_transmit_async(SX126xTransmitListener *listener, const uint8_t *data, size_t len,
uint32_t timeout_ms) {
if (listener == nullptr || timeout_ms == 0 || timeout_ms > MAX_TRANSMIT_TIMEOUT_MS) {
return SX126xError::SX126X_ERROR_INVALID_PARAMS;
}
this->packet_trigger_.trigger(packet, rssi, snr);
return this->start_transmit_(listener, data, len, timeout_ms);
}
SX126xError SX126x::start_transmit_(SX126xTransmitListener *listener, const uint8_t *data, size_t len,
uint32_t timeout_ms) {
if (!this->is_ready()) {
return SX126xError::SX126X_ERROR_NOT_READY;
}
const RadioEffects effects = this->radio_.start_transmit(listener, data, len, timeout_ms);
this->apply_(effects);
return effects.result;
}
bool SX126x::cancel_transmit_async(SX126xTransmitListener *listener) {
const RadioEffects effects = this->radio_.cancel_transmit(listener);
if (!effects.finished) {
return false;
}
this->apply_(effects);
return true;
}
void SX126x::loop() {
if (this->dio1_pin_->is_internal()) {
this->disable_loop();
}
if (!this->dio1_pin_->digital_read()) {
if (this->rx_dispatching_ || this->is_failed()) {
return;
}
uint16_t status;
uint8_t buf[3];
uint8_t rssi;
int8_t snr;
this->read_opcode_(RADIO_GET_IRQSTATUS, buf, 2);
this->write_opcode_(RADIO_CLR_IRQSTATUS, buf, 2);
status = (buf[0] << 8) | buf[1];
if ((status & IRQ_RX_DONE) == IRQ_RX_DONE) {
if ((status & IRQ_CRC_ERROR) != IRQ_CRC_ERROR) {
this->read_opcode_(RADIO_GET_PACKETSTATUS, buf, 3);
if (this->modulation_ == PACKET_TYPE_LORA) {
rssi = buf[0];
snr = buf[1];
} else {
rssi = buf[2];
snr = 0;
}
this->read_opcode_(RADIO_GET_RXBUFFERSTATUS, buf, 2);
this->packet_.resize(buf[0]);
this->read_fifo_(buf[1], this->packet_);
this->call_listeners_(this->packet_, (float) rssi / -2.0f, (float) snr / 4.0f);
}
}
}
void SX126x::run_image_cal() {
// the following values were taken from section 9.2.1 table 9-2
// in rev 2.1 of the datasheet
uint8_t buf[2] = {0, 0};
if (this->frequency_ > 900000000) {
buf[0] = 0xE1;
buf[1] = 0xE9;
} else if (this->frequency_ > 850000000) {
buf[0] = 0xD7;
buf[1] = 0xDB;
} else if (this->frequency_ > 770000000) {
buf[0] = 0xC1;
buf[1] = 0xC5;
} else if (this->frequency_ > 460000000) {
buf[0] = 0x75;
buf[1] = 0x81;
} else if (this->frequency_ > 425000000) {
buf[0] = 0x6B;
buf[1] = 0x6F;
}
if (buf[0] > 0 && buf[1] > 0) {
this->write_opcode_(RADIO_CALIBRATEIMAGE, buf, 2);
}
}
void SX126x::set_mode_rx() {
uint8_t buf[8];
// configure irq params
uint16_t irq = IRQ_RX_DONE | IRQ_RX_TX_TIMEOUT | IRQ_CRC_ERROR;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[5] = (IRQ_RADIO_NONE >> 0) & 0xFF;
buf[6] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[7] = (IRQ_RADIO_NONE >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_DIOIRQPARAMS, buf, 8);
// set timeout to 0
buf[0] = 0x00;
this->write_opcode_(RADIO_SET_LORASYMBTIMEOUT, buf, 1);
// switch to continuous mode rx
buf[0] = 0xFF;
buf[1] = 0xFF;
buf[2] = 0xFF;
this->write_opcode_(RADIO_SET_RX, buf, 3);
}
void SX126x::set_mode_tx() {
uint8_t buf[8];
// configure irq params
uint16_t irq = IRQ_TX_DONE | IRQ_RX_TX_TIMEOUT;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[5] = (IRQ_RADIO_NONE >> 0) & 0xFF;
buf[6] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[7] = (IRQ_RADIO_NONE >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_DIOIRQPARAMS, buf, 8);
// switch to single mode tx
buf[0] = 0x00;
buf[1] = 0x00;
buf[2] = 0x00;
this->write_opcode_(RADIO_SET_TX, buf, 3);
}
void SX126x::set_mode_sleep(bool cold) {
// 0x04 = warm start (config retained), 0x00 = cold start (config lost, lowest power)
uint8_t buf[1];
buf[0] = cold ? 0x00 : 0x04;
this->write_opcode_(RADIO_SET_SLEEP, buf, 1);
}
void SX126x::set_mode_standby(SX126xStandbyMode mode) {
uint8_t buf[1];
buf[0] = mode;
this->write_opcode_(RADIO_SET_STANDBY, buf, 1);
}
void SX126x::wait_busy_() {
// wait if the device is busy, the maximum delay is only be a few ms
// with most commands taking only a few us
uint32_t start = millis();
while (this->busy_pin_->digital_read()) {
if (millis() - start > BUSY_TIMEOUT_MS) {
this->mark_failed(LOG_STR("Wait busy timeout"));
break;
}
}
this->apply_(this->radio_.poll(App.get_loop_component_start_time(), this->packet_));
}
void SX126x::dump_config() {
ESP_LOGCONFIG(TAG, "SX126x:");
LOG_PIN(" CS Pin: ", this->cs_);
LOG_PIN(" BUSY Pin: ", this->busy_pin_);
LOG_PIN(" RST Pin: ", this->rst_pin_);
LOG_PIN(" DIO1 Pin: ", this->dio1_pin_);
ESP_LOGCONFIG(TAG, "SX126x:\n Ready for operation: %s",
YESNO(!this->is_failed() && this->radio_.state() == RadioState::RADIO_STATE_IDLE));
LOG_PIN(" CS Pin: ", this->radio_.bus().get_cs_pin());
LOG_PIN(" BUSY Pin: ", this->radio_.bus().get_busy_pin());
LOG_PIN(" RST Pin: ", this->radio_.get_rst_pin());
LOG_PIN(" DIO1 Pin: ", this->radio_.get_dio1_pin());
ESP_LOGCONFIG(TAG,
" HW Version: %15s\n"
" Requested configuration:\n"
" Frequency: %" PRIu32 " Hz\n"
" Bandwidth: %" PRIu32 " Hz\n"
" PA Power: %" PRId8 " dBm\n"
" PA Ramp: %" PRIu16 " us\n"
" Payload Length: %" PRIu32 "\n"
" Payload Length: %" PRIu8 "\n"
" CRC Enable: %s\n"
" Rx Start: %s",
this->version_, this->frequency_, BW_HZ[this->bandwidth_], this->pa_power_,
progmem_read_uint16(&RAMP[this->pa_ramp_]), this->payload_length_, TRUEFALSE(this->crc_enable_),
TRUEFALSE(this->rx_start_));
if (this->modulation_ == PACKET_TYPE_GFSK) {
this->version_, this->requested_profile_.frequency,
SX126xCommands::bandwidth_hz(this->requested_profile_.bandwidth), this->requested_profile_.pa_power,
SX126xCommands::ramp_us(this->requested_profile_.pa_ramp), this->requested_profile_.payload_length,
TRUEFALSE(this->requested_profile_.crc_enable), TRUEFALSE(this->requested_profile_.rx_start));
if (this->requested_profile_.modulation == PACKET_TYPE_GFSK) {
const char *shaping = "NONE";
if (this->shaping_ == GAUSSIAN_BT_0_3) {
if (this->requested_profile_.shaping == GAUSSIAN_BT_0_3) {
shaping = "GAUSSIAN_BT_0_3";
} else if (this->shaping_ == GAUSSIAN_BT_0_5) {
} else if (this->requested_profile_.shaping == GAUSSIAN_BT_0_5) {
shaping = "GAUSSIAN_BT_0_5";
} else if (this->shaping_ == GAUSSIAN_BT_0_7) {
} else if (this->requested_profile_.shaping == GAUSSIAN_BT_0_7) {
shaping = "GAUSSIAN_BT_0_7";
} else if (this->shaping_ == GAUSSIAN_BT_1_0) {
} else if (this->requested_profile_.shaping == GAUSSIAN_BT_1_0) {
shaping = "GAUSSIAN_BT_1_0";
}
ESP_LOGCONFIG(TAG,
@@ -539,14 +222,15 @@ void SX126x::dump_config() {
" Preamble Size: %" PRIu16 "\n"
" Preamble Detect: %" PRIu16 "\n"
" Bitrate: %" PRIu32 "b/s",
this->deviation_, shaping, this->preamble_size_, this->preamble_detect_, this->bitrate_);
} else if (this->modulation_ == PACKET_TYPE_LORA) {
this->requested_profile_.deviation, shaping, this->requested_profile_.preamble_size,
this->requested_profile_.preamble_detect, this->requested_profile_.bitrate);
} else if (this->requested_profile_.modulation == PACKET_TYPE_LORA) {
const char *cr = "4/8";
if (this->coding_rate_ == LORA_CR_4_5) {
if (this->requested_profile_.coding_rate == LORA_CR_4_5) {
cr = "4/5";
} else if (this->coding_rate_ == LORA_CR_4_6) {
} else if (this->requested_profile_.coding_rate == LORA_CR_4_6) {
cr = "4/6";
} else if (this->coding_rate_ == LORA_CR_4_7) {
} else if (this->requested_profile_.coding_rate == LORA_CR_4_7) {
cr = "4/7";
}
ESP_LOGCONFIG(TAG,
@@ -554,16 +238,17 @@ void SX126x::dump_config() {
" Spreading Factor: %" PRIu8 "\n"
" Coding Rate: %s\n"
" Preamble Size: %" PRIu16,
this->spreading_factor_, cr, this->preamble_size_);
this->requested_profile_.spreading_factor, cr, this->requested_profile_.preamble_size);
}
if (!this->sync_value_.empty()) {
if (this->requested_profile_.sync_size != 0) {
char hex_buf[17]; // 8 bytes max = 16 hex chars + null
ESP_LOGCONFIG(TAG, " Sync Value: 0x%s",
format_hex_to(hex_buf, this->sync_value_.data(), this->sync_value_.size()));
ESP_LOGCONFIG(
TAG, " Sync Value: 0x%s",
format_hex_to(hex_buf, this->requested_profile_.sync_value.data(), this->requested_profile_.sync_size));
}
ESP_LOGCONFIG(TAG, " Whitening Enable: %s", TRUEFALSE(this->whitening_enable_));
if (this->whitening_enable_) {
ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_);
ESP_LOGCONFIG(TAG, " Whitening Enable: %s", TRUEFALSE(this->requested_profile_.whitening_enable));
if (this->requested_profile_.whitening_enable) {
ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->requested_profile_.whitening_initial);
}
}
+80 -114
View File
@@ -1,157 +1,123 @@
#pragma once
#include "sx126x_radio.h"
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "sx126x_reg.h"
#include <utility>
#include <vector>
namespace esphome::sx126x {
static constexpr size_t SX126X_MAX_PACKET_SIZE = 255;
enum SX126xBw : uint8_t {
// FSK
SX126X_BW_4800,
SX126X_BW_5800,
SX126X_BW_7300,
SX126X_BW_9700,
SX126X_BW_11700,
SX126X_BW_14600,
SX126X_BW_19500,
SX126X_BW_23400,
SX126X_BW_29300,
SX126X_BW_39000,
SX126X_BW_46900,
SX126X_BW_58600,
SX126X_BW_78200,
SX126X_BW_93800,
SX126X_BW_117300,
SX126X_BW_156200,
SX126X_BW_187200,
SX126X_BW_234300,
SX126X_BW_312000,
SX126X_BW_373600,
SX126X_BW_467000,
// LORA
SX126X_BW_7810,
SX126X_BW_10420,
SX126X_BW_15630,
SX126X_BW_20830,
SX126X_BW_31250,
SX126X_BW_41670,
SX126X_BW_62500,
SX126X_BW_125000,
SX126X_BW_250000,
SX126X_BW_500000,
};
enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX126X_ERROR_NOT_READY };
class SX126xListener {
public:
// Called from loop(). The packet is borrowed until this returns; copy what you keep.
// Listeners may send, change mode or configure. A send returns BUSY if another packet
// is already waiting, which is then delivered by the next loop().
virtual void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) = 0;
};
class SX126x final : public Component,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_8MHZ> {
class SX126x final : public Component {
public:
void set_spi_parent(spi::SPIComponent *parent) { this->radio_.bus().set_spi_parent(parent); }
void set_cs_pin(GPIOPin *pin) { this->radio_.bus().set_cs_pin(pin); }
void set_data_rate(uint32_t rate) { this->radio_.bus().set_data_rate(rate); }
void set_mode(spi::SPIMode mode) { this->radio_.bus().set_mode(mode); }
void set_bit_order(spi::SPIBitOrder order) { this->radio_.bus().set_bit_order(order); }
void set_release_device(bool release) { this->radio_.bus().set_release_device(release); }
void set_write_only(bool write_only) { this->radio_.bus().set_write_only(write_only); }
#ifdef USE_SPI_PSRAM_DMA
void set_psram_dma(bool enable) { this->radio_.bus().set_psram_dma(enable); }
#endif
// Releases the SPI bus, as SPIDevice::spi_teardown() did before SX126x stopped being one
void spi_teardown() { this->radio_.bus().teardown(); }
size_t get_max_packet_size();
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
void setup() override;
void loop() override;
void dump_config() override;
void set_bandwidth(SX126xBw bandwidth) { this->bandwidth_ = bandwidth; }
void set_bitrate(uint32_t bitrate) { this->bitrate_ = bitrate; }
void set_busy_pin(GPIOPin *busy_pin) { this->busy_pin_ = busy_pin; }
void set_coding_rate(uint8_t coding_rate) { this->coding_rate_ = coding_rate; }
void set_crc_enable(bool crc_enable) { this->crc_enable_ = crc_enable; }
void set_crc_inverted(bool crc_inverted) { this->crc_inverted_ = crc_inverted; }
void set_crc_size(uint8_t crc_size) { this->crc_size_ = crc_size; }
void set_crc_polynomial(uint16_t crc_polynomial) { this->crc_polynomial_ = crc_polynomial; }
void set_crc_initial(uint16_t crc_initial) { this->crc_initial_ = crc_initial; }
void set_whitening_enable(bool whitening_enable) { this->whitening_enable_ = whitening_enable; }
void set_whitening_initial(uint16_t whitening_initial) { this->whitening_initial_ = whitening_initial; }
void set_deviation(uint32_t deviation) { this->deviation_ = deviation; }
void set_dio1_pin(GPIOPin *dio1_pin) { this->dio1_pin_ = dio1_pin; }
void set_frequency(uint32_t frequency) { this->frequency_ = frequency; }
void set_sx1261(bool sx1261) { this->sx1261_ = sx1261; }
void set_bandwidth(SX126xBw bandwidth) { this->requested_profile_.bandwidth = bandwidth; }
void set_bitrate(uint32_t bitrate) { this->requested_profile_.bitrate = bitrate; }
void set_busy_pin(GPIOPin *busy_pin) { this->radio_.bus().set_busy_pin(busy_pin); }
void set_coding_rate(uint8_t coding_rate) { this->requested_profile_.coding_rate = coding_rate; }
void set_crc_enable(bool crc_enable) { this->requested_profile_.crc_enable = crc_enable; }
void set_crc_inverted(bool crc_inverted) { this->requested_profile_.crc_inverted = crc_inverted; }
void set_crc_size(uint8_t crc_size) { this->requested_profile_.crc_size = crc_size; }
void set_crc_polynomial(uint16_t crc_polynomial) { this->requested_profile_.crc_polynomial = crc_polynomial; }
void set_crc_initial(uint16_t crc_initial) { this->requested_profile_.crc_initial = crc_initial; }
void set_whitening_enable(bool whitening_enable) { this->requested_profile_.whitening_enable = whitening_enable; }
void set_whitening_initial(uint16_t whitening_initial) {
this->requested_profile_.whitening_initial = whitening_initial;
}
void set_deviation(uint32_t deviation) { this->requested_profile_.deviation = deviation; }
void set_dio1_pin(GPIOPin *dio1_pin) { this->radio_.set_dio1_pin(dio1_pin); }
void set_frequency(uint32_t frequency) { this->requested_profile_.frequency = frequency; }
void set_sx1261(bool sx1261) { this->requested_profile_.sx1261 = sx1261; }
// Mode changes are ignored with a warning unless the radio is idle and configured.
void set_mode_rx();
void set_mode_tx();
void set_mode_standby(SX126xStandbyMode mode);
void set_mode_sleep(bool cold = false);
void set_modulation(uint8_t modulation) { this->modulation_ = modulation; }
void set_pa_power(int8_t power) { this->pa_power_ = power; }
void set_pa_ramp(uint8_t ramp) { this->pa_ramp_ = ramp; }
void set_payload_length(uint8_t payload_length) { this->payload_length_ = payload_length; }
void set_preamble_detect(uint16_t preamble_detect) { this->preamble_detect_ = preamble_detect; }
void set_preamble_size(uint16_t preamble_size) { this->preamble_size_ = preamble_size; }
void set_rst_pin(GPIOPin *rst_pin) { this->rst_pin_ = rst_pin; }
void set_rx_start(bool rx_start) { this->rx_start_ = rx_start; }
void set_rf_switch(bool rf_switch) { this->rf_switch_ = rf_switch; }
void set_shaping(uint8_t shaping) { this->shaping_ = shaping; }
void set_spreading_factor(uint8_t spreading_factor) { this->spreading_factor_ = spreading_factor; }
void set_sync_value(const std::vector<uint8_t> &sync_value) { this->sync_value_ = sync_value; }
void set_tcxo_voltage(uint8_t tcxo_voltage) { this->tcxo_voltage_ = tcxo_voltage; }
void set_tcxo_delay(uint32_t tcxo_delay) { this->tcxo_delay_ = tcxo_delay; }
void set_modulation(uint8_t modulation) { this->requested_profile_.modulation = modulation; }
void set_pa_power(int8_t power) { this->requested_profile_.pa_power = power; }
void set_pa_ramp(uint8_t ramp) { this->requested_profile_.pa_ramp = ramp; }
void set_payload_length(uint8_t payload_length) { this->requested_profile_.payload_length = payload_length; }
void set_preamble_detect(uint16_t preamble_detect) { this->requested_profile_.preamble_detect = preamble_detect; }
void set_preamble_size(uint16_t preamble_size) { this->requested_profile_.preamble_size = preamble_size; }
void set_rst_pin(GPIOPin *rst_pin) { this->radio_.set_rst_pin(rst_pin); }
void set_rx_start(bool rx_start) { this->requested_profile_.rx_start = rx_start; }
void set_rf_switch(bool rf_switch) { this->requested_profile_.rf_switch = rf_switch; }
void set_shaping(uint8_t shaping) { this->requested_profile_.shaping = shaping; }
void set_spreading_factor(uint8_t spreading_factor) { this->requested_profile_.spreading_factor = spreading_factor; }
void set_sync_value(const std::vector<uint8_t> &sync_value) { this->requested_profile_.set_sync_value(sync_value); }
void set_tcxo_voltage(uint8_t tcxo_voltage) { this->requested_profile_.tcxo_voltage = tcxo_voltage; }
void set_tcxo_delay(uint32_t tcxo_delay) { this->requested_profile_.tcxo_delay = tcxo_delay; }
void run_image_cal();
// Mode changes and configuration are allowed during RX callbacks when the radio
// is available; they never modify the borrowed packet buffer.
void configure();
SX126xError transmit_packet(const uint8_t *data, size_t len);
SX126xError transmit_packet(const std::vector<uint8_t> &packet) {
return this->transmit_packet(packet.data(), packet.size());
}
// Main-loop context only. SX126X_ERROR_NONE accepts one operation and promises one completion.
// Data is copied into the chip FIFO before return; listener must outlive completion.
// Pending RX rejects admission with BUSY and is delivered by a later loop().
// Timeout: 1..262143 ms. SPI/BUSY waits are synchronous; airtime is asynchronous.
[[nodiscard]] SX126xError start_transmit_async(SX126xTransmitListener *listener, const uint8_t *data, size_t len,
uint32_t timeout_ms = 4000);
[[nodiscard]] SX126xError start_transmit_async(SX126xTransmitListener *listener, const std::vector<uint8_t> &packet,
uint32_t timeout_ms = 4000) {
return this->start_transmit_async(listener, packet.data(), packet.size(), timeout_ms);
}
// Normal completion runs in loop(); cancellation completes synchronously in its caller.
// The radio is released before callbacks, which may start another operation if recovery
// succeeded. True accepts cancellation; the completion result reports cleanup failure.
bool cancel_transmit_async(SX126xTransmitListener *listener);
bool is_transmitting() const { return this->radio_.is_transmitting(); }
void register_listener(SX126xListener *listener) { this->listeners_.push_back(listener); }
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
protected:
static void gpio_intr(SX126x *arg);
void configure_fsk_ook_();
void configure_lora_();
void set_packet_params_(uint8_t payload_length);
uint8_t read_fifo_(uint8_t offset, std::vector<uint8_t> &packet);
void write_fifo_(uint8_t offset, const uint8_t *data, size_t len);
void write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size);
uint8_t read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size);
void write_register_(uint16_t reg, uint8_t *data, uint8_t size);
void read_register_(uint16_t reg, uint8_t *data, uint8_t size);
void call_listeners_(const std::vector<uint8_t> &packet, float rssi, float snr);
void wait_busy_();
private:
static void IRAM_ATTR gpio_intr(SX126x *arg) { arg->enable_loop_soon_any_context(); }
void change_mode_(ModeCommand command, uint8_t arg = 0);
// The only place that runs user code for a radio operation: loop scheduling, then status,
// then the completion or packet listeners. SX126xRadio has already committed its state.
void apply_(const RadioEffects &effects);
void watch_configuration_();
SX126xError start_transmit_(SX126xTransmitListener *listener, const uint8_t *data, size_t len, uint32_t timeout_ms);
SX126xRadio radio_;
RadioProfile requested_profile_;
char version_[16]{};
Trigger<std::vector<uint8_t>, float, float> packet_trigger_;
std::vector<SX126xListener *> listeners_;
std::vector<uint8_t> packet_;
std::vector<uint8_t> sync_value_;
GPIOPin *busy_pin_{nullptr};
GPIOPin *dio1_pin_{nullptr};
GPIOPin *rst_pin_{nullptr};
uint32_t bitrate_{0};
uint32_t deviation_{0};
uint32_t frequency_{0};
uint32_t payload_length_{0};
uint32_t tcxo_delay_{0};
uint16_t crc_polynomial_{0};
uint16_t crc_initial_{0};
uint16_t whitening_initial_{0};
uint16_t preamble_detect_{0};
uint16_t preamble_size_{0};
char version_[16];
SX126xBw bandwidth_{SX126X_BW_125000};
uint8_t crc_size_{0};
uint8_t tcxo_voltage_{0};
uint8_t coding_rate_{0};
uint8_t modulation_{PACKET_TYPE_LORA};
uint8_t pa_ramp_{0};
uint8_t shaping_{0};
uint8_t spreading_factor_{0};
int8_t pa_power_{0};
bool sx1261_{false};
bool crc_enable_{false};
bool crc_inverted_{false};
bool whitening_enable_{false};
bool rx_start_{false};
bool rf_switch_{false};
bool rx_dispatching_{false};
bool cold_sleep_{false};
bool reconfigure_pending_{false};
};
} // namespace esphome::sx126x
@@ -0,0 +1,355 @@
#include "sx126x_commands.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <cmath>
#include <cstring>
namespace esphome::sx126x {
static constexpr uint16_t RAMP[8] PROGMEM = {10, 20, 40, 80, 200, 800, 1700, 3400};
// Aligned 32 bit loads from flash are safe on ESP8266, so this table is read directly
static constexpr uint32_t BW_HZ[31] PROGMEM = {4800, 5800, 7300, 9700, 11700, 14600, 19500, 23400,
29300, 39000, 46900, 58600, 78200, 93800, 117300, 156200,
187200, 234300, 312000, 373600, 467000, 7810, 10420, 15630,
20830, 31250, 41670, 62500, 125000, 250000, 500000};
static constexpr uint8_t BW_LORA[10] PROGMEM = {LORA_BW_7810, LORA_BW_10420, LORA_BW_15630, LORA_BW_20830,
LORA_BW_31250, LORA_BW_41670, LORA_BW_62500, LORA_BW_125000,
LORA_BW_250000, LORA_BW_500000};
static constexpr uint8_t BW_FSK[21] PROGMEM = {
FSK_BW_4800, FSK_BW_5800, FSK_BW_7300, FSK_BW_9700, FSK_BW_11700, FSK_BW_14600, FSK_BW_19500,
FSK_BW_23400, FSK_BW_29300, FSK_BW_39000, FSK_BW_46900, FSK_BW_58600, FSK_BW_78200, FSK_BW_93800,
FSK_BW_117300, FSK_BW_156200, FSK_BW_187200, FSK_BW_234300, FSK_BW_312000, FSK_BW_373600, FSK_BW_467000};
// OCP (Over Current Protection) values
static constexpr uint8_t OCP_80MA = 0x18; // 80 mA max current
static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current
// LoRa low data rate optimization threshold
static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms
void SX126xCommands::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
const uint8_t header[] = {opcode, 0};
this->spi_device_.read(header, {data, size});
}
void SX126xCommands::write_opcode_(uint8_t opcode, const uint8_t *data, uint8_t size) {
this->spi_device_.write({&opcode, 1}, {data, size});
}
void SX126xCommands::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
const uint8_t header[] = {RADIO_READ_REGISTER, static_cast<uint8_t>(reg >> 8), static_cast<uint8_t>(reg), 0};
this->spi_device_.read(header, {data, size});
}
void SX126xCommands::write_register_(uint16_t reg, const uint8_t *data, uint8_t size) {
const uint8_t header[] = {RADIO_WRITE_REGISTER, static_cast<uint8_t>(reg >> 8), static_cast<uint8_t>(reg)};
this->spi_device_.write(header, {data, size});
}
ConfigurationResult SX126xCommands::configure(const RadioProfile &profile, std::span<char, 16> version,
AppliedProfile &applied) {
applied.frequency = profile.frequency;
applied.payload_length = profile.payload_length;
applied.modulation = profile.modulation;
applied.rx_start = profile.rx_start;
encode_packet_params(profile, applied);
uint8_t buf[8];
// wakeup
this->read_opcode_(RADIO_GET_STATUS, nullptr, 0);
// config tcxo
if (profile.tcxo_voltage != TCXO_CTRL_NONE) {
uint32_t delay = profile.tcxo_delay >> 6;
buf[0] = profile.tcxo_voltage;
buf[1] = (delay >> 16) & 0xFF;
buf[2] = (delay >> 8) & 0xFF;
buf[3] = (delay >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_TCXOMODE, buf, 4);
buf[0] = 0x7F;
this->write_opcode_(RADIO_CALIBRATE, buf, 1);
}
// clear errors
buf[0] = 0x00;
buf[1] = 0x00;
this->write_opcode_(RADIO_CLR_ERROR, buf, 2);
// rf switch
if (profile.rf_switch) {
buf[0] = 0x01;
this->write_opcode_(RADIO_SET_RFSWITCHMODE, buf, 1);
}
// check silicon version to make sure hw is ok
this->read_register_(REG_VERSION_STRING, reinterpret_cast<uint8_t *>(version.data()), version.size());
if (!this->spi_device_.ok()) {
return ConfigurationResult::CONFIGURATION_RESULT_BUSY_TIMEOUT;
}
version[version.size() - 1] = '\0';
if (strncmp(version.data(), "SX126", 5) != 0 && strncmp(version.data(), "LLCC68", 6) != 0) {
return ConfigurationResult::CONFIGURATION_RESULT_UNSUPPORTED;
}
// setup packet type
buf[0] = profile.modulation;
this->write_opcode_(RADIO_SET_PACKETTYPE, buf, 1);
// calibrate image
this->calibrate_image(profile.frequency);
// set frequency
uint64_t freq = ((uint64_t) profile.frequency << 25) / XTAL_FREQ;
buf[0] = (uint8_t) ((freq >> 24) & 0xFF);
buf[1] = (uint8_t) ((freq >> 16) & 0xFF);
buf[2] = (uint8_t) ((freq >> 8) & 0xFF);
buf[3] = (uint8_t) (freq & 0xFF);
this->write_opcode_(RADIO_SET_RFFREQUENCY, buf, 4);
// configure pa
int8_t pa_power = profile.pa_power;
if (profile.sx1261) {
// the following values were taken from section 13.1.14.1 table 13-21
// in rev 2.1 of the datasheet
if (pa_power == 15) {
uint8_t cfg[4] = {0x06, 0x00, 0x01, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
} else {
uint8_t cfg[4] = {0x04, 0x00, 0x01, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
}
pa_power = clamp(pa_power, (int8_t) -3, (int8_t) 14);
buf[0] = OCP_80MA;
this->write_register_(REG_OCP, buf, 1);
} else {
// the following values were taken from section 13.1.14.1 table 13-21
// in rev 2.1 of the datasheet
uint8_t cfg[4] = {0x04, 0x07, 0x00, 0x01};
this->write_opcode_(RADIO_SET_PACONFIG, cfg, 4);
pa_power = clamp(pa_power, (int8_t) -3, (int8_t) 22);
buf[0] = OCP_140MA;
this->write_register_(REG_OCP, buf, 1);
}
buf[0] = pa_power;
buf[1] = profile.pa_ramp;
this->write_opcode_(RADIO_SET_TXPARAMS, buf, 2);
// configure modem
if (profile.modulation == PACKET_TYPE_LORA) {
// set modulation params
float duration = 1000.0f * (1UL << profile.spreading_factor) / BW_HZ[profile.bandwidth];
buf[0] = profile.spreading_factor;
buf[1] = progmem_read_byte(&BW_LORA[profile.bandwidth - SX126X_BW_7810]);
buf[2] = profile.coding_rate;
buf[3] = (duration > LOW_DATA_RATE_OPTIMIZE_THRESHOLD) ? 0x01 : 0x00;
this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 4);
// set packet params and sync word
this->write_opcode_(RADIO_SET_PACKETPARAMS, applied.packet_params.data(), applied.packet_params_size);
if (profile.sync_size == 2) {
this->write_register_(REG_LORA_SYNCWORD, profile.sync_value.data(), profile.sync_size);
}
} else {
// set modulation params
uint32_t bitrate = ((uint64_t) XTAL_FREQ * 32) / profile.bitrate;
uint32_t fdev = ((uint64_t) profile.deviation << 25) / XTAL_FREQ;
buf[0] = (bitrate >> 16) & 0xFF;
buf[1] = (bitrate >> 8) & 0xFF;
buf[2] = (bitrate >> 0) & 0xFF;
buf[3] = profile.shaping;
buf[4] = progmem_read_byte(&BW_FSK[profile.bandwidth - SX126X_BW_4800]);
buf[5] = (fdev >> 16) & 0xFF;
buf[6] = (fdev >> 8) & 0xFF;
buf[7] = (fdev >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_MODULATIONPARAMS, buf, 8);
// set crc params
if (profile.crc_enable) {
buf[0] = profile.crc_initial >> 8;
buf[1] = profile.crc_initial & 0xFF;
this->write_register_(REG_CRC_INITIAL, buf, 2);
buf[0] = profile.crc_polynomial >> 8;
buf[1] = profile.crc_polynomial & 0xFF;
this->write_register_(REG_CRC_POLYNOMIAL, buf, 2);
}
// set whitening params
if (profile.whitening_enable) {
// according to the datasheet, section 12 table 12-1 "The user should not
// change the value of the 7 MSB of this register"
this->read_register_(REG_WHITENING_INITIAL, buf, 1);
buf[0] = (buf[0] & 0xFE) | ((profile.whitening_initial >> 8) & 0x01);
buf[1] = profile.whitening_initial & 0xFF;
this->write_register_(REG_WHITENING_INITIAL, buf, 2);
}
// set packet params and sync word
this->write_opcode_(RADIO_SET_PACKETPARAMS, applied.packet_params.data(), applied.packet_params_size);
if (profile.sync_size != 0) {
this->write_register_(REG_GFSK_SYNCWORD, profile.sync_value.data(), profile.sync_size);
}
}
// switch to rx or sleep
if (profile.rx_start) {
this->receive();
} else {
this->sleep();
}
return this->spi_device_.ok() ? ConfigurationResult::CONFIGURATION_RESULT_OK
: ConfigurationResult::CONFIGURATION_RESULT_BUSY_TIMEOUT;
}
void SX126xCommands::encode_packet_params(const RadioProfile &profile, AppliedProfile &applied) {
auto &buf = applied.packet_params;
const uint8_t payload_length = profile.max_packet_size();
if (profile.modulation == PACKET_TYPE_LORA) {
buf[0] = (profile.preamble_size >> 8) & 0xFF;
buf[1] = (profile.preamble_size >> 0) & 0xFF;
buf[2] = (profile.payload_length > 0) ? 0x01 : 0x00;
buf[3] = payload_length;
buf[4] = (profile.crc_enable) ? 0x01 : 0x00;
buf[5] = 0x00;
applied.packet_params_size = 6;
} else {
uint16_t preamble_size = profile.preamble_size * 8;
buf[0] = (preamble_size >> 8) & 0xFF;
buf[1] = (preamble_size >> 0) & 0xFF;
buf[2] = (profile.preamble_detect > 0) ? ((profile.preamble_detect - 1) | 0x04) : 0x00;
buf[3] = profile.sync_size * 8;
buf[4] = 0x00;
buf[5] = (profile.payload_length > 0) ? 0x00 : 0x01;
buf[6] = payload_length;
if (profile.crc_enable) {
buf[7] = (profile.crc_inverted ? 0x04 : 0x00) + (profile.crc_size & 0x02);
} else {
buf[7] = 0x01;
}
buf[8] = (profile.whitening_enable) ? 0x01 : 0x00;
applied.packet_params_size = 9;
}
}
void SX126xCommands::set_packet_length(AppliedProfile &applied, uint8_t length) {
applied.packet_params[applied.modulation == PACKET_TYPE_LORA ? 3 : 6] = length;
this->write_opcode_(RADIO_SET_PACKETPARAMS, applied.packet_params.data(), applied.packet_params_size);
}
void SX126xCommands::calibrate_image(uint32_t frequency) {
// the following values were taken from section 9.2.1 table 9-2
// in rev 2.1 of the datasheet
uint8_t buf[2] = {0, 0};
if (frequency > 900000000) {
buf[0] = 0xE1;
buf[1] = 0xE9;
} else if (frequency > 850000000) {
buf[0] = 0xD7;
buf[1] = 0xDB;
} else if (frequency > 770000000) {
buf[0] = 0xC1;
buf[1] = 0xC5;
} else if (frequency > 460000000) {
buf[0] = 0x75;
buf[1] = 0x81;
} else if (frequency > 425000000) {
buf[0] = 0x6B;
buf[1] = 0x6F;
}
if (buf[0] > 0 && buf[1] > 0) {
this->write_opcode_(RADIO_CALIBRATEIMAGE, buf, 2);
}
}
void SX126xCommands::receive() {
uint8_t buf[8];
// configure irq params
uint16_t irq = IRQ_RX_DONE | IRQ_RX_TX_TIMEOUT | IRQ_CRC_ERROR;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[5] = (IRQ_RADIO_NONE >> 0) & 0xFF;
buf[6] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[7] = (IRQ_RADIO_NONE >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_DIOIRQPARAMS, buf, 8);
// set timeout to 0
buf[0] = 0x00;
this->write_opcode_(RADIO_SET_LORASYMBTIMEOUT, buf, 1);
// switch to continuous mode rx
buf[0] = 0xFF;
buf[1] = 0xFF;
buf[2] = 0xFF;
this->write_opcode_(RADIO_SET_RX, buf, 3);
}
void SX126xCommands::transmit(uint32_t timeout_ms) {
uint8_t buf[8];
// configure irq params
uint16_t irq = IRQ_TX_DONE | IRQ_RX_TX_TIMEOUT;
buf[0] = (irq >> 8) & 0xFF;
buf[1] = (irq >> 0) & 0xFF;
buf[2] = (irq >> 8) & 0xFF;
buf[3] = (irq >> 0) & 0xFF;
buf[4] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[5] = (IRQ_RADIO_NONE >> 0) & 0xFF;
buf[6] = (IRQ_RADIO_NONE >> 8) & 0xFF;
buf[7] = (IRQ_RADIO_NONE >> 0) & 0xFF;
this->write_opcode_(RADIO_SET_DIOIRQPARAMS, buf, 8);
const uint32_t ticks = timeout_ms * 64;
buf[0] = ticks >> 16;
buf[1] = ticks >> 8;
buf[2] = ticks;
this->write_opcode_(RADIO_SET_TX, buf, 3);
}
void SX126xCommands::sleep(bool cold) {
// 0x04 = warm start (config retained), 0x00 = cold start (config lost, lowest power)
const uint8_t mode = cold ? 0x00 : 0x04;
this->write_opcode_(RADIO_SET_SLEEP, &mode, 1);
}
void SX126xCommands::standby(SX126xStandbyMode mode) {
const uint8_t value = mode;
this->write_opcode_(RADIO_SET_STANDBY, &value, 1);
}
uint32_t SX126xCommands::bandwidth_hz(SX126xBw bw) { return BW_HZ[bw]; }
uint16_t SX126xCommands::ramp_us(uint8_t ramp) { return progmem_read_uint16(&RAMP[ramp]); }
void SX126xCommands::clear_irqs(uint16_t mask) {
const uint8_t data[] = {static_cast<uint8_t>(mask >> 8), static_cast<uint8_t>(mask)};
this->write_opcode_(RADIO_CLR_IRQSTATUS, data, 2);
}
uint16_t SX126xCommands::read_irq() {
uint8_t data[2]{};
this->read_opcode_(RADIO_GET_IRQSTATUS, data, 2);
return (uint16_t(data[0]) << 8) | data[1];
}
void SX126xCommands::write_fifo(const uint8_t *data, size_t size) {
const uint8_t header[] = {RADIO_WRITE_BUFFER, 0};
this->spi_device_.write(header, {data, size});
}
void SX126xCommands::read_packet(uint8_t modulation, std::vector<uint8_t> &packet, float &rssi, float &snr) {
uint8_t data[3]{};
this->read_opcode_(RADIO_GET_PACKETSTATUS, data, 3);
rssi = (modulation == PACKET_TYPE_LORA ? data[0] : data[2]) / -2.0f;
// FSK packets report no SNR
snr = modulation == PACKET_TYPE_LORA ? static_cast<int8_t>(data[1]) / 4.0f : NAN;
this->read_opcode_(RADIO_GET_RXBUFFERSTATUS, data, 2);
if (!this->spi_device_.ok()) {
return; // The length and offset were not read.
}
packet.resize(data[0]);
const uint8_t header[] = {RADIO_READ_BUFFER, data[1], 0};
this->spi_device_.read(header, packet);
}
} // namespace esphome::sx126x
@@ -0,0 +1,49 @@
#pragma once
#include "radio_profile.h"
#include "sx126x_spi_device.h"
#include <vector>
namespace esphome::sx126x {
enum class ConfigurationResult {
CONFIGURATION_RESULT_OK,
CONFIGURATION_RESULT_BUSY_TIMEOUT,
CONFIGURATION_RESULT_UNSUPPORTED
};
// Encodes chip commands. No ESPHome lifecycle, callbacks, or raw SPI access.
// Commands record failure in the SPI device: callers start a sequence with begin()
// and check ok() at its end, or before acting on a value that was read.
class SX126xCommands {
public:
SX126xCommands() = default;
SX126xCommands(const SX126xCommands &) = delete;
SX126xCommands &operator=(const SX126xCommands &) = delete;
SX126xSpiDevice &bus() { return this->spi_device_; }
// Fills applied as it goes; only use it if the result is OK.
[[nodiscard]] ConfigurationResult configure(const RadioProfile &profile, std::span<char, 16> version,
AppliedProfile &applied);
// Sets the payload length in the applied packet parameters and writes them.
void set_packet_length(AppliedProfile &applied, uint8_t length);
void calibrate_image(uint32_t frequency);
void standby(SX126xStandbyMode mode);
void receive();
void transmit(uint32_t timeout_ms = 0);
void sleep(bool cold = false);
void clear_irqs(uint16_t mask = 0xFFFF);
// Returns 0 if the read was skipped or failed.
uint16_t read_irq();
void write_fifo(const uint8_t *data, size_t size);
// Leaves the packet unchanged if a read fails.
void read_packet(uint8_t modulation, std::vector<uint8_t> &packet, float &rssi, float &snr);
static uint32_t bandwidth_hz(SX126xBw bw);
static uint16_t ramp_us(uint8_t ramp);
private:
void read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size);
void write_opcode_(uint8_t opcode, const uint8_t *data, uint8_t size);
void read_register_(uint16_t reg, uint8_t *data, uint8_t size);
void write_register_(uint16_t reg, const uint8_t *data, uint8_t size);
static void encode_packet_params(const RadioProfile &profile, AppliedProfile &applied);
SX126xSpiDevice spi_device_;
};
} // namespace esphome::sx126x
+253
View File
@@ -0,0 +1,253 @@
#include "sx126x_radio.h"
#include "esphome/core/log.h"
#include <utility>
namespace esphome::sx126x {
// No ESP_LOG calls in this file: log listeners are user code, see sx126x_radio.h.
static constexpr uint32_t RESET_DELAY_HIGH_US = 5000;
static constexpr uint32_t RESET_DELAY_LOW_US = 2000;
static constexpr uint32_t RECEIVE_RETRY_INTERVAL_MS = 200;
RadioEffects SX126xRadio::comm_report(bool ok) {
RadioEffects effects;
effects.report = true;
effects.warning = ok ? nullptr : LOG_STR("Wait busy timeout");
return effects;
}
RadioEffects SX126xRadio::configure(const RadioProfile &requested, std::span<char, 16> version) {
ESPHOME_DEBUG_ASSERT(this->can_configure());
this->busy_ = true;
this->rx_retry_pending_ = false;
this->pending_irq_ = 0;
RadioEffects effects;
effects.report = true;
effects.warning = this->apply_configuration_(requested, version);
this->commit_(effects.warning == nullptr ? RadioState::RADIO_STATE_IDLE
: RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
return effects;
}
const LogString *SX126xRadio::apply_configuration_(const RadioProfile &requested, std::span<char, 16> version) {
if (!requested.sync_valid) {
return LOG_STR("Invalid sync value");
}
this->rst_pin_->digital_write(true);
delayMicroseconds(RESET_DELAY_HIGH_US);
this->rst_pin_->digital_write(false);
delayMicroseconds(RESET_DELAY_LOW_US);
this->rst_pin_->digital_write(true);
delayMicroseconds(RESET_DELAY_HIGH_US);
this->bus().begin();
AppliedProfile applied;
switch (this->commands_.configure(requested, version, applied)) {
case ConfigurationResult::CONFIGURATION_RESULT_OK:
this->applied_ = applied;
return nullptr;
case ConfigurationResult::CONFIGURATION_RESULT_UNSUPPORTED:
return LOG_STR("Unsupported chip version");
default:
return LOG_STR("Wait busy timeout");
}
}
RadioEffects SX126xRadio::change_mode(ModeCommand command, uint8_t arg) {
ESPHOME_DEBUG_ASSERT(this->can_change_mode());
this->busy_ = true;
this->bus().begin();
switch (command) {
case ModeCommand::MODE_COMMAND_RX:
this->commands_.receive();
break;
case ModeCommand::MODE_COMMAND_TX:
this->commands_.transmit();
break;
case ModeCommand::MODE_COMMAND_STANDBY:
this->commands_.standby(static_cast<SX126xStandbyMode>(arg));
break;
case ModeCommand::MODE_COMMAND_SLEEP:
case ModeCommand::MODE_COMMAND_COLD_SLEEP:
this->commands_.sleep(command == ModeCommand::MODE_COMMAND_COLD_SLEEP);
break;
case ModeCommand::MODE_COMMAND_IMAGE_CAL:
this->commands_.calibrate_image(this->applied_.frequency);
break;
}
const bool ok = this->bus().ok();
// Cold sleep loses the configuration, and a failed command leaves the chip state unknown.
this->commit_(ok && command != ModeCommand::MODE_COMMAND_COLD_SLEEP ? RadioState::RADIO_STATE_IDLE
: RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
return comm_report(ok);
}
RadioEffects SX126xRadio::start_transmit(SX126xTransmitListener *listener, const uint8_t *data, size_t len,
uint32_t timeout_ms) {
RadioEffects effects;
if (this->state_ == RadioState::RADIO_STATE_NEEDS_CONFIGURATION) {
effects.result = SX126xError::SX126X_ERROR_NOT_READY;
return effects;
}
if (this->busy_ || this->state_ != RadioState::RADIO_STATE_IDLE) {
effects.result = SX126xError::SX126X_ERROR_BUSY;
return effects;
}
const uint8_t fixed_length = this->applied_.payload_length;
if (data == nullptr || len == 0 || len > this->max_packet_size() || (fixed_length != 0 && len != fixed_length)) {
effects.result = SX126xError::SX126X_ERROR_INVALID_PARAMS;
return effects;
}
this->busy_ = true;
this->bus().begin();
this->commands_.standby(STDBY_XOSC);
// RX cannot advance in standby. Keep a packet that completed before standby intact for
// poll(), before clearing IRQs or overwriting the shared FIFO. A failed read returns 0.
// A packet that failed its CRC is not delivered, so it does not hold up the send.
const uint16_t irq = this->commands_.read_irq() | this->pending_irq_;
if ((irq & IRQ_RX_DONE) && !(irq & IRQ_CRC_ERROR)) {
this->commit_(RadioState::RADIO_STATE_HOLDING_RX);
effects.result = SX126xError::SX126X_ERROR_BUSY;
return effects;
}
this->commands_.clear_irqs();
this->pending_irq_ = 0;
if (fixed_length == 0) {
this->commands_.set_packet_length(this->applied_, len);
}
this->commands_.write_fifo(data, len);
this->commands_.transmit(timeout_ms);
if (!this->bus().ok()) {
const bool restored = this->restore_();
this->commit_(restored ? RadioState::RADIO_STATE_IDLE : RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
effects = comm_report(restored);
effects.result = SX126xError::SX126X_ERROR_BUSY_TIMEOUT;
return effects;
}
this->tx_ = {listener, millis(), timeout_ms};
this->commit_(RadioState::RADIO_STATE_TRANSMITTING);
// No status report on success, so no user code runs before the caller has the result.
// The completion reports status instead.
return effects;
}
RadioEffects SX126xRadio::poll_transmit(uint32_t now) {
if (this->busy_ || !this->is_transmitting()) {
return {};
}
bool done = false;
SX126xError outcome = SX126xError::SX126X_ERROR_NONE;
if (this->dio1_pin_->digital_read()) {
this->busy_ = true;
this->bus().begin();
const uint16_t status = this->commands_.read_irq();
this->commands_.clear_irqs(status);
this->busy_ = false;
if (!this->bus().ok()) {
outcome = SX126xError::SX126X_ERROR_BUSY_TIMEOUT;
done = true;
} else if (status & IRQ_RX_TX_TIMEOUT) {
outcome = SX126xError::SX126X_ERROR_TIMEOUT;
done = true;
} else if (status & IRQ_TX_DONE) {
done = true;
}
}
// A cached loop tick can precede admission; signed subtraction also handles wraparound.
if (!done && static_cast<int32_t>(now - this->tx_.started_ms) >= static_cast<int32_t>(this->tx_.timeout_ms)) {
outcome = SX126xError::SX126X_ERROR_TIMEOUT;
done = true;
}
return done ? this->finish_transmit_(outcome) : RadioEffects{};
}
RadioEffects SX126xRadio::cancel_transmit(SX126xTransmitListener *listener) {
if (this->busy_ || !this->is_transmitting() || listener == nullptr || listener != this->tx_.listener) {
return {};
}
return this->finish_transmit_(SX126xError::SX126X_ERROR_CANCELLED);
}
// The only place a send ends.
RadioEffects SX126xRadio::finish_transmit_(SX126xError result) {
this->busy_ = true;
const bool restored = this->restore_();
this->commit_(restored ? RadioState::RADIO_STATE_IDLE : RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
RadioEffects effects = comm_report(restored);
effects.result = restored ? result : SX126xError::SX126X_ERROR_BUSY_TIMEOUT;
effects.listener = std::exchange(this->tx_.listener, nullptr);
effects.finished = true;
return effects;
}
bool SX126xRadio::restore_() {
this->bus().begin();
this->commands_.standby(STDBY_XOSC);
this->commands_.clear_irqs();
if (this->applied_.payload_length == 0) {
this->commands_.set_packet_length(this->applied_, this->max_packet_size());
}
if (this->applied_.rx_start) {
this->commands_.receive();
} else {
this->commands_.sleep();
}
return this->bus().ok();
}
RadioEffects SX126xRadio::poll(uint32_t now, std::vector<uint8_t> &packet) {
if (this->busy_) {
return {};
}
if (this->is_transmitting()) {
return this->poll_transmit(now);
}
RadioEffects effects;
if (this->state_ == RadioState::RADIO_STATE_NEEDS_CONFIGURATION) {
effects.sleep_loop = true; // Polling cannot recover the radio; configure() can.
return effects;
}
if (this->rx_retry_pending_ && now - this->rx_retry_started_ms_ < RECEIVE_RETRY_INTERVAL_MS) {
return effects;
}
const bool holding = this->state_ == RadioState::RADIO_STATE_HOLDING_RX;
if (!holding && this->pending_irq_ == 0 && !this->dio1_pin_->digital_read()) {
effects.sleep_loop = this->dio1_pin_->is_internal(); // Its interrupt wakes the loop.
return effects;
}
return this->receive_(holding, now, packet);
}
RadioEffects SX126xRadio::receive_(bool holding, uint32_t now, std::vector<uint8_t> &packet) {
this->busy_ = true;
this->bus().begin();
// A packet whose read failed has had its flags cleared, so they are kept until it is read.
const uint16_t status = this->commands_.read_irq() | this->pending_irq_;
const bool received = (status & IRQ_RX_DONE) && !(status & IRQ_CRC_ERROR);
if (!holding) {
// Cleared before the read, so a packet that completes during the read raises DIO1 again.
// A held packet keeps its IRQ until restoration.
this->commands_.clear_irqs(status);
}
float rssi = 0, snr = 0;
if (received) {
this->commands_.read_packet(this->applied_.modulation, packet, rssi, snr);
}
const bool read = this->bus().ok();
this->pending_irq_ = read ? 0 : status;
// Do not spend a BUSY timeout on every loop while a read keeps failing.
this->rx_retry_pending_ = !read;
this->rx_retry_started_ms_ = now;
RadioState next = this->state_;
if (read && holding) {
next = this->restore_() ? RadioState::RADIO_STATE_IDLE : RadioState::RADIO_STATE_NEEDS_CONFIGURATION;
}
this->commit_(next);
RadioEffects effects = comm_report(read && next != RadioState::RADIO_STATE_NEEDS_CONFIGURATION);
effects.received = read && received;
effects.rssi = rssi;
effects.snr = snr;
return effects;
}
} // namespace esphome::sx126x
+149
View File
@@ -0,0 +1,149 @@
#pragma once
#include "radio_profile.h"
#include "sx126x_commands.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <span>
#include <vector>
namespace esphome::sx126x {
enum class SX126xError {
SX126X_ERROR_NONE = 0,
SX126X_ERROR_TIMEOUT,
SX126X_ERROR_INVALID_PARAMS,
SX126X_ERROR_NOT_READY,
SX126X_ERROR_BUSY_TIMEOUT,
SX126X_ERROR_BUSY,
SX126X_ERROR_CANCELLED
};
class SX126xTransmitListener {
public:
virtual void on_transmit_complete(SX126xError result) = 0;
};
// Who may use the radio between operations. This is driver state, not the chip's mode.
enum class RadioState : uint8_t {
// Startup, cold sleep, or a failed configuration, mode command or restoration. Only
// configure() is accepted; sends return NOT_READY and the loop sleeps. SX126x configures
// again every second after a failure, and on the next mode command after cold sleep.
RADIO_STATE_NEEDS_CONFIGURATION,
// Available. The chip is receiving or sleeping, as configured, or after the TX mode command
// sending whatever the FIFO holds. That send is not tracked: a send or mode command may cut
// it short, and poll() clears its TX_DONE as a flag without a packet.
RADIO_STATE_IDLE,
// A send is on air. SPI is free between polls; only polling and cancellation are accepted.
RADIO_STATE_TRANSMITTING,
// Standby with a received packet left in the FIFO, found when a send entered standby.
// The send was rejected with BUSY; the next poll() delivers the packet and restores the
// radio. configure() may discard it to recover from repeated read failures.
RADIO_STATE_HOLDING_RX,
};
enum class ModeCommand : uint8_t {
MODE_COMMAND_RX,
MODE_COMMAND_TX,
MODE_COMMAND_STANDBY,
MODE_COMMAND_SLEEP,
MODE_COMMAND_COLD_SLEEP,
MODE_COMMAND_IMAGE_CAL,
};
// What a radio operation leaves for its caller to do. The radio has already committed its
// state; the caller reports status first, then calls the listener or delivers the packet.
struct RadioEffects {
SX126xError result{SX126xError::SX126X_ERROR_NONE};
// The completion to notify when finished is set; null for a blocking send.
SX126xTransmitListener *listener{nullptr};
// With report set: the warning to set, or null to clear it.
const LogString *warning{nullptr};
float rssi{0};
float snr{0};
bool report{false};
// A send ended, and result is its outcome.
bool finished{false};
// The packet buffer holds a packet to deliver.
bool received{false};
// Nothing to poll until the next DIO1 edge or configuration.
bool sleep_loop{false};
};
// Owns the radio state and every SPI command sequence.
//
// Status reports, completions and packet listeners can run user code, which may call back
// into the driver. Every operation therefore does its SPI work with busy() set, commits the
// next state, and returns RadioEffects; SX126x::apply_() then reports status and calls the
// listeners. This class has no access to component status or listeners, so callbacks only
// ever see a committed state. Do not log here either: log listeners are user code. busy()
// covers re-entry during a sequence, for example from a GPIO pin that logs.
//
// Each sequence starts with bus().begin(); after a BUSY timeout the rest is skipped and
// bus().ok() is false. A failed send preparation restores the radio in a new sequence. A
// failed restoration needs configuration, and its result overrides the send result. A
// successful send start reports nothing, so no user code runs before the caller has the
// result. Every send ends in finish_transmit_(), exactly once.
//
// A failed receive read keeps the packet and retries after RECEIVE_RETRY_INTERVAL_MS, so a
// stuck BUSY line does not cost a 20 ms wait on every loop.
class SX126xRadio {
public:
SX126xRadio() = default;
SX126xRadio(const SX126xRadio &) = delete;
SX126xRadio &operator=(const SX126xRadio &) = delete;
SX126xSpiDevice &bus() { return this->commands_.bus(); }
void set_dio1_pin(GPIOPin *pin) { this->dio1_pin_ = pin; }
void set_rst_pin(GPIOPin *pin) { this->rst_pin_ = pin; }
GPIOPin *get_dio1_pin() const { return this->dio1_pin_; }
GPIOPin *get_rst_pin() const { return this->rst_pin_; }
RadioState state() const { return this->state_; }
bool busy() const { return this->busy_; }
bool is_transmitting() const { return this->state_ == RadioState::RADIO_STATE_TRANSMITTING; }
// A held packet may be discarded: explicit reconfiguration is the recovery path.
bool can_configure() const { return !this->busy_ && !this->is_transmitting(); }
bool can_change_mode() const { return !this->busy_ && this->state_ == RadioState::RADIO_STATE_IDLE; }
size_t max_packet_size() const { return this->applied_.max_packet_size(); }
// Requires can_configure(). Resets the chip and applies the requested profile.
RadioEffects configure(const RadioProfile &requested, std::span<char, 16> version);
// Requires can_change_mode(). Cold sleep and failed commands need configuration afterwards.
RadioEffects change_mode(ModeCommand command, uint8_t arg);
// Result SX126X_ERROR_NONE starts a send that finishes exactly once, through poll_transmit() or
// cancel_transmit(). Any other result owes no completion.
RadioEffects start_transmit(SX126xTransmitListener *listener, const uint8_t *data, size_t len, uint32_t timeout_ms);
// Sets finished only when it ended the current send.
RadioEffects poll_transmit(uint32_t now);
// Sets finished only if listener owns the current send.
RadioEffects cancel_transmit(SX126xTransmitListener *listener);
// One loop step: polls a send, or reads a received packet into packet.
RadioEffects poll(uint32_t now, std::vector<uint8_t> &packet);
private:
struct TxOperation {
SX126xTransmitListener *listener{nullptr};
uint32_t started_ms{0};
uint32_t timeout_ms{0};
};
void commit_(RadioState state) {
this->state_ = state;
this->busy_ = false;
}
static RadioEffects comm_report(bool ok);
const LogString *apply_configuration_(const RadioProfile &requested, std::span<char, 16> version);
bool restore_();
RadioEffects finish_transmit_(SX126xError result);
RadioEffects receive_(bool holding, uint32_t now, std::vector<uint8_t> &packet);
SX126xCommands commands_;
AppliedProfile applied_;
TxOperation tx_;
uint32_t rx_retry_started_ms_{0};
uint16_t pending_irq_{0}; // flags of a received packet whose read failed after they were cleared
GPIOPin *dio1_pin_{nullptr};
GPIOPin *rst_pin_{nullptr};
RadioState state_{RadioState::RADIO_STATE_NEEDS_CONFIGURATION};
bool busy_{false};
bool rx_retry_pending_{false};
};
} // namespace esphome::sx126x
@@ -0,0 +1,45 @@
#include "sx126x_spi_device.h"
#include "esphome/core/hal.h"
namespace esphome::sx126x {
static constexpr uint32_t BUSY_TIMEOUT_MS = 20;
static constexpr uint32_t SWITCHING_DELAY_US = 1;
void SX126xSpiDevice::exchange_(std::span<const uint8_t> header, std::span<const uint8_t> tx, std::span<uint8_t> rx) {
if (!this->ok_) {
return;
}
// Selecting first wakes a sleeping radio. The guard releases CS even on timeout.
Selection selected(this->device_);
const uint32_t start = millis();
while (this->busy_pin_->digital_read()) {
if (millis() - start > BUSY_TIMEOUT_MS) {
this->ok_ = false;
return;
}
}
for (auto byte : header) {
this->device_.transfer_byte(byte);
}
for (auto byte : tx) {
this->device_.transfer_byte(byte);
}
for (auto &byte : rx) {
byte = this->device_.transfer_byte(0);
}
}
void SX126xSpiDevice::write(std::span<const uint8_t> header, std::span<const uint8_t> data) {
this->exchange_(header, data, {});
if (this->ok_) {
// This delay belongs after CS is released, not in the selected interval.
delayMicroseconds(SWITCHING_DELAY_US);
}
}
void SX126xSpiDevice::read(std::span<const uint8_t> header, std::span<uint8_t> data) {
this->exchange_(header, {}, data);
}
} // namespace esphome::sx126x
@@ -0,0 +1,63 @@
#pragma once
#include "esphome/components/spi/spi.h"
#include <span>
namespace esphome::sx126x {
// Persistent radio device. read()/write() use exchange_() for one complete selected interval.
// No raw SPI access escapes. Call only from the main loop; spans are borrowed until return.
// Failures stick: after a BUSY timeout every exchange is skipped until the next begin(),
// so a command sequence checks ok() once instead of after every command.
class SX126xSpiDevice {
public:
SX126xSpiDevice() = default;
SX126xSpiDevice(const SX126xSpiDevice &) = delete;
SX126xSpiDevice &operator=(const SX126xSpiDevice &) = delete;
void set_spi_parent(spi::SPIComponent *parent) { this->device_.set_spi_parent(parent); }
void set_cs_pin(GPIOPin *pin) { this->device_.set_cs_pin(pin); }
void set_data_rate(uint32_t rate) { this->device_.set_data_rate(rate); }
void set_mode(spi::SPIMode mode) { this->device_.set_mode(mode); }
void set_bit_order(spi::SPIBitOrder order) { this->device_.set_bit_order(order); }
void set_release_device(bool release) { this->device_.set_release_device(release); }
void set_write_only(bool write_only) { this->device_.set_write_only(write_only); }
#ifdef USE_SPI_PSRAM_DMA
void set_psram_dma(bool enable) { this->device_.set_psram_dma(enable); }
#endif
void set_busy_pin(GPIOPin *pin) { this->busy_pin_ = pin; }
GPIOPin *get_busy_pin() const { return this->busy_pin_; }
GPIOPin *get_cs_pin() const { return this->device_.get_cs_pin(); }
void setup() { this->device_.spi_setup(); }
void teardown() { this->device_.spi_teardown(); }
// Starts a command sequence with no recorded failure.
void begin() { this->ok_ = true; }
// False once any exchange since begin() timed out waiting for BUSY.
bool ok() const { return this->ok_; }
void write(std::span<const uint8_t> header, std::span<const uint8_t> data);
// Leaves data unchanged when the exchange is skipped or times out.
void read(std::span<const uint8_t> header, std::span<uint8_t> data);
private:
class Device final : public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_8MHZ> {
public:
GPIOPin *get_cs_pin() const { return this->cs_; }
};
class Selection {
public:
explicit Selection(Device &device) : device_(device) { this->device_.enable(); }
~Selection() { this->device_.disable(); }
Selection(const Selection &) = delete;
Selection &operator=(const Selection &) = delete;
private:
Device &device_;
};
void exchange_(std::span<const uint8_t> header, std::span<const uint8_t> tx, std::span<uint8_t> rx);
Device device_;
GPIOPin *busy_pin_{nullptr};
bool ok_{true};
};
} // namespace esphome::sx126x
+6 -2
View File
@@ -5,13 +5,15 @@ from esphome.const import CONF_MODEL
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@glmnet", "@arpena"]
CODEOWNERS = ["@glmnet", "@arpena", "@Rodrigoah"]
whirlpool_ns = cg.esphome_ns.namespace("whirlpool")
WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR)
Model = whirlpool_ns.enum("Model")
MODELS = Model.options("DG11J1-3A", "DG11J1-91", "HISENSE_ARG", prefix="MODEL_")
MODELS = Model.options(
"DG11J1-3A", "DG11J1-91", "HISENSE_ARG", "HITACHI_HCRA31NEWH", prefix="MODEL_"
)
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend(
{
@@ -26,3 +28,5 @@ async def to_code(config: ConfigType) -> None:
# cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared
if config[CONF_MODEL] == "HISENSE_ARG":
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
elif config[CONF_MODEL] == "HITACHI_HCRA31NEWH":
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
+61 -15
View File
@@ -2,6 +2,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome::whirlpool {
ESPHOME_LOG_TAG(TAG, "whirlpool.climate");
@@ -44,6 +46,17 @@ static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15
static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15
static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C
// Hitachi HCRA31NEWH model: power is a state in every frame
static constexpr uint8_t HITACHI_BYTE10 = 0x20; // byte 10
static constexpr uint8_t HITACHI_COOL = 0x02; // byte 15
static constexpr uint8_t HITACHI_OFF = 0x01; // byte 15
static constexpr uint8_t HITACHI_BYTE18_ON = 0x38; // byte 18
static constexpr uint8_t HITACHI_BYTE18_OFF = 0x28; // byte 18
static constexpr uint8_t HITACHI_BYTE19 = 0x08; // byte 19
// Header, 21 bytes of 8 bits, two dividers and the footer mark
static constexpr uint32_t WHIRLPOOL_FRAME_TIMINGS = 2 + WHIRLPOOL_STATE_LENGTH * 16 + 2 * 2 + 1;
WhirlpoolClimate::WhirlpoolClimate()
: climate_ir::ClimateIR(
WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true,
@@ -55,19 +68,19 @@ void WhirlpoolClimate::transmit_state() {
this->last_transmit_time_ = millis(); // setting the time of the last transmission.
uint8_t remote_state[WHIRLPOOL_STATE_LENGTH];
memset(remote_state, 0, sizeof(remote_state));
remote_state[0] = 0x83;
remote_state[1] = 0x06;
put_be16(remote_state, this->remote_id_());
remote_state[6] = 0x80;
remote_state[18] = 0x08; // the same on every supported model
remote_state[18] = 0x08;
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
const bool power_toggle = powered_on != this->powered_on_assumed;
const bool powered_on = this->mode != climate::CLIMATE_MODE_OFF;
// Hitachi sends power as a state instead of a toggle
const bool power_toggle = !this->is_hitachi_hcra31newh_() && powered_on != this->powered_on_assumed;
if (power_toggle) {
// Set power toggle command
remote_state[2] = 4;
remote_state[15] = 1;
this->powered_on_assumed = powered_on;
}
this->powered_on_assumed = powered_on;
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT_COOL:
// set fan auto
@@ -93,6 +106,10 @@ void WhirlpoolClimate::transmit_state() {
remote_state[15] = 6;
break;
case climate::CLIMATE_MODE_OFF:
// These remotes keep the cool code in the power-off frame
if (this->is_hisense_arg_() || this->is_hitachi_hcra31newh_())
remote_state[3] = WHIRLPOOL_COOL;
break;
default:
break;
}
@@ -102,6 +119,7 @@ void WhirlpoolClimate::transmit_state() {
// Temperature
auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_()));
const auto temp_step = (uint8_t) (temp - this->temperature_min_());
this->target_temperature = temp; // publish what the unit was sent
if (hisense_arg && this->fixed_temp_mode_()) {
remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4;
} else {
@@ -131,7 +149,6 @@ void WhirlpoolClimate::transmit_state() {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
remote_state[8] = WHIRLPOOL_SWING_BYTE8;
}
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
} else if (this->send_swing_cmd_) {
if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) {
remote_state[2] |= WHIRLPOOL_SWING_MASK;
@@ -139,6 +156,11 @@ void WhirlpoolClimate::transmit_state() {
}
}
if (hisense_arg)
this->encode_hisense_arg_(remote_state, power_toggle, temp_step);
if (this->is_hitachi_hcra31newh_())
this->encode_hitachi_hcra31newh_(remote_state);
// Checksum
remote_state[13] = xor_bytes(&remote_state[2], 11, remote_state[13]);
remote_state[20] = xor_bytes(&remote_state[14], 6, remote_state[20]);
@@ -148,6 +170,7 @@ void WhirlpoolClimate::transmit_state() {
// Send code
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
data->reserve(WHIRLPOOL_FRAME_TIMINGS);
data->set_carrier_frequency(38000);
@@ -180,10 +203,6 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY;
switch (this->mode) {
case climate::CLIMATE_MODE_OFF:
// The remote keeps the cool code in the power-off frame
remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL;
break;
case climate::CLIMATE_MODE_DRY:
// The unit runs these fan speeds itself; the user's choice is kept for the next mode change
remote_state[2] &= ~WHIRLPOOL_FAN_MASK;
@@ -212,6 +231,27 @@ void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_tog
#endif
}
void WhirlpoolClimate::encode_hitachi_hcra31newh_(uint8_t *remote_state) const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
remote_state[10] = HITACHI_BYTE10;
remote_state[18] = HITACHI_BYTE18_ON;
remote_state[19] = HITACHI_BYTE19;
// The unit takes the whole state from every frame: only the off frame carries the power bit
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
remote_state[15] = HITACHI_COOL;
break;
case climate::CLIMATE_MODE_OFF:
remote_state[2] |= WHIRLPOOL_POWER;
remote_state[15] = HITACHI_OFF;
remote_state[18] = HITACHI_BYTE18_OFF;
break;
default:
break;
}
#endif
}
bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
// Check if the esp isn't currently transmitting.
if (millis() - this->last_transmit_time_ < 500) {
@@ -234,7 +274,8 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
if (!data.expect_item(WHIRLPOOL_BIT_MARK, WHIRLPOOL_GAP))
return false;
}
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid()) {
// The Hitachi remote always sends all 21 bytes; byte 18 holds its power state
if (i == WHIRLPOOL_SECOND_DIVIDER && !data.is_valid() && !this->is_hitachi_hcra31newh_()) {
// Remote control only sent 14 bytes, nothing more to read, not even the footer
ESP_LOGV(TAG, "Remote control only sent %d bytes", i);
skip_footer = true;
@@ -261,13 +302,18 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
ESP_LOGV_HEX(TAG, remote_state, WHIRLPOOL_STATE_LENGTH, WHIRLPOOL_STATE_LENGTH, "Received: %s");
// verify header remote code
if (remote_state[0] != 0x83 || remote_state[1] != 0x06)
if (encode_uint16(remote_state[0], remote_state[1]) != this->remote_id_())
return false;
// powr on/off button
ESP_LOGV(TAG, "Power: %02X", (remote_state[2] & WHIRLPOOL_POWER));
if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
if (this->is_hitachi_hcra31newh_()) {
// Power is a state: byte 18 says whether the unit is off
this->powered_on_assumed = remote_state[18] != HITACHI_BYTE18_OFF;
if (!this->powered_on_assumed)
this->mode = climate::CLIMATE_MODE_OFF;
} else if ((remote_state[2] & WHIRLPOOL_POWER) == WHIRLPOOL_POWER) {
auto powered_on = this->mode != climate::CLIMATE_MODE_OFF;
if (powered_on) {
@@ -309,7 +355,7 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) {
int temp = remote_state[3] & 0xF0;
ESP_LOGVV(TAG, "Temperature Raw: %02X", temp);
temp = (uint8_t) temp >> 4;
temp += static_cast<int>(this->temperature_min_());
temp = std::min(temp + static_cast<int>(this->temperature_min_()), static_cast<int>(this->temperature_max_()));
ESP_LOGVV(TAG, "Temperature Climate: %u", temp);
this->target_temperature = temp;
}
+19 -4
View File
@@ -6,9 +6,10 @@ namespace esphome::whirlpool {
/// Simple enum to represent models.
enum Model {
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32
MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30
MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30
MODEL_HITACHI_HCRA31NEWH = 3, /// Hitachi with the HCRA31NEWH remote, 16 to 30
};
// Temperature
@@ -29,7 +30,9 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
/// Override control to change settings of the climate device.
void control(const climate::ClimateCall &call) override {
send_swing_cmd_ = call.get_swing_mode().has_value();
// The toggle remotes flip swing on every press, so only send one when the mode really changes
auto swing = call.get_swing_mode();
this->send_swing_cmd_ = swing.has_value() && *swing != this->swing_mode;
climate_ir::ClimateIR::control(call);
}
@@ -55,10 +58,22 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR {
return false;
#endif
}
/// False when the model is compiled out, so the compiler drops the Hitachi branches.
bool is_hitachi_hcra31newh_() const {
#ifdef USE_WHIRLPOOL_HITACHI_HCRA31NEWH
return this->model_ == MODEL_HITACHI_HCRA31NEWH;
#else
return false;
#endif
}
/// The remote ID sent in bytes 0 and 1.
uint16_t remote_id_() const { return this->is_hitachi_hcra31newh_() ? 0xA959 : 0x8306; }
/// Modes in which the Hisense ARG model sends a fixed temperature.
bool fixed_temp_mode_() const;
/// Add the Hisense ARG bytes on top of the common whirlpool frame.
void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const;
/// Add the Hitachi HCRA31NEWH bytes on top of the common whirlpool frame.
void encode_hitachi_hcra31newh_(uint8_t *remote_state) const;
/// Set the time of the last transmission.
uint32_t last_transmit_time_{};
+1 -1
View File
@@ -563,7 +563,7 @@ class WiFiComponent final : public Component {
// (In most use cases you won't need these)
/// Setup WiFi interface.
void setup() override;
#if defined(USE_LN882X) || defined(USE_BK72XX)
#if defined(USE_ESP32) || defined(USE_LN882X) || defined(USE_BK72XX)
void on_powerdown() override;
#endif
void start();
@@ -1635,6 +1635,21 @@ bool WiFiComponent::wifi_dpp_start_listen_() {
void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); }
#endif
void WiFiComponent::on_powerdown() {
// Leave the AP before esp_restart() or deep sleep stops Wi-Fi. esp_wifi_stop() while associated
// makes the ESP32 retransmit its Disassociation up to the retry limit although the AP ACKs it
// (32 copies with protected management frames), which some APs handle badly.
// Upstream bug: https://github.com/espressif/esp-idf/issues/19443 - remove this once it is fixed
// in the ESP-IDF versions we support.
// Ask the driver: s_sta_connected lags until the main loop has handled the connect event.
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK)
return;
ESP_LOGD(TAG, "Disconnecting before powerdown");
esp_wifi_disconnect();
delay(100); // NOLINT: once per reboot or deep sleep, lets the disassociation go out
}
bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); }
bssid_t WiFiComponent::wifi_bssid() {
+2
View File
@@ -294,6 +294,8 @@ void Application::reboot() {
for (auto &component : std::ranges::reverse_view(this->components_)) {
component->on_shutdown();
}
// No teardown on a forced reboot, but still power hardware down (Wi-Fi leaves the AP)
this->run_powerdown_hooks();
arch_restart();
}
void Application::safe_reboot() {
+4 -2
View File
@@ -185,10 +185,12 @@ class Component {
*/
virtual bool teardown() { return true; }
/** Called after teardown is complete to power down hardware.
/** Called last before a reboot or deep sleep to power down hardware.
*
* This is called after all components have finished their teardown process,
* On a safe shutdown this is called after all components have finished their teardown process,
* making it safe to power down hardware like ethernet PHY.
* On a forced reboot (Application::reboot()) it is called right after on_shutdown() with no
* teardown, so implementations must not assume teardown() has run.
*/
virtual void on_powerdown() {}
+2
View File
@@ -70,6 +70,7 @@
#define USE_BUTTON
#define USE_CAMERA
#define USE_CANBUS_FD
#define USE_CC1101_LONG_PACKET
#define USE_CLIMATE
#define USE_CLIMATE_VISUAL_OVERRIDES
#define USE_CONTROLLER_REGISTRY
@@ -387,6 +388,7 @@
#define USE_WATER_HEATER
#define USE_WATER_HEATER_VISUAL_OVERRIDES
#define USE_WHIRLPOOL_HISENSE_ARG
#define USE_WHIRLPOOL_HITACHI_HCRA31NEWH
#define USE_ZWAVE_PROXY
// Feature flags which do not work for zephyr
+14 -65
View File
@@ -803,18 +803,6 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj":
if type_ is not None:
id_.type = type_
_emit_pvariable(id_, rhs, CORE.add_global, CORE.add)
CORE.register_variable(id_, obj)
return obj
def _emit_pvariable(
id_: ID,
rhs: SafeExpType,
add_global_: Callable[[Expression | Statement], object],
add_: Callable[[Expression | Statement], object],
) -> None:
"""Emit the storage, declaration and construction of the pointer variable ``id_``."""
if isinstance(rhs, MockObj) and rhs.is_new_expr:
# For 'new' allocations, use placement new into static storage
# to avoid heap fragmentation on embedded devices.
@@ -837,14 +825,14 @@ def _emit_pvariable(
storage_name = _pstorage_name(actual_type, id_.id)
# Declare aligned byte array for the object storage
add_global_(
CORE.add_global(
RawStatement(
f"alignas({actual_type}) static unsigned char {storage_name}[sizeof({actual_type})];"
)
)
# Pointer declaration uses id_.type to preserve the declared base-class
# pointer type for downstream callers (polymorphism through base ptr).
add_global_(
CORE.add_global(
AssignmentExpression(
f"static {pointer_type}",
"*const ",
@@ -853,48 +841,15 @@ def _emit_pvariable(
)
)
placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args)
add_(ExpressionStatement(placement_new))
CORE.add(ExpressionStatement(placement_new))
CORE.data.setdefault(_STATIC_POINTER_IDS, set()).add(id_.id)
else:
decl = VariableDeclarationExpression(id_.type, "*", id_, static=True)
add_global_(decl)
add_(AssignmentExpression(None, None, id_, rhs))
CORE.add_global(decl)
CORE.add(AssignmentExpression(None, None, id_, rhs))
class _StatementSlot(Statement):
"""A place for statements that are added later."""
__slots__ = ("statements",)
def __init__(self) -> None:
self.statements: list[Statement] = []
def add(self, expression: Expression | Statement) -> None:
self.statements.append(statement(expression))
def __str__(self) -> str:
return "\n".join(str(item).rstrip() for item in self.statements)
class LatePvariable:
"""A pointer variable registered now, so other code can refer to ``var``, and created by
``define()`` in the place it was declared, once its class is known."""
def __init__(self, id_: ID) -> None:
self.id = id_
self.var = MockObj(id_, "->")
# Global declarations that must come before the variable can be added here
self.global_slot = _StatementSlot()
self._main_slot = _StatementSlot()
CORE.add_global(self.global_slot)
CORE.add(self._main_slot)
CORE.register_variable(id_, self.var)
def define(self, type_: "MockObjClass", *args: SafeExpType) -> None:
"""Create the variable as ``type_``, like ``new_Pvariable`` with ``args``."""
self.id.type = type_
rhs = _new_expression(self.id, args)
_emit_pvariable(self.id, rhs, self.global_slot.add, self._main_slot.add)
CORE.register_variable(id_, obj)
return obj
_STATIC_POINTER_IDS = "static_pointer_ids"
@@ -916,21 +871,16 @@ def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj":
with the given arguments.
:param id_: The ID used to declare the variable (also specifies the type).
:param args: The values to pass to the constructor, after optional template arguments.
:param args: The values to pass to the constructor.
:return: The new variable as a MockObj.
"""
if args and isinstance(args[0], TemplateArguments):
id_ = id_.copy()
return Pvariable(id_, _new_expression(id_, args))
def _new_expression(id_: ID, args: tuple[SafeExpType, ...]) -> "MockObj":
"""``new`` of ``id_.type``, first templated on ``args[0]`` if that is TemplateArguments."""
if args and isinstance(args[0], TemplateArguments):
id_.type = id_.type.template(args[0])
args = args[1:]
return id_.type.new(*args)
rhs = id_.type.new(*args)
return Pvariable(id_, rhs)
def static_function(
@@ -938,19 +888,18 @@ def static_function(
return_type: SafeExpType,
parameters: TemplateArgsType,
body: list[str | Statement],
add_global_: Callable[[Statement], object] | None = None,
) -> RawExpression:
"""Emit ``static <return_type> <name>(parameters) { body }`` at global scope and return an
expression naming it, for use as a template argument or a function pointer.
The declaration goes with the other globals (or to ``add_global_``, e.g. a
``LatePvariable``'s place), so a template argument can name the function at once; the body
goes after the user includes, where every id and everything those headers declare is in scope.
The declaration goes with the other globals, so a template argument can name the function
at once; the body goes after the user includes, where every id and everything those
headers declare is in scope.
"""
signature = (
f"static {safe_exp(return_type)} {name}({ParameterListExpression(*parameters)})"
)
(add_global_ or add_global)(RawStatement(f"{signature};"))
add_global(RawStatement(f"{signature};"))
add_global_after_includes(
RawStatement(f"{signature} {{\n{indent(chr(10).join(map(str, body)))}\n}}")
)
@@ -0,0 +1,62 @@
"""Tests for cc1101 packet_length validation."""
from typing import Any
import pytest
from esphome.components.cc1101 import _validate_packet_mode
import esphome.config_validation as cv
from esphome.core import Lambda
LENGTH_LAMBDA = Lambda("return x.size() < 3 ? 0 : x[2] + 12;")
@pytest.mark.parametrize(
"config",
[
{"packet_mode": True, "gdo0_pin": 2, "packet_length": LENGTH_LAMBDA},
{"packet_mode": True, "gdo0_pin": 2, "packet_length": 1024},
{"packet_mode": True, "gdo0_pin": 2, "packet_length": 64, "crc_enable": True},
{"packet_mode": False, "packet_length": 200, "crc_enable": True},
],
)
def test_packet_length_valid(config: dict[str, Any]) -> None:
"""Fixed lengths, a lambda, and CRC on packets that fit the FIFO are accepted."""
assert _validate_packet_mode(config) is config
@pytest.mark.parametrize(
("config", "message"),
[
(
{"packet_mode": False, "packet_length": LENGTH_LAMBDA},
"packet_mode is required when packet_length is a lambda",
),
(
{"packet_mode": True, "gdo0_pin": 2},
"packet_length is required when packet_mode is enabled",
),
(
{
"packet_mode": True,
"gdo0_pin": 2,
"packet_length": 65,
"crc_enable": True,
},
"crc_enable is not supported",
),
(
{
"packet_mode": True,
"gdo0_pin": 2,
"packet_length": LENGTH_LAMBDA,
"crc_enable": True,
},
"crc_enable is not supported",
),
],
)
def test_packet_length_invalid(config: dict[str, Any], message: str) -> None:
"""Invalid packet_length combinations are rejected with a clear message."""
with pytest.raises(cv.Invalid, match=message):
_validate_packet_mode(config)
@@ -14,8 +14,7 @@ def test_nec_field_lambdas_get_the_script_args(
main_cpp = generate_main(component_config_path("nec_lambda_args.yaml"))
(body,) = protocol_call_bodies(main_cpp)
assert re.search(
r"static void esphome__\w+__fn\(const int & esphome__running, "
r"const std::remove_cvref_t<int> & x\) \{",
r"static void esphome__\w+__fn\(const std::remove_cvref_t<int> & x\) \{",
main_cpp,
)
assert "apply_call.data.address = [](int x) -> uint16_t {" in body
@@ -51,10 +51,9 @@ def test_code_storage_id_warns(
def test_no_action_objects(main_cpp: str) -> None:
assert "RawAction" not in main_cpp
assert "RCSwitch" not in main_cpp.replace("RCSwitchBase", "")
# The script runs its transmits from one generated function; the buttons call functions directly
assert "new(applyaction_id" not in main_cpp
assert "new(automation_id" not in main_cpp
assert "FunctionScript<" in main_cpp
# The script's two transmits merge into one action; the buttons call functions directly
assert main_cpp.count("new(applyaction_id") == 1
assert main_cpp.count("new(automation_id") == 1
def test_raw_static_code_in_flash(main_cpp: str, bodies: list[str]) -> None:
@@ -33,50 +33,24 @@ def _actions_of(main_cpp: str, automation: str) -> list[str]:
return re.findall(rf"\b{automation}->add_action\((\w+)\);", main_cpp)
def _template_tail(types: tuple[str, ...]) -> str:
return "".join(f", {type_}" for type_ in types)
def _single_action(main_cpp: str, script: str) -> str:
(action,) = _actions_of(main_cpp, _automation(main_cpp, script))
return action
def _mode_class(mode: str, *types: str) -> str:
"""The mode class shared by scripts that run a function, with the same parameters."""
return f"script::{mode}<script::FunctionBody<{', '.join(types)}>{_template_tail(types)}>"
def _run_function(main_cpp: str, script: str) -> str:
"""Return the function of the one merged action the script's Automation runs."""
constructed = _constructed(main_cpp, _single_action(main_cpp, script))
match = re.match(r"ApplyRunAction<(\w+)[,>]", constructed)
assert match is not None, constructed
return match.group(1)
def _function_script(mode: str, script: str, *types: str) -> str:
"""The class of a script whose actions run as one function."""
return (
f"script::FunctionScript<{_mode_class(mode, *types)}, "
f"esphome__{script}__fn{_template_tail(types)}>"
)
def _assert_function_script(main_cpp: str, mode: str, script: str, *types: str) -> None:
assert (
_constructed(main_cpp, script) == f"{_function_script(mode, script, *types)}()"
)
def _wait_class(script_class: str) -> str:
return f"script::ScriptWaitAction<{script_class}>"
def _script_body(main_cpp: str, script: str) -> str:
"""The function a script runs; it has no Automation."""
assert not re.search(rf"\bAutomation<.*?>\({script}\);", main_cpp)
return _function_body(main_cpp, f"esphome__{script}__fn")
def test_inline_actions_run_as_one_function(generate_main) -> None:
"""A script of inline actions calls one function, declared before the script."""
def test_inline_actions_merge_into_one_action(generate_main) -> None:
"""A script of inline actions runs them as one merged action of an Automation."""
main_cpp = generate_main(CONFIG)
class_ = _function_script("SingleMode", "inline_single")
assert _constructed(main_cpp, "inline_single") == f"{class_}()"
declaration = main_cpp.index(
"static void esphome__inline_single__fn(const int & esphome__running);"
)
assert declaration < main_cpp.index(f"alignas({class_})")
assert f"static {class_} *const inline_single" in main_cpp
body = _script_body(main_cpp, "inline_single")
assert _constructed(main_cpp, "inline_single") == "script::SingleScript<>()"
body = _function_body(main_cpp, _run_function(main_cpp, "inline_single"))
assert '"INLINE first"' in body
assert "counter->value() += 1;" in body
# A script can be stopped from inside, so the rest of the run checks for it
@@ -84,37 +58,31 @@ def test_inline_actions_run_as_one_function(generate_main) -> None:
def test_one_lambda_action(generate_main) -> None:
"""A single inline action is the whole function."""
"""A single inline action keeps its own action object."""
main_cpp = generate_main(CONFIG)
body = _script_body(main_cpp, "one_action")
assert '"ONE action"' in body
assert "esphome__running" not in body
constructed = _constructed(main_cpp, _single_action(main_cpp, "one_action"))
assert constructed.startswith("StatelessLambdaAction<>(")
assert '"ONE action"' in constructed
def test_modes(generate_main) -> None:
"""Each mode has its own template; max_runs is set on queued and parallel scripts."""
"""Each mode has its own class; max_runs is set on queued and parallel scripts."""
main_cpp = generate_main(CONFIG)
for mode, script in (
("RestartMode", "inline_restart"),
("ParallelMode", "inline_parallel"),
):
_assert_function_script(main_cpp, mode, script)
# One class for all queued scripts, given the function
assert _constructed(main_cpp, "inline_queued") == (
f"script::FunctionPointerScript<{_mode_class('QueueingMode')}>"
"(esphome__inline_queued__fn)"
)
assert _constructed(main_cpp, "inline_restart") == "script::RestartScript<>()"
assert _constructed(main_cpp, "inline_queued") == "script::QueueingScript<>()"
assert _constructed(main_cpp, "inline_parallel") == "script::ParallelScript<>()"
assert "inline_queued->set_max_runs(3);" in main_cpp
assert "inline_parallel->set_max_runs(2);" in main_cpp
assert re.search(r"App\.register_component_\(inline_queued, \d+\);", main_cpp)
assert '"QUEUED run"' in _script_body(main_cpp, "inline_queued")
assert '"PARALLEL run"' in _script_body(main_cpp, "inline_parallel")
for script in ("inline_queued", "inline_parallel"):
constructed = _constructed(main_cpp, _single_action(main_cpp, script))
assert constructed.startswith("StatelessLambdaAction<>(")
def test_restart_executes_itself(generate_main) -> None:
"""A script can execute itself; on_stop keeps its own Automation."""
"""A script can execute itself; on_stop has its own Automation."""
main_cpp = generate_main(CONFIG)
body = _script_body(main_cpp, "inline_restart")
body = _function_body(main_cpp, _run_function(main_cpp, "inline_restart"))
assert "::inline_restart->execute();" in body
stop = _automation(main_cpp, r"inline_restart->get_stop_trigger\(\)")
(action,) = _actions_of(main_cpp, stop)
@@ -124,14 +92,18 @@ def test_restart_executes_itself(generate_main) -> None:
def test_parameters_by_value(generate_main) -> None:
"""Lambdas take the parameters by value, so ``x *= 2`` compiles in every form."""
main_cpp = generate_main(CONFIG)
_assert_function_script(main_cpp, "SingleMode", "with_params", "int", "std::string")
assert (
_constructed(main_cpp, "with_params")
== "script::SingleScript<int, std::string>()"
)
name = _run_function(main_cpp, "with_params")
assert re.search(
r"static void esphome__with_params__fn\(const int & esphome__running, "
rf"static void {name}\(const int & esphome__running, "
r"const std::remove_cvref_t<int> & x, "
r"const std::remove_cvref_t<std::string> & s\)",
main_cpp,
)
body = _script_body(main_cpp, "with_params")
body = _function_body(main_cpp, name)
assert body.count("[](int x, std::string s) -> void {") == 2
assert body.count("[](int x, std::string s) -> int {") == 2
assert "x *= 2;\n ESP_LOGI(" in body
@@ -144,7 +116,7 @@ def test_parameters_by_value(generate_main) -> None:
def test_script_stop_inside(generate_main) -> None:
"""``script.stop`` without an id stops the script it is in; the next action checks."""
main_cpp = generate_main(CONFIG)
body = _script_body(main_cpp, "stops_itself")
body = _function_body(main_cpp, _run_function(main_cpp, "stops_itself"))
stop, rest = body.split("::stops_itself->stop();")
assert '"STOPS start"' in stop
assert rest.lstrip("\n }").startswith("if (!esphome__running)\n return;")
@@ -154,59 +126,35 @@ def test_script_stop_inside(generate_main) -> None:
def test_calls_itself_with_parameter(generate_main) -> None:
"""A parallel script with a parameter can execute itself from an ``if``."""
main_cpp = generate_main(CONFIG)
_assert_function_script(main_cpp, "ParallelMode", "calls_itself", "int")
body = _script_body(main_cpp, "calls_itself")
assert _constructed(main_cpp, "calls_itself") == "script::ParallelScript<int>()"
body = _function_body(main_cpp, _run_function(main_cpp, "calls_itself"))
assert "return n > 0;" in body
assert "::calls_itself->execute(std::move(esphome__arg_0));" in body
def test_delay_keeps_separate_actions(generate_main) -> None:
"""A delay keeps today's class, Automation and separate actions."""
"""A delay keeps the Automation and its separate actions."""
main_cpp = generate_main(CONFIG)
assert _constructed(main_cpp, "with_delay") == "script::SingleScript<>()"
assert "static script::SingleScript<> *const with_delay" in main_cpp
before, delay, after = _actions_of(main_cpp, _automation(main_cpp, "with_delay"))
assert _constructed(main_cpp, delay) == "DelayAction<>()"
assert '"DELAY before"' in _constructed(main_cpp, before)
assert '"DELAY after"' in _constructed(main_cpp, after)
assert "esphome__with_delay__fn" not in main_cpp
def test_manual_automation_id(generate_main) -> None:
"""An automation_id set by hand names the Automation, so a lambda can reach it."""
main_cpp = generate_main(CONFIG)
assert _constructed(main_cpp, "manual_automation") == "script::SingleScript<>()"
assert _automation(main_cpp, "manual_automation") == "manual_automation_id"
(action,) = _actions_of(main_cpp, "manual_automation_id")
assert '"MANUAL"' in _constructed(main_cpp, action)
def test_script_wait(generate_main) -> None:
"""``script.wait`` on a script that runs a function uses the class shared by its mode."""
"""``script.wait`` is a component templated on the class of the script it waits for."""
main_cpp = generate_main(CONFIG)
(action,) = _actions_of(main_cpp, _automation(main_cpp, "waits"))
class_ = _wait_class(_mode_class("SingleMode"))
assert _constructed(main_cpp, action) == f"{class_}(inline_single)"
def test_script_wait_built_first(generate_main) -> None:
"""A ``script.wait`` built before the script it waits for gets the shared class too."""
main_cpp = generate_main(CONFIG)
late, delay = _actions_of(main_cpp, _automation(main_cpp, "waits_early"))
class_ = _wait_class(_mode_class("SingleMode"))
assert _constructed(main_cpp, late) == f"{class_}(late_target)"
assert f"static {class_} *const {late}" in main_cpp
assert _constructed(main_cpp, delay) == (
f"{_wait_class('script::SingleScript<>')}(with_delay)"
)
def test_script_wait_keeps_waiting_script_parameters(generate_main) -> None:
"""Only the waited-for script's class changes, not a parameter with the same name."""
main_cpp = generate_main(CONFIG)
automation = _automation(main_cpp, "waits_with_param")
wait, _ = _actions_of(main_cpp, automation)
assert _constructed(main_cpp, wait) == (
f"script::ScriptWaitAction<{_mode_class('SingleMode')}, "
"script::SingleScript<>*>(late_target)"
assert (
_constructed(main_cpp, action)
== "script::ScriptWaitAction<script::SingleScript<>>(inline_single)"
)
@@ -11,20 +11,6 @@ globals:
type: int
script:
# Built before the scripts it waits for
- id: waits_early
then:
- script.wait: late_target
- script.wait: with_delay
# Has a parameter of the class an inline script had before it was known
- id: waits_with_param
parameters:
other: script::SingleScript<>*
then:
- script.wait: late_target
- lambda: other->execute();
# Inline actions only
- id: inline_single
then:
@@ -117,7 +103,3 @@ script:
- id: waits
then:
- script.wait: inline_single
- id: late_target
then:
- logger.log: "LATE target"
@@ -0,0 +1,18 @@
"""Validate the sync-word limit before firmware generation."""
import pytest
from esphome.components.sx126x import CONF_SYNC_VALUE, CONFIG_SCHEMA
import esphome.config_validation as cv
@pytest.mark.parametrize("size", [0, 1, 2, 8])
def test_supported_sync_lengths(size: int) -> None:
value = [0x14] * size
assert CONFIG_SCHEMA.schema[CONF_SYNC_VALUE](value) == value
@pytest.mark.parametrize("size", [9, 256])
def test_oversized_sync_word(size: int) -> None:
with pytest.raises(cv.Invalid, match="at most 8"):
CONFIG_SCHEMA.schema[CONF_SYNC_VALUE]([0x14] * size)
@@ -0,0 +1,26 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
cc1101:
# Above 255 bytes: infinite length mode, then fixed for the remainder
- id: transceiver_long_fixed
cs_pin: GPIO5
gdo0_pin: GPIO4
packet_mode: true
packet_length: 1024
on_packet:
then:
- lambda: 'ESP_LOGD("cc1101", "long packet %u bytes", x.size());'
# Length taken from the packet header
- id: transceiver_long_lambda
cs_pin: GPIO15
gdo0_pin: GPIO2
packet_mode: true
packet_length: !lambda |-
if (x.size() < 3) return 0;
if (x[0] != 0x54) return -1;
return x[2] + 12;
on_packet:
then:
- lambda: 'ESP_LOGD("cc1101", "lambda packet %u bytes rssi %.1f", x.size(), rssi);'
@@ -0,0 +1,24 @@
packages:
spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml
cc1101:
- id: transceiver_long_fixed
cs_pin: GPIO5
gdo0_pin: GPIO4
packet_mode: true
packet_length: 1024
on_packet:
then:
- lambda: 'ESP_LOGD("cc1101", "long packet %u bytes", x.size());'
- id: transceiver_long_lambda
cs_pin: GPIO15
gdo0_pin: GPIO2
packet_mode: true
packet_length: !lambda |-
if (x.size() < 3) return 0;
if (x[0] != 0x54) return -1;
return x[2] + 12;
on_packet:
then:
- lambda: 'ESP_LOGD("cc1101", "lambda packet %u bytes rssi %.1f", x.size(), rssi);'
@@ -0,0 +1,7 @@
substitutions:
cs_pin: GPIO5
gdo0_pin: GPIO4
packages:
spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml
cc1101: !include common.yaml
+9 -2
View File
@@ -20,7 +20,14 @@ class FakePin final : public GPIOPin {
bool level{false};
};
// Answers the 8-bit status read with a set report and 16-bit setup reads with zeros.
// Fills a 16-bit register read: the firmware check code read gets a valid code, other registers zeros.
inline void answer_setup_read(const uint8_t *data, size_t size, uint8_t *out, size_t out_size) {
std::memset(out, 0, out_size);
if (size == 2 && encode_uint16(data[0], data[1]) == cstxxx::REG_FW_CHECK_CODE && out_size == 4)
out[2] = out[3] = 0xCA;
}
// Answers the 8-bit status read with a set report and 16-bit setup reads through answer_setup_read().
class StatusBus final : public i2c::I2CBus {
public:
std::array<uint8_t, 28> status{};
@@ -35,7 +42,7 @@ class StatusBus final : public i2c::I2CBus {
if (size == 1 && data[0] == CST226_REG_STATUS) {
std::memcpy(out, this->status.data(), std::min(out_size, this->status.size()));
} else {
std::memset(out, 0, out_size);
answer_setup_read(data, size, out, out_size);
}
return i2c::ERROR_OK;
}
@@ -1,5 +1,8 @@
#include <gtest/gtest.h>
#include <cstring>
#include <vector>
#include "esphome/components/cst226/touchscreen/cst226_touchscreen.h"
#include "../common.h"
@@ -14,6 +17,45 @@ TEST(CST226TouchscreenTest, SetupCompletes) {
EXPECT_FALSE(hub.is_failed());
}
// Records the writes that carry no read, and answers reads through answer_setup_read().
class WriteLogBus final : public i2c::I2CBus {
public:
std::vector<std::vector<uint8_t>> writes;
bool bad_check_code{false};
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
if (out_size == 0) {
this->writes.emplace_back(data, data + size);
} else {
answer_setup_read(data, size, out, out_size);
if (this->bad_check_code)
std::memset(out, 0, out_size);
}
return i2c::ERROR_OK;
}
};
TEST(CST226TouchscreenTest, SetupEntersCommandModeAndLeavesIt) {
WriteLogBus bus;
CST226Touchscreen hub;
hub.set_i2c_bus(&bus);
hub.setup();
ASSERT_GE(bus.writes.size(), 2u);
EXPECT_EQ(bus.writes.front(), (std::vector<uint8_t>{0xD1, 0x01}));
EXPECT_EQ(bus.writes.back(), (std::vector<uint8_t>{0xD1, 0x09}));
EXPECT_FALSE(hub.is_failed());
}
TEST(CST226TouchscreenTest, SetupFailsOnAWrongCheckCode) {
WriteLogBus bus;
bus.bad_check_code = true;
CST226Touchscreen hub;
hub.set_i2c_bus(&bus);
hub.setup();
EXPECT_TRUE(hub.is_failed());
}
TEST(CST226TouchscreenTest, SetupFailsWithoutTheChip) {
StatusBus bus;
bus.fail = true;
@@ -0,0 +1,32 @@
#include <gtest/gtest.h>
#include <algorithm>
#include "esphome/components/cstxxx/cstxxx.h"
namespace esphome::cstxxx::testing {
TEST(CSTxxxDecodeTouch, FirstRecordStartsTheReport) {
const uint8_t report[report_size(1)] = {0x16, 0x12, 0x34, 0x56, 0x78, 0x01, SYNC_BYTE};
auto touch = decode_touch(report, 0);
EXPECT_EQ(touch.id, 1);
EXPECT_EQ(touch.event, 0x06);
EXPECT_EQ(touch.x, 0x125);
EXPECT_EQ(touch.y, 0x346);
EXPECT_EQ(touch.z, 0x78);
}
TEST(CSTxxxDecodeTouch, LaterRecordsSkipTheStatusBytes) {
uint8_t report[report_size(3)]{};
report[FINGER_COUNT_IDX] = 3;
report[SYNC_IDX] = SYNC_BYTE;
const uint8_t third[5] = {0x26, 0xFF, 0x00, 0xF0, 0x05};
std::copy(third, third + 5, report + 12);
auto touch = decode_touch(report, 2);
EXPECT_EQ(touch.id, 2);
EXPECT_EQ(touch.x, 0xFFF);
EXPECT_EQ(touch.y, 0x000);
EXPECT_EQ(touch.z, 5);
}
} // namespace esphome::cstxxx::testing
@@ -2,3 +2,7 @@ packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
# IPv6 with an IP sensor: compiles the IPv6 event handler's listener notification
network:
enable_ipv6: true
@@ -1,121 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/components/script/script.h"
namespace esphome::script::testing {
// Each body is the function of its script, as code generation emits it; the bodies reach
// their script and record what happened through globals.
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
static void single_body(const int &running);
using TestSingle = FunctionScript<SingleMode<FunctionBody<>>, single_body>;
static TestSingle *single_script;
static int single_runs;
static bool single_running_inside;
static void single_body(const int &running) {
single_runs++;
single_running_inside = single_script->is_running();
// Only once, so a script that is not running here fails the test instead of recursing
if (single_runs == 1)
single_script->execute();
}
TEST(FunctionScriptTest, SingleIsRunningOnlyInsideItsBody) {
TestSingle script;
single_script = &script;
single_runs = 0;
script.execute();
EXPECT_EQ(single_runs, 1);
EXPECT_TRUE(single_running_inside);
EXPECT_FALSE(script.is_running());
}
static void stop_body(const int &running);
using TestStop = FunctionScript<SingleMode<FunctionBody<>>, stop_body>;
static TestStop *stop_script;
static int stop_running_after;
static void stop_body(const int &running) {
stop_script->stop();
stop_running_after = running;
}
TEST(FunctionScriptTest, StopInsideClearsTheRunCount) {
TestStop script;
stop_script = &script;
stop_running_after = -1;
script.execute();
EXPECT_EQ(stop_running_after, 0);
EXPECT_FALSE(script.is_running());
}
static void restart_body(const int &running);
using TestRestart = FunctionScript<RestartMode<FunctionBody<>>, restart_body>;
static TestRestart *restart_script;
static int restart_starts;
static int restart_ends;
static void restart_body(const int &running) {
if (restart_starts++ == 0)
restart_script->execute();
if (running)
restart_ends++;
}
TEST(FunctionScriptTest, RestartFromInsideStopsTheOuterRun) {
TestRestart script;
restart_script = &script;
restart_starts = restart_ends = 0;
script.execute();
EXPECT_EQ(restart_starts, 2);
EXPECT_EQ(restart_ends, 1);
EXPECT_FALSE(script.is_running());
}
static void parallel_body(const int &running, const int &n);
using TestParallel = FunctionScript<ParallelMode<FunctionBody<int>, int>, parallel_body, int>;
static TestParallel *parallel_script;
static int parallel_runs;
static void parallel_body(const int &running, const int &n) {
parallel_runs++;
if (n < 5)
parallel_script->execute(n + 1);
}
TEST(FunctionScriptTest, ParallelCountsNestedRuns) {
TestParallel script;
script.set_max_runs(2);
parallel_script = &script;
parallel_runs = 0;
script.execute(1);
EXPECT_EQ(parallel_runs, 2);
EXPECT_FALSE(script.is_running());
}
static void queued_body(const int &running, const int &n);
using TestQueued = FunctionPointerScript<QueueingMode<FunctionBody<int>, int>, int>;
// Through the base class, as external components keep scripts
static Script<int> *queued_script;
static int queued_last;
static int queued_runs;
static void queued_body(const int &running, const int &n) {
queued_runs++;
queued_last = n;
if (n == 1) {
queued_script->execute(2);
queued_script->execute(3);
}
}
TEST(FunctionScriptTest, QueuedRunsTheQueuedRunAfterTheBody) {
TestQueued script(queued_body);
script.set_max_runs(2);
queued_script = &script;
queued_runs = 0;
script.execute(1);
EXPECT_EQ(queued_runs, 2);
EXPECT_EQ(queued_last, 2);
EXPECT_FALSE(script.is_running());
}
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::script::testing
+16
View File
@@ -0,0 +1,16 @@
"""Enable real logger callbacks for radio reentrancy regression tests."""
import esphome.codegen as cg
from esphome.components.logger import request_log_listener
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config: ConfigType) -> None:
request_log_listener()
# The tests build a software SPI bus in C++, which no configured bus asks for
cg.add_define("USE_SPI_SOFTWARE")
manifest.multi_conf = False
manifest.to_code = to_code
@@ -7,3 +7,22 @@ button:
cold: true
- sx126x.set_mode_sleep:
cold: !lambda return id(sx126x_my_number).state > 1;
- platform: template
name: "SX126x Async Send or Cancel"
on_press:
- lambda: |-
class Listener : public sx126x::SX126xTransmitListener {
public:
void on_transmit_complete(sx126x::SX126xError result) override {
ESP_LOGD("sx126x_test", "Async completion: %d", static_cast<int>(result));
}
};
static Listener listener;
if (id(sx126x_radio).is_transmitting()) {
id(sx126x_radio).cancel_transmit_async(&listener);
} else {
const uint8_t data[] = {0x11, 0x22, 0x33};
auto result = id(sx126x_radio).start_transmit_async(&listener, data, sizeof(data), 4000);
ESP_LOGD("sx126x_test", "Async admission: %d", static_cast<int>(result));
}
+1
View File
@@ -1,4 +1,5 @@
sx126x:
id: sx126x_radio
dio1_pin: ${dio1_pin}
cs_pin: ${cs_pin}
busy_pin: ${busy_pin}
@@ -0,0 +1,643 @@
#include "sx126x_test_helpers.h"
#include "esphome/core/application.h"
#include <tuple>
namespace esphome::sx126x::testing {
class TransmitListener : public SX126xTransmitListener {
public:
void on_transmit_complete(SX126xError result) override {
this->results.push_back(result);
if (this->on_complete) {
this->on_complete(result);
}
}
std::vector<SX126xError> results;
std::function<void(SX126xError)> on_complete;
};
class ReceiveCallback : public SX126xListener {
public:
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override { this->on_packet_callback(); }
std::function<void()> on_packet_callback;
};
class SX126xAsyncModes : public ::testing::TestWithParam<std::tuple<bool, bool, bool>> {};
TEST_P(SX126xAsyncModes, ReturnsBeforeCompletionAndRestoresMode) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware hw;
TransmitListener listener;
hw.configure(lora, rx, fixed);
hw.auto_complete = false;
hw.clear_trace();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 1234), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_TRUE(listener.results.empty());
EXPECT_EQ(hw.sends, 1u);
const auto &tx = hw.transactions.back();
EXPECT_EQ(tx, (std::vector<uint8_t>{RADIO_SET_TX, 0x01, 0x34, 0x80}));
const auto fifo = std::find_if(hw.transactions.begin(), hw.transactions.end(),
[](const auto &cmd) { return cmd[0] == RADIO_WRITE_BUFFER; });
ASSERT_NE(fifo, hw.transactions.end());
EXPECT_EQ(*fifo, (std::vector<uint8_t>{RADIO_WRITE_BUFFER, 0, 0x11, 0x22, 0x33}));
hw.packet.assign(3, 0x99); // The driver must not retain the caller's payload.
hw.irq = IRQ_TX_DONE;
listener.on_complete = [&](SX126xError result) {
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_EQ(hw.receiving, rx);
EXPECT_TRUE(hw.cs.level);
};
hw.radio.loop();
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST_P(SX126xAsyncModes, RejectsEveryFailedStartWithoutCallbackAndCanRetry) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
TransmitListener listener;
reference.configure(lora, rx, fixed);
reference.clear_trace();
ASSERT_EQ(reference.radio.start_transmit_async(&listener, reference.packet), SX126xError::SX126X_ERROR_NONE);
const size_t count = reference.transactions.size();
reference.radio.cancel_transmit_async(&listener);
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
TransmitListener result;
hw.configure(lora, rx, fixed);
hw.clear_trace();
hw.fail_at = fault;
EXPECT_EQ(hw.radio.start_transmit_async(&result, hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(result.results.empty());
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_TRUE(hw.cs.level);
EXPECT_TRUE(hw.transactions[fault - 1].empty());
EXPECT_EQ(hw.sends, 0u);
EXPECT_EQ(hw.receiving, rx); // One-shot fault permits failed-start cleanup.
hw.clear_trace();
hw.radio.configure();
ASSERT_EQ(hw.radio.start_transmit_async(&result, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(result.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
}
TEST_P(SX126xAsyncModes, ReportsEveryCompletionAndCleanupFailureOnce) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
TransmitListener listener;
reference.configure(lora, rx, fixed);
ASSERT_EQ(reference.radio.start_transmit_async(&listener, reference.packet), SX126xError::SX126X_ERROR_NONE);
reference.clear_trace();
reference.radio.loop();
const size_t count = reference.transactions.size();
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
TransmitListener result;
hw.configure(lora, rx, fixed);
ASSERT_EQ(hw.radio.start_transmit_async(&result, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.clear_trace();
hw.fail_at = fault;
hw.radio.loop();
EXPECT_EQ(result.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_BUSY_TIMEOUT}));
EXPECT_TRUE(hw.transactions[fault - 1].empty());
EXPECT_TRUE(hw.cs.level);
EXPECT_FALSE(hw.radio.is_transmitting());
hw.radio.loop();
EXPECT_EQ(result.results.size(), 1u);
hw.clear_trace();
hw.radio.configure();
ASSERT_EQ(hw.radio.start_transmit_async(&result, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
ASSERT_EQ(result.results.size(), 2u);
EXPECT_EQ(result.results.back(), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(hw.receiving, rx);
}
}
TEST_P(SX126xAsyncModes, BlockingAndAsyncUseTheSameCommandSequence) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware blocking, async;
TransmitListener listener;
blocking.configure(lora, rx, fixed);
async.configure(lora, rx, fixed);
blocking.clear_trace();
async.clear_trace();
ASSERT_EQ(blocking.radio.transmit_packet(blocking.packet), SX126xError::SX126X_ERROR_NONE);
ASSERT_EQ(async.radio.start_transmit_async(&listener, async.packet), SX126xError::SX126X_ERROR_NONE);
async.radio.loop();
EXPECT_EQ(blocking.transactions, async.transactions);
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
EXPECT_EQ(blocking.receiving, async.receiving);
}
INSTANTIATE_TEST_SUITE_P(LoRaAndFSKWithRxOrSleepAndFixedOrVariablePayload, SX126xAsyncModes,
::testing::Combine(::testing::Bool(), ::testing::Bool(), ::testing::Bool()));
TEST(SX126xAsync, SoftwareDeadlineWithoutDioAndCachedTickBeforeStart) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
hw.auto_complete = false;
hw.dio.internal = true;
const uint32_t before = millis();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 50), SX126xError::SX126X_ERROR_NONE);
const uint32_t after = millis();
{
LoopBlockingGuard clock{nullptr, nullptr, before - 1};
hw.radio.call();
EXPECT_TRUE(listener.results.empty()); // No unsigned underflow on the admitting loop tick.
}
{
LoopBlockingGuard clock{nullptr, nullptr, after + 51};
hw.radio.call(); // Still enabled despite no IRQ or ISR edge.
}
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_TIMEOUT}));
EXPECT_TRUE(hw.receiving);
EXPECT_FALSE(hw.radio.is_transmitting());
}
TEST(SX126xAsync, UnrelatedIrqIsNotSuccessAndTimeoutWinsOverTxDone) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
hw.auto_complete = false;
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.irq = IRQ_RX_DONE;
{
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
}
EXPECT_TRUE(listener.results.empty());
hw.irq = IRQ_TX_DONE | IRQ_RX_TX_TIMEOUT;
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_TIMEOUT}));
}
TEST(SX126xAsync, CancelRequiresOwnerAndCompletesSynchronouslyOnce) {
RadioHardware hw;
TransmitListener listener, other;
hw.configure(true, true);
hw.auto_complete = false;
EXPECT_FALSE(hw.radio.cancel_transmit_async(&listener));
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_FALSE(hw.radio.cancel_transmit_async(nullptr));
EXPECT_FALSE(hw.radio.cancel_transmit_async(&other));
EXPECT_TRUE(hw.radio.cancel_transmit_async(&listener));
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_CANCELLED}));
EXPECT_FALSE(hw.radio.cancel_transmit_async(&listener));
hw.irq = IRQ_TX_DONE; // A late IRQ cannot deliver a second completion.
hw.radio.loop();
EXPECT_EQ(listener.results.size(), 1u);
EXPECT_TRUE(hw.receiving);
hw.irq = IRQ_TX_DONE; // Also cleared before a new send starts.
ASSERT_EQ(hw.radio.start_transmit_async(&other, hw.packet), SX126xError::SX126X_ERROR_NONE);
{
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
}
EXPECT_TRUE(other.results.empty());
EXPECT_TRUE(hw.radio.cancel_transmit_async(&other));
}
TEST(SX126xAsync, CompletionCallbackCanStartNextSend) {
RadioHardware hw;
TransmitListener first, second;
hw.configure(true, true);
hw.auto_complete = false;
first.on_complete = [&](SX126xError result) {
EXPECT_EQ(hw.radio.start_transmit_async(&second, hw.packet), SX126xError::SX126X_ERROR_NONE);
};
ASSERT_EQ(hw.radio.start_transmit_async(&first, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(first.results.size(), 1u);
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_TRUE(second.results.empty());
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(second.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
static void expect_conflicting_calls_rejected(RadioHardware &hw, TransmitListener &listener) {
const size_t count = hw.transactions.size();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_BUSY);
hw.radio.set_mode_rx();
hw.radio.set_mode_tx();
hw.radio.set_mode_standby(STDBY_XOSC);
hw.radio.set_mode_sleep(true);
hw.radio.configure();
hw.radio.run_image_cal();
EXPECT_EQ(hw.transactions.size(), count);
}
TEST(SX126xAsync, PendingReceiveRejectsAdmissionWithoutDispatchOrAcknowledgment) {
RadioHardware hw;
TransmitListener listener;
PacketListener receiver;
hw.configure(true, true);
hw.radio.register_listener(&receiver);
hw.irq = IRQ_RX_DONE;
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_TRUE(receiver.packets.empty());
EXPECT_TRUE(listener.results.empty());
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
hw.radio.loop();
EXPECT_EQ(receiver.packets.size(), 1u);
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
expect_conflicting_calls_rejected(hw, listener);
hw.radio.loop();
EXPECT_EQ(listener.results.size(), 1u);
}
TEST(SX126xAsync, ValidatesReadinessPayloadAndTimeoutWithoutSpi) {
RadioHardware hw;
TransmitListener listener;
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
hw.configure(true, true, true);
hw.clear_trace();
EXPECT_EQ(hw.radio.start_transmit_async(nullptr, hw.packet), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, nullptr, 3), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet.data(), 0), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet.data(), 2), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet.data(), 256), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 0), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 262144), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.results.empty());
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 262143), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(hw.transactions.back(), (std::vector<uint8_t>{RADIO_SET_TX, 0xFF, 0xFF, 0xC0}));
hw.radio.loop();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet, 1), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(hw.transactions.back(), (std::vector<uint8_t>{RADIO_SET_TX, 0, 0, 64}));
hw.radio.loop();
}
TEST(SX126xAsync, PersistentBusyReportsFailedCleanupAndAllowsLaterRecovery) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
hw.auto_complete = false;
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.busy.on_read = []() { return true; };
ASSERT_TRUE(hw.radio.cancel_transmit_async(&listener));
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_BUSY_TIMEOUT}));
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_TRUE(hw.radio.status_has_warning());
EXPECT_TRUE(hw.cs.level);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_EQ(listener.results.size(), 1u); // Failed admission never invokes completion.
hw.busy.on_read = []() { return false; };
hw.radio.configure();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
ASSERT_EQ(listener.results.size(), 2u);
EXPECT_EQ(listener.results.back(), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hw.receiving);
EXPECT_FALSE(hw.radio.status_has_warning());
}
TEST(SX126xAsync, CancellationCallbackMayStartAnotherSend) {
RadioHardware hw;
TransmitListener first, second;
hw.configure(true, true);
hw.auto_complete = false;
first.on_complete = [&](SX126xError result) {
EXPECT_EQ(result, SX126xError::SX126X_ERROR_CANCELLED);
EXPECT_EQ(hw.radio.start_transmit_async(&second, hw.packet), SX126xError::SX126X_ERROR_NONE);
};
ASSERT_EQ(hw.radio.start_transmit_async(&first, hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hw.radio.cancel_transmit_async(&first));
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_FALSE(hw.radio.cancel_transmit_async(&first));
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(second.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, RejectsAfterFailedSetupWithoutBusAccess) {
RadioHardware hw;
TransmitListener listener;
hw.fail_at = 1;
hw.configure(true, true);
ASSERT_TRUE(hw.radio.is_failed());
hw.clear_trace();
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.results.empty());
}
TEST(SX126xAsync, TwoRadiosHaveIndependentOwnersAndCompletion) {
RadioHardware a, b;
TransmitListener first, second;
a.configure(true, true);
b.configure(false, false);
a.auto_complete = b.auto_complete = false;
ASSERT_EQ(a.radio.start_transmit_async(&first, a.packet), SX126xError::SX126X_ERROR_NONE);
ASSERT_EQ(b.radio.start_transmit_async(&second, b.packet), SX126xError::SX126X_ERROR_NONE);
a.radio.cancel_transmit_async(&first);
EXPECT_TRUE(b.radio.is_transmitting());
EXPECT_TRUE(second.results.empty());
b.irq = IRQ_TX_DONE;
b.radio.loop();
EXPECT_EQ(first.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_CANCELLED}));
EXPECT_EQ(second.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, BackToBackReceiveEchoPreservesBorrowedPacketForAllListeners) {
class EchoListener : public SX126xListener {
public:
std::function<void(const std::vector<uint8_t> &)> callback;
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override { callback(packet); }
} echo;
RadioHardware hw;
TransmitListener tx;
PacketListener observer;
hw.configure(true, true);
hw.auto_complete = false;
hw.radio.register_listener(&echo);
hw.radio.register_listener(&observer);
const auto first = hw.packet;
const std::vector<uint8_t> second(200, 0xA5);
unsigned calls = 0;
echo.callback = [&](const std::vector<uint8_t> &packet) {
if (++calls == 1) {
hw.packet = second; // Larger back-to-back RX could reallocate the driver's packet_.
hw.irq = IRQ_RX_DONE;
const auto count = hw.transactions.size();
hw.radio.loop(); // Explicit loop reentry must not overwrite the borrowed RX buffer either.
// The send stops in standby and holds the new packet instead of overwriting the FIFO.
EXPECT_EQ(hw.radio.start_transmit_async(&tx, packet), SX126xError::SX126X_ERROR_BUSY);
ASSERT_EQ(hw.transactions.size(), count + 2);
EXPECT_EQ(hw.transactions[count][0], RADIO_SET_STANDBY);
EXPECT_EQ(hw.transactions.back()[0], RADIO_GET_IRQSTATUS);
EXPECT_EQ(packet, first);
} else {
EXPECT_EQ(packet, second);
EXPECT_EQ(hw.radio.start_transmit_async(&tx, packet), SX126xError::SX126X_ERROR_NONE);
}
};
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
ASSERT_EQ(observer.packets.size(), 1u);
EXPECT_EQ(observer.packets[0], first);
EXPECT_TRUE(tx.results.empty());
EXPECT_FALSE(hw.radio.is_transmitting());
hw.radio.loop();
ASSERT_EQ(observer.packets.size(), 2u);
EXPECT_EQ(observer.packets[1], second);
ASSERT_EQ(calls, 2u);
auto fifo = std::find_if(hw.transactions.rbegin(), hw.transactions.rend(),
[](const auto &cmd) { return !cmd.empty() && cmd[0] == RADIO_WRITE_BUFFER; });
ASSERT_NE(fifo, hw.transactions.rend());
EXPECT_EQ(std::vector<uint8_t>(fifo->begin() + 2, fifo->end()), second);
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(tx.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, FailedReconfigurationRejectsAsyncUntilReconfigured) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = 3;
hw.radio.configure();
hw.clear_trace();
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.results.empty());
hw.radio.configure();
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, BlockingSendUsesIrqResultInsteadOfTreatingAnyDioAsSuccess) {
RadioHardware hw;
hw.configure(true, true);
auto write = hw.cs.on_write;
hw.cs.on_write = [&](bool high) {
write(high);
if (high && !hw.transactions.back().empty() && hw.transactions.back()[0] == RADIO_SET_TX) {
hw.irq = IRQ_RX_TX_TIMEOUT;
}
};
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_TIMEOUT);
EXPECT_TRUE(hw.receiving);
}
TEST(SX126xAsync, FailedAdmissionRestoresReceivePollingWithoutAnotherTransmission) {
RadioHardware hw;
PacketListener rx;
TransmitListener tx;
hw.configure(true, true);
hw.radio.register_listener(&rx);
const auto configured_params = hw.packet_params;
hw.dio.internal = true;
hw.radio.call();
ASSERT_TRUE(hw.radio.is_idle());
hw.clear_trace();
hw.fail_at = 3;
EXPECT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(tx.results.empty());
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.packet_params, configured_params);
hw.clear_trace();
hw.irq = IRQ_RX_DONE; // No simulated ISR edge: restoration must re-enable polling.
hw.radio.call();
ASSERT_EQ(rx.packets.size(), 1u);
EXPECT_EQ(rx.packets[0], hw.packet);
EXPECT_EQ(hw.sends, 0u);
}
TEST_P(SX126xAsyncModes, ReceiveAtStandbyRejectsSendAndSurvivesReadFailure) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware hw;
PacketListener received;
TransmitListener tx;
hw.configure(lora, rx, fixed);
hw.radio.call(); // Advance from setup into ESPHome loop scheduling.
hw.radio.register_listener(&received);
hw.clear_trace();
auto write = hw.cs.on_write;
bool inject = true;
hw.cs.on_write = [&](bool high) {
write(high);
if (high && inject && !hw.transactions.back().empty() && hw.transactions.back()[0] == RADIO_SET_STANDBY) {
inject = false;
hw.irq = IRQ_RX_DONE;
}
};
EXPECT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_TRUE(tx.results.empty());
EXPECT_TRUE(received.packets.empty());
EXPECT_FALSE(hw.receiving);
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
ASSERT_EQ(hw.transactions.size(), 2u);
EXPECT_EQ(hw.transactions[0][0], RADIO_SET_STANDBY);
EXPECT_EQ(hw.transactions[1][0], RADIO_GET_IRQSTATUS);
const auto resets = hw.rst.writes;
hw.radio.set_mode_rx();
EXPECT_EQ(hw.rst.writes, resets);
EXPECT_EQ(hw.transactions.size(), 2u);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_FALSE(hw.radio.cancel_transmit_async(&tx));
hw.clear_trace();
hw.fail_at = 2; // Packet metadata read fails after the held IRQ is read.
hw.radio.loop();
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_TRUE(received.packets.empty());
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
EXPECT_FALSE(hw.receiving);
hw.clear_trace();
LoopBlockingGuard retry{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hw.radio.loop();
hw.radio.loop();
ASSERT_EQ(received.packets.size(), 1u);
EXPECT_EQ(received.packets[0], hw.packet);
EXPECT_EQ(hw.receiving, rx);
EXPECT_EQ(hw.sends, 0u);
EXPECT_TRUE(tx.results.empty());
ASSERT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(tx.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST_P(SX126xAsyncModes, FailedPreparationAndRestorationRequireSuccessfulConfiguration) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware hw;
TransmitListener listener;
hw.configure(lora, rx, fixed);
hw.clear_trace();
const auto busy = hw.busy.on_read;
hw.busy.on_read = [&]() { return hw.transactions.size() >= 3 || busy(); };
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(listener.results.empty());
EXPECT_EQ(hw.sends, 0u);
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_TRUE(hw.cs.level);
ASSERT_EQ(hw.transactions.size(), 4u);
EXPECT_TRUE(hw.transactions[2].empty()); // Failed preparation.
EXPECT_TRUE(hw.transactions[3].empty()); // Failed restoration.
hw.clear_trace();
hw.busy.on_read = busy;
hw.radio.set_mode_rx();
hw.radio.loop();
EXPECT_TRUE(hw.radio.is_idle());
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
hw.fail_at = 1;
hw.radio.configure();
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.clear_trace();
hw.radio.configure();
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_EQ(hw.receiving, rx);
EXPECT_TRUE(listener.results.empty());
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
TEST(SX126xAsync, ExplicitConfigurationCanRecoverAnUnreadableHeldPacket) {
RadioHardware hw;
TransmitListener listener;
hw.configure(true, true);
auto write = hw.cs.on_write;
hw.cs.on_write = [&](bool high) {
write(high);
if (high && !hw.transactions.back().empty() && hw.transactions.back()[0] == RADIO_SET_STANDBY) {
hw.irq = IRQ_RX_DONE;
}
};
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_BUSY);
hw.cs.on_write = write;
hw.busy.on_read = []() { return true; };
hw.radio.loop();
const auto resets = hw.rst.writes;
hw.radio.configure();
EXPECT_GT(hw.rst.writes, resets);
EXPECT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.busy.on_read = []() { return false; };
hw.radio.configure();
EXPECT_TRUE(hw.receiving);
EXPECT_TRUE(listener.results.empty());
ASSERT_EQ(hw.radio.start_transmit_async(&listener, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop();
EXPECT_EQ(listener.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
}
class SX126xReceiveActions : public ::testing::TestWithParam<unsigned> {};
TEST_P(SX126xReceiveActions, ModeAndConfigurationPreserveBorrowedPacket) {
RadioHardware hw;
ReceiveCallback action;
PacketListener observer;
hw.configure(true, true);
hw.radio.register_listener(&action);
hw.radio.register_listener(&observer);
const auto original = hw.packet;
const unsigned selected_action = GetParam();
unsigned callbacks = 0;
action.on_packet_callback = [&]() {
callbacks++;
hw.clear_trace();
switch (selected_action) {
case 0:
hw.radio.set_mode_sleep();
break;
case 1:
hw.radio.set_mode_sleep(true);
break;
case 2:
hw.radio.set_rx_start(false);
hw.radio.configure();
break;
case 3:
hw.radio.set_mode_standby(STDBY_XOSC);
break;
case 4:
hw.radio.set_mode_rx();
break;
case 5:
hw.radio.run_image_cal();
break;
default:
FAIL() << "Unknown action " << selected_action;
}
EXPECT_FALSE(hw.transactions.empty()); // The requested operation must actually reach the chip.
EXPECT_EQ(hw.receiving, selected_action >= 4);
const auto commands = hw.transactions.size();
hw.packet.assign(19, 0x99);
hw.irq = IRQ_RX_DONE;
hw.radio.loop(); // Recursive packet delivery is still forbidden, including after configure().
EXPECT_EQ(hw.transactions.size(), commands);
};
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
EXPECT_EQ(callbacks, 1u);
ASSERT_EQ(observer.packets.size(), 1u);
EXPECT_EQ(observer.packets.front(), original);
EXPECT_EQ(hw.irq, IRQ_RX_DONE); // The next packet is left for a later loop.
if (selected_action == 1) {
TransmitListener tx;
EXPECT_EQ(hw.radio.start_transmit_async(&tx, original), SX126xError::SX126X_ERROR_NOT_READY);
}
}
INSTANTIATE_TEST_SUITE_P(SleepColdSleepConfigureStandbyRxAndCalibration, SX126xReceiveActions,
::testing::Range(0u, 6u));
} // namespace esphome::sx126x::testing
@@ -0,0 +1,788 @@
#include "sx126x_test_helpers.h"
#include <cmath>
#include "esphome/core/application.h"
#include <tuple>
#include <type_traits>
#ifdef USE_HOST
#include <string>
#include "esphome/components/logger/logger.h"
#endif
namespace esphome::sx126x::testing {
class SX126xBusySetup : public ::testing::TestWithParam<std::tuple<bool, bool>> {};
TEST_P(SX126xBusySetup, StopsAtEveryConfigurationCommand) {
const auto [lora, rx] = GetParam();
RadioHardware reference;
reference.configure(lora, rx);
ASSERT_FALSE(reference.radio.is_failed());
ASSERT_FALSE(reference.radio.status_has_warning());
const size_t count = reference.transactions.size();
ASSERT_GT(count, 10u);
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hardware;
hardware.fail_at = fault;
hardware.configure(lora, rx);
hardware.expect_aborted(fault);
EXPECT_TRUE(hardware.radio.is_failed());
hardware.radio.dump_config(); // Also safe if the version read never happened.
hardware.clear_trace();
hardware.radio.configure();
hardware.radio.set_mode_rx();
hardware.radio.set_mode_tx();
hardware.radio.set_mode_sleep();
hardware.radio.set_mode_standby(STDBY_XOSC);
hardware.radio.run_image_cal();
hardware.radio.loop();
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hardware.transactions.empty());
}
}
INSTANTIATE_TEST_SUITE_P(LoRaAndFSKWithRxOrSleep, SX126xBusySetup,
::testing::Combine(::testing::Bool(), ::testing::Bool()));
class SX126xBusyTransmit : public ::testing::TestWithParam<std::tuple<bool, bool, bool>> {};
TEST_P(SX126xBusyTransmit, StopsAtEverySendAndCleanupCommandAndCanRetry) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
reference.configure(lora, rx, fixed);
reference.clear_trace();
ASSERT_EQ(reference.radio.transmit_packet(reference.packet), SX126xError::SX126X_ERROR_NONE);
const size_t count = reference.transactions.size();
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hardware;
hardware.configure(lora, rx, fixed);
hardware.clear_trace();
hardware.fail_at = fault;
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet.data(), hardware.packet.size()),
SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(hardware.transactions[fault - 1].empty());
EXPECT_TRUE(hardware.cs.level);
EXPECT_FALSE(hardware.radio.is_failed());
hardware.clear_trace();
hardware.radio.configure();
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_FALSE(hardware.radio.status_has_warning());
EXPECT_EQ(hardware.receiving, rx);
}
}
TEST_P(SX126xBusyTransmit, RestoresEveryPreparationFailureWithoutAnotherTransmission) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
reference.configure(lora, rx, fixed);
reference.clear_trace();
ASSERT_EQ(reference.radio.transmit_packet(reference.packet), SX126xError::SX126X_ERROR_NONE);
size_t tx_command = 0;
for (size_t i = 0; i < reference.transactions.size(); i++) {
if (reference.transactions[i][0] == RADIO_SET_TX) {
tx_command = i + 1;
break;
}
}
ASSERT_NE(tx_command, 0u);
for (size_t fault = 1; fault <= tx_command; fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
PacketListener listener;
hw.configure(lora, rx, fixed);
hw.radio.register_listener(&listener);
const auto configured_params = hw.packet_params;
hw.clear_trace();
hw.fail_at = fault;
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(hw.transactions[fault - 1].empty());
EXPECT_TRUE(hw.cs.level);
EXPECT_EQ(hw.ends, hw.transactions.size());
EXPECT_EQ(hw.receiving, rx);
EXPECT_EQ(hw.packet_params, configured_params);
EXPECT_EQ(hw.irq, 0u);
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_FALSE(hw.radio.is_failed());
const auto sends = hw.sends;
hw.clear_trace();
if (rx) {
if (!fixed) {
hw.packet.resize(9, 0x55); // RX must accept a different length than the attempted TX.
}
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hw.packet);
}
EXPECT_EQ(hw.sends, sends);
}
}
TEST_P(SX126xBusyTransmit, FailedRestorationRequiresConfigureBeforeReceiveRecovery) {
const auto [lora, rx, fixed] = GetParam();
RadioHardware reference;
reference.configure(lora, rx, fixed);
reference.clear_trace();
ASSERT_EQ(reference.radio.transmit_packet(reference.packet), SX126xError::SX126X_ERROR_NONE);
size_t restoration = 0;
bool sent = false;
for (size_t i = 0; i < reference.transactions.size(); i++) {
if (reference.transactions[i][0] == RADIO_SET_TX) {
sent = true;
}
if (sent && reference.transactions[i][0] == RADIO_SET_STANDBY) {
restoration = i + 1;
break;
}
}
ASSERT_NE(restoration, 0u);
for (size_t fault = restoration; fault <= reference.transactions.size(); fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
PacketListener listener;
hw.configure(lora, rx, fixed);
hw.radio.register_listener(&listener);
const auto configured_params = hw.packet_params;
hw.clear_trace();
hw.fail_at = fault;
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_TRUE(hw.cs.level);
EXPECT_TRUE(hw.radio.status_has_warning());
EXPECT_FALSE(hw.radio.is_failed());
const auto sends = hw.sends;
hw.clear_trace(); // Clearing BUSY alone must not re-admit partial configuration.
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
hw.radio.set_mode_rx();
hw.radio.set_mode_tx();
hw.radio.set_mode_standby(STDBY_XOSC);
hw.radio.set_mode_sleep();
hw.radio.run_image_cal();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.packets.empty());
EXPECT_TRUE(hw.radio.status_has_warning());
hw.irq = 0; // Hardware reset clears IRQs; the GPIO fake does not model reset.
hw.radio.configure();
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_EQ(hw.packet_params, configured_params);
EXPECT_EQ(hw.receiving, rx);
if (rx) {
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hw.packet);
}
EXPECT_EQ(hw.sends, sends); // Recovery must not depend on sending another packet.
}
}
TEST(SX126xBusy, PersistentBusyDuringPreparationAndRestorationRequiresConfigure) {
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
const auto configured_params = hw.packet_params;
hw.clear_trace();
auto normal_busy = hw.busy.on_read;
hw.busy.on_read = [&]() { return hw.transactions.size() >= 3 || normal_busy(); };
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_FALSE(hw.receiving);
EXPECT_TRUE(hw.radio.status_has_warning());
EXPECT_EQ(hw.sends, 0u);
hw.busy.on_read = normal_busy;
hw.clear_trace();
hw.radio.set_mode_rx();
hw.radio.run_image_cal();
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(hw.radio.status_has_warning());
hw.radio.configure();
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.packet_params, configured_params);
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hw.packet);
EXPECT_EQ(hw.sends, 0u);
}
INSTANTIATE_TEST_SUITE_P(LoRaAndFSKWithRxOrSleepAndFixedOrVariablePayload, SX126xBusyTransmit,
::testing::Combine(::testing::Bool(), ::testing::Bool(), ::testing::Bool()));
class SX126xBusyReceive : public ::testing::TestWithParam<bool> {};
TEST_P(SX126xBusyReceive, KeepsAPacketWhoseReadFailedAndRetries) {
const bool lora = GetParam();
for (size_t fault = 1; fault <= 5; fault++) {
SCOPED_TRACE(fault);
RadioHardware hardware;
PacketListener listener;
hardware.configure(lora, true);
hardware.radio.register_listener(&listener);
hardware.clear_trace();
hardware.irq = IRQ_RX_DONE;
hardware.fail_at = fault;
hardware.radio.loop();
hardware.expect_aborted(fault);
EXPECT_TRUE(listener.packets.empty());
EXPECT_TRUE(hardware.radio.status_has_warning());
EXPECT_FALSE(hardware.radio.is_failed());
// The flags may already be cleared; the packet is still read on the retry.
hardware.clear_trace();
LoopBlockingGuard retry{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hardware.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hardware.packet);
EXPECT_FLOAT_EQ(listener.rssi, lora ? -40 : -42);
if (lora) {
EXPECT_FLOAT_EQ(listener.snr, 10);
} else {
EXPECT_TRUE(std::isnan(listener.snr));
}
EXPECT_FALSE(hardware.radio.status_has_warning());
}
}
INSTANTIATE_TEST_SUITE_P(LoRaAndFSK, SX126xBusyReceive, ::testing::Bool());
// A packet whose flags were cleared before its read failed, then held by a send, survives a
// second failed read
TEST(SX126xBusy, HeldPacketSurvivesASecondFailedRead) {
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
hw.clear_trace();
hw.irq = IRQ_RX_DONE;
hw.fail_at = 3; // the first read after the flags were cleared
hw.radio.loop();
ASSERT_EQ(hw.irq, 0);
ASSERT_TRUE(listener.packets.empty());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY);
hw.clear_trace();
hw.fail_at = 2; // the held packet's read
LoopBlockingGuard first{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hw.radio.loop();
ASSERT_TRUE(listener.packets.empty());
hw.clear_trace();
LoopBlockingGuard second{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hw.radio.loop();
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets[0], hw.packet);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, KeepsPollingAnUnclearedInterruptAfterBusyTimeout) {
RadioHardware hardware;
PacketListener listener;
hardware.configure(true, true);
hardware.radio.register_listener(&listener);
hardware.dio.internal = true; // No new ISR edge while the pending IRQ stays high.
hardware.clear_trace();
hardware.fail_at = 1;
hardware.irq = IRQ_RX_DONE;
hardware.radio.call();
EXPECT_TRUE(listener.packets.empty());
hardware.clear_trace();
LoopBlockingGuard retry{nullptr, nullptr, App.get_loop_component_start_time() + 200};
hardware.radio.call();
EXPECT_EQ(listener.packets.size(), 1u);
}
TEST(SX126xBusy, AssertsChipSelectBeforeWaitingForWakeup) {
RadioHardware hardware;
hardware.configure(true, true);
hardware.clear_trace();
hardware.busy.level = true; // The CS callback releases BUSY only after selecting the chip.
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hardware.cs.level);
EXPECT_EQ(hardware.ends, hardware.transactions.size());
}
TEST(SX126xBusy, RejectsSendBeforeSetupWithoutTouchingPins) {
RadioHardware hardware;
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hardware.transactions.empty());
EXPECT_EQ(hardware.busy.reads, 0u);
EXPECT_EQ(hardware.dio.reads, 0u);
}
TEST(SX126xBusy, UnsupportedVersionFailsSetup) {
RadioHardware hardware;
hardware.supported_version = false;
hardware.configure(true, true);
EXPECT_TRUE(hardware.radio.is_failed());
}
TEST(SX126xBusy, RuntimeVersionFailureRequiresConfigureWithoutPermanentlyFailing) {
RadioHardware hardware;
hardware.configure(true, true);
hardware.supported_version = false;
hardware.radio.configure();
EXPECT_FALSE(hardware.radio.is_failed());
EXPECT_TRUE(hardware.radio.status_has_warning());
hardware.clear_trace();
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_TRUE(hardware.transactions.empty());
hardware.supported_version = true;
hardware.radio.configure();
EXPECT_FALSE(hardware.radio.status_has_warning());
EXPECT_EQ(hardware.radio.transmit_packet(hardware.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, FailedReconfigurationRequiresSuccessfulConfigureBeforeOperations) {
RadioHardware reference;
reference.configure(true, true);
const size_t count = reference.transactions.size();
for (size_t fault = 1; fault <= count; fault++) {
SCOPED_TRACE(fault);
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
hw.clear_trace();
hw.fail_at = fault;
hw.radio.configure();
EXPECT_FALSE(hw.radio.is_failed());
hw.clear_trace();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
hw.radio.set_mode_rx();
hw.radio.set_mode_tx();
hw.radio.set_mode_standby(STDBY_XOSC);
hw.radio.set_mode_sleep();
hw.radio.run_image_cal();
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(listener.packets.empty());
hw.radio.configure();
hw.irq = 0;
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
}
TEST(SX126xBusy, RetryClearsStaleTxDoneBeforeWaitingForDelayedCompletion) {
RadioHardware reference;
reference.configure(true, true);
reference.clear_trace();
reference.radio.transmit_packet(reference.packet);
size_t cleanup = 0;
bool sent = false;
for (size_t i = 0; i < reference.transactions.size(); i++) {
if (reference.transactions[i][0] == RADIO_SET_TX) {
sent = true;
}
if (sent && reference.transactions[i][0] == RADIO_CLR_IRQSTATUS) {
cleanup = i + 1;
break;
}
}
ASSERT_NE(cleanup, 0u);
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = cleanup;
ASSERT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
EXPECT_EQ(hw.irq & IRQ_TX_DONE, 0); // Shared restoration retries the failed IRQ clear.
hw.irq = IRQ_TX_DONE; // A stale flag must still not finish the next TX.
hw.clear_trace();
hw.auto_complete = false;
unsigned polls = 0;
hw.dio.on_read = [&]() {
if (++polls == 5) {
hw.irq |= IRQ_TX_DONE;
}
return hw.irq != 0;
};
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(polls, 5u);
EXPECT_TRUE(hw.receiving);
}
} // namespace esphome::sx126x::testing
namespace esphome::sx126x::testing {
template<typename T>
concept RawSpiAccessible = requires(T &device) { device.transfer_byte(0); };
template<typename T>
concept ManualSelectionAccessible = requires(T &device) {
device.enable();
device.disable();
};
static_assert(!RawSpiAccessible<SX126x>);
static_assert(!RawSpiAccessible<SX126xSpiDevice>);
static_assert(!ManualSelectionAccessible<SX126x>);
static_assert(!ManualSelectionAccessible<SX126xSpiDevice>);
// The radio runs every command sequence and state change. Without component status or
// listeners it cannot run user code mid-operation; SX126x::apply_() does that afterwards.
static_assert(!std::is_base_of_v<Component, SX126xRadio>);
} // namespace esphome::sx126x::testing
namespace esphome::sx126x::testing {
TEST(SX126xBusy, FailedModeChangeCannotBeClearedByAnotherModeCommand) {
for (size_t fault = 1; fault <= 3; fault++) {
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = fault;
hw.radio.set_mode_rx();
EXPECT_TRUE(hw.radio.status_has_warning());
hw.clear_trace();
hw.radio.set_mode_standby(STDBY_XOSC);
hw.radio.run_image_cal();
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(hw.radio.status_has_warning());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.radio.configure();
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_TRUE(hw.receiving);
}
}
TEST(SX126xBusy, ConfigurationCannotReenterSendPreparationOrRestoration) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.call(); // Advance from setup into ESPHome loop scheduling.
hw.clear_trace();
auto write = hw.cs.on_write;
unsigned guarded = 0;
hw.cs.on_write = [&](bool high) {
write(high);
if (!high || hw.transactions.back().empty() || hw.transactions.back()[0] != RADIO_SET_STANDBY) {
return;
}
guarded++;
const auto commands = hw.transactions.size();
hw.radio.configure();
hw.radio.set_mode_rx();
hw.radio.loop();
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY);
EXPECT_EQ(hw.transactions.size(), commands);
};
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(guarded, 2u);
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_TRUE(hw.receiving);
}
} // namespace esphome::sx126x::testing
namespace esphome::sx126x::testing {
TEST(SX126xBusy, InvalidPayloadDoesNotEnterAnOperation) {
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
EXPECT_EQ(hw.radio.transmit_packet(nullptr, 3), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet.data(), 0), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, ColdSleepIsWokenByAModeCommandAndWarmSleepRetainsIt) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.set_mode_sleep(false);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.set_mode_sleep(true);
hw.clear_trace();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
// Sleeping again leaves it asleep
hw.radio.set_mode_sleep(false);
EXPECT_TRUE(hw.transactions.empty());
// Any other mode command applies the lost configuration first
hw.radio.set_mode_rx();
EXPECT_FALSE(hw.transactions.empty());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
// Runs the scheduler for ms milliseconds of real time, as the main loop would
static void run_scheduler(uint32_t ms) {
const uint32_t start = millis();
while (millis() - start < ms) {
App.scheduler.call(millis());
delay(5);
}
}
TEST(SX126xBusy, FailedModeChangeIsReconfiguredWithoutBeingAsked) {
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = 1;
hw.radio.set_mode_rx();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
run_scheduler(1100);
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
// Once configured, the retry stops
hw.clear_trace();
run_scheduler(1100);
EXPECT_TRUE(hw.transactions.empty());
}
TEST(SX126xBusy, ColdSleepIsNotReconfiguredWithoutBeingAsked) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.set_mode_sleep(true);
hw.clear_trace();
run_scheduler(1100);
EXPECT_TRUE(hw.transactions.empty());
}
TEST(SX126xBusy, ConfigureBeforeSetupDoesNothing) {
RadioHardware hw;
hw.radio.configure();
EXPECT_TRUE(hw.transactions.empty());
EXPECT_EQ(hw.rst.writes, 0u);
hw.configure(true, true);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
// The TX mode command sends whatever the FIFO holds without tracking it; its TX_DONE is
// cleared as a flag without a packet, and a send still works afterwards
TEST(SX126xBusy, RawTxModeLeavesTheRadioUsable) {
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
hw.radio.set_mode_tx();
ASSERT_EQ(hw.irq & IRQ_TX_DONE, IRQ_TX_DONE);
hw.radio.loop();
EXPECT_EQ(hw.irq, 0);
EXPECT_TRUE(listener.packets.empty());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, PacketThatFailedItsCrcDoesNotHoldUpASend) {
RadioHardware hw;
hw.configure(true, true);
hw.irq = IRQ_RX_DONE | IRQ_CRC_ERROR;
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
TEST(SX126xBusy, PacketCompletingDuringAReadIsReadNext) {
RadioHardware hw;
PacketListener listener;
hw.configure(true, true);
hw.radio.register_listener(&listener);
bool second = false;
hw.on_command = [&](uint8_t opcode) {
if (opcode == RADIO_READ_BUFFER && !second) {
second = true;
hw.irq |= IRQ_RX_DONE;
}
};
hw.irq = IRQ_RX_DONE;
hw.radio.loop();
EXPECT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(hw.irq & IRQ_RX_DONE, IRQ_RX_DONE); // DIO1 stays high for the second packet
hw.radio.loop();
EXPECT_EQ(listener.packets.size(), 2u);
}
TEST(SX126xBusy, UnavailableLoopStopsAndConfigurationResumesReceiveWithoutAnInterrupt) {
for (bool internal : {false, true}) {
for (unsigned cause = 0; cause < 3; cause++) {
SCOPED_TRACE(internal);
SCOPED_TRACE(cause);
RadioHardware hw;
PacketListener received;
hw.configure(true, true);
hw.radio.register_listener(&received);
// Set after setup: the fake pin cannot attach a hardware interrupt.
hw.dio.internal = internal;
hw.clear_trace();
if (cause == 0) {
hw.radio.set_mode_sleep(true);
} else if (cause == 1) {
hw.fail_at = 1;
hw.radio.configure();
} else {
hw.fail_at = 1;
hw.radio.set_mode_rx();
}
hw.clear_trace();
hw.radio.call();
ASSERT_TRUE(hw.radio.is_idle());
EXPECT_TRUE(hw.transactions.empty());
EXPECT_EQ(hw.dio.reads, 0u);
hw.radio.configure();
ASSERT_TRUE(hw.radio.is_in_loop_state());
hw.irq = IRQ_RX_DONE; // No ISR edge: configure() must resume polling itself.
hw.radio.call();
ASSERT_EQ(received.packets.size(), 1u);
EXPECT_EQ(received.packets[0], hw.packet);
}
}
}
} // namespace esphome::sx126x::testing
namespace esphome::sx126x::testing {
TEST(SX126xProfile, SettersDoNotChangeAppliedPacketOrRestorationPolicy) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.set_payload_length(2);
hw.radio.set_rx_start(false);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_TRUE(hw.receiving);
hw.radio.configure();
EXPECT_EQ(hw.radio.get_max_packet_size(), 2u);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_INVALID_PARAMS);
EXPECT_EQ(hw.radio.transmit_packet(hw.packet.data(), 2), SX126xError::SX126X_ERROR_NONE);
EXPECT_FALSE(hw.receiving);
}
TEST(SX126xProfile, RejectsOversizedSyncValueBeforeResetOrSpi) {
RadioHardware hw;
hw.configure(true, true);
hw.radio.set_sync_value(std::vector<uint8_t>(9, 1));
hw.clear_trace();
const auto reset_writes = hw.rst.writes;
hw.radio.configure();
EXPECT_EQ(hw.rst.writes, reset_writes);
EXPECT_TRUE(hw.transactions.empty());
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.radio.set_sync_value({0x14, 0x24});
hw.radio.configure();
EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NONE);
}
} // namespace esphome::sx126x::testing
#ifdef USE_HOST
namespace esphome::sx126x::testing {
template<typename F> static std::string capture_radio_logs(F action) {
const auto previous_baud_rate = logger::global_logger->get_baud_rate();
::testing::internal::CaptureStdout();
logger::global_logger->set_baud_rate(115200);
action();
logger::global_logger->set_baud_rate(previous_baud_rate);
return ::testing::internal::GetCapturedStdout();
}
TEST(SX126xDiagnostics, BlockingRejectionsExplainWhyNoPacketWasSent) {
RadioHardware hw;
auto output = capture_radio_logs(
[&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_NOT_READY); });
EXPECT_NE(output.find("Packet send failed: radio not ready"), std::string::npos);
hw.configure(true, true, true);
hw.clear_trace();
output = capture_radio_logs(
[&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet.data(), 2), SX126xError::SX126X_ERROR_INVALID_PARAMS); });
EXPECT_NE(output.find("Packet send failed: invalid payload"), std::string::npos);
EXPECT_TRUE(hw.transactions.empty());
hw.irq = IRQ_RX_DONE;
output =
capture_radio_logs([&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY); });
EXPECT_NE(output.find("Packet send failed: radio busy"), std::string::npos);
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
EXPECT_EQ(hw.sends, 0u);
hw.radio.loop(); // Deliver the held packet, which also clears its IRQ.
EXPECT_EQ(hw.irq, 0u);
hw.clear_trace();
hw.fail_at = 1;
output = capture_radio_logs(
[&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT); });
EXPECT_NE(output.find("Packet send failed: bus timeout"), std::string::npos);
EXPECT_EQ(hw.sends, 0u);
}
TEST(SX126xDiagnostics, AsyncAdmissionLeavesRejectionReportingToItsCaller) {
class Completion final : public SX126xTransmitListener {
public:
void on_transmit_complete(SX126xError result) override { ADD_FAILURE() << "Rejected send completed"; }
} completion;
RadioHardware hw;
const auto output = capture_radio_logs([&]() {
EXPECT_EQ(hw.radio.start_transmit_async(&completion, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
hw.configure(true, true, true);
EXPECT_EQ(hw.radio.start_transmit_async(&completion, hw.packet.data(), 2),
SX126xError::SX126X_ERROR_INVALID_PARAMS);
hw.irq = IRQ_RX_DONE;
EXPECT_EQ(hw.radio.start_transmit_async(&completion, hw.packet), SX126xError::SX126X_ERROR_BUSY);
});
EXPECT_EQ(output.find("Packet send failed"), std::string::npos);
EXPECT_EQ(hw.sends, 0u);
}
TEST(SX126xDiagnostics, FailedSetupPrintsRequestedConfiguration) {
RadioHardware hw;
hw.fail_at = 1;
hw.radio.set_pa_power(10);
hw.configure(true, true, true);
ASSERT_TRUE(hw.radio.is_failed());
const auto output = capture_radio_logs([&]() { hw.radio.dump_config(); });
EXPECT_NE(output.find("Ready for operation: NO"), std::string::npos);
EXPECT_NE(output.find("Requested configuration:"), std::string::npos);
EXPECT_NE(output.find("Frequency: 915000000 Hz"), std::string::npos);
EXPECT_NE(output.find("PA Power: 10 dBm"), std::string::npos);
EXPECT_NE(output.find("Payload Length: 3"), std::string::npos);
EXPECT_NE(output.find("Sync Value: 0x1424"), std::string::npos);
}
TEST(SX126xDiagnostics, StagedConfigurationIsLabelledAndDoesNotAlterAppliedSettings) {
RadioHardware hw;
hw.configure(true, true, true);
hw.radio.set_frequency(868000000);
hw.radio.set_payload_length(2);
auto output = capture_radio_logs([&]() { hw.radio.dump_config(); });
EXPECT_NE(output.find("Ready for operation: YES"), std::string::npos);
EXPECT_NE(output.find("Requested configuration:"), std::string::npos);
EXPECT_NE(output.find("Frequency: 868000000 Hz"), std::string::npos);
EXPECT_NE(output.find("Payload Length: 2"), std::string::npos);
EXPECT_EQ(hw.radio.get_max_packet_size(), 3u);
hw.clear_trace();
hw.fail_at = 1;
hw.radio.configure();
output = capture_radio_logs([&]() { hw.radio.dump_config(); });
EXPECT_NE(output.find("Ready for operation: NO"), std::string::npos);
EXPECT_NE(output.find("Frequency: 868000000 Hz"), std::string::npos);
EXPECT_EQ(hw.radio.get_max_packet_size(), 3u);
}
class SX126xTimeoutDiagnostics : public ::testing::TestWithParam<bool> {};
TEST_P(SX126xTimeoutDiagnostics, BlockingTimeoutLogsFailureAfterRestoration) {
RadioHardware hw;
hw.configure(true, true);
hw.auto_complete = false;
if (GetParam()) {
hw.dio.on_read = [&]() {
if (hw.sends > 0) {
hw.irq = IRQ_RX_TX_TIMEOUT;
}
return hw.irq != 0;
};
}
const auto output =
capture_radio_logs([&]() { EXPECT_EQ(hw.radio.transmit_packet(hw.packet), SX126xError::SX126X_ERROR_TIMEOUT); });
EXPECT_NE(output.find("Packet send failed: timeout"), std::string::npos);
EXPECT_TRUE(hw.receiving);
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_FALSE(hw.radio.status_has_warning()); // Restoration itself succeeded.
}
INSTANTIATE_TEST_SUITE_P(SoftwareDeadlineAndRadioTimeout, SX126xTimeoutDiagnostics, ::testing::Bool());
TEST(SX126xDiagnostics, AsyncTimeoutLeavesCompletionReportingToItsCaller) {
struct Completion final : SX126xTransmitListener {
void on_transmit_complete(SX126xError result) override {
this->count++;
EXPECT_EQ(result, SX126xError::SX126X_ERROR_TIMEOUT);
}
unsigned count{0};
} completion;
RadioHardware hw;
hw.configure(true, true);
const auto output = capture_radio_logs([&]() {
ASSERT_EQ(hw.radio.start_transmit_async(&completion, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.irq = IRQ_RX_TX_TIMEOUT;
hw.radio.loop();
});
EXPECT_EQ(completion.count, 1u);
EXPECT_EQ(output.find("Packet send failed"), std::string::npos);
}
} // namespace esphome::sx126x::testing
#endif
@@ -0,0 +1,182 @@
#include "sx126x_test_helpers.h"
#include "esphome/components/logger/logger.h"
#include "esphome/core/application.h"
#include <string>
#include <tuple>
namespace esphome::sx126x::testing {
class RadioLogCallback {
public:
RadioLogCallback() : previous_(logger::global_logger) {
this->logger_.pre_setup();
this->logger_.set_log_level(ESPHOME_LOG_LEVEL_WARN);
this->logger_.add_log_callback(this,
[](void *instance, uint8_t level, const char *tag, const char *message, size_t len) {
auto *self = static_cast<RadioLogCallback *>(instance);
if (self->callback_) {
self->callback_(std::string(message, len));
}
});
}
~RadioLogCallback() { logger::global_logger = this->previous_; }
void set_callback(std::function<void(const std::string &)> callback) { this->callback_ = std::move(callback); }
private:
std::function<void(const std::string &)> callback_;
logger::Logger *previous_;
logger::Logger logger_{0};
};
struct ReentryCompletion final : SX126xTransmitListener {
void on_transmit_complete(SX126xError result) override { this->results.push_back(result); }
std::vector<SX126xError> results;
};
// Setup reports why it failed, not a generic reason
TEST(SX126xReentry, FailedSetupKeepsItsReason) {
RadioLogCallback log;
std::vector<std::string> messages;
log.set_callback([&](const std::string &message) { messages.push_back(message); });
RadioHardware hw;
hw.supported_version = false;
hw.configure(true, true);
ASSERT_TRUE(hw.radio.is_failed());
bool found = false;
for (const auto &message : messages)
found |= message.find("set Error flag: Unsupported chip version") != std::string::npos;
EXPECT_TRUE(found);
}
// Status logging is the only user code that can run inside a radio operation. It runs only
// after the operation has committed its state, so a callback sees either a running send or a
// released radio, and the interrupted operation touches nothing after the callback returns.
TEST(SX126xReentry, SuccessfulStartRunsNoUserCode) {
RadioHardware hw;
RadioLogCallback log;
ReentryCompletion outer, nested;
hw.configure(true, true);
hw.auto_complete = false;
hw.radio.status_set_warning(LOG_STR("Previous communication failure"));
hw.irq = IRQ_TX_DONE; // A stale IRQ must not complete the new send either.
unsigned callbacks = 0;
log.set_callback([&](const std::string &message) {
if (message.find("cleared Warning flag") != std::string::npos) {
callbacks++;
EXPECT_FALSE(hw.radio.is_transmitting()); // Only the completion clears the warning.
EXPECT_EQ(hw.radio.start_transmit_async(&nested, hw.packet), SX126xError::SX126X_ERROR_NONE);
}
});
ASSERT_EQ(hw.radio.start_transmit_async(&outer, hw.packet), SX126xError::SX126X_ERROR_NONE);
EXPECT_EQ(callbacks, 0u);
EXPECT_TRUE(outer.results.empty());
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
EXPECT_TRUE(outer.results.empty()); // The stale IRQ was cleared before the send.
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
ASSERT_EQ(callbacks, 1u);
EXPECT_EQ(outer.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
EXPECT_TRUE(nested.results.empty());
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_EQ(hw.sends, 2u);
ASSERT_TRUE(hw.radio.cancel_transmit_async(&nested));
EXPECT_EQ(nested.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_CANCELLED}));
EXPECT_EQ(outer.results.size(), 1u);
}
class SX126xStartReentry : public ::testing::TestWithParam<bool> {};
TEST_P(SX126xStartReentry, WarningCallbackAfterFailedStartSeesReleasedRadio) {
const bool cold_sleep = GetParam();
RadioHardware hw;
RadioLogCallback log;
ReentryCompletion outer, nested;
hw.configure(true, true);
hw.auto_complete = false;
hw.radio.status_set_warning(LOG_STR("Previous communication failure"));
hw.clear_trace();
hw.fail_at = 3; // Preparation fails; restoration succeeds and clears the warning.
unsigned callbacks = 0;
size_t commands_after_callback = 0;
log.set_callback([&](const std::string &message) {
if (message.find("cleared Warning flag") != std::string::npos) {
callbacks++;
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_EQ(hw.receiving, true); // Restoration finished before the callback.
if (cold_sleep) {
hw.radio.set_mode_sleep(true);
} else {
EXPECT_EQ(hw.radio.start_transmit_async(&nested, hw.packet), SX126xError::SX126X_ERROR_NONE);
}
commands_after_callback = hw.transactions.size();
}
});
EXPECT_EQ(hw.radio.start_transmit_async(&outer, hw.packet), SX126xError::SX126X_ERROR_BUSY_TIMEOUT);
ASSERT_EQ(callbacks, 1u);
EXPECT_EQ(hw.transactions.size(), commands_after_callback); // The failed start touched nothing after it.
EXPECT_TRUE(outer.results.empty()); // A rejected start never completes.
if (cold_sleep) {
EXPECT_FALSE(hw.radio.is_transmitting());
EXPECT_EQ(hw.radio.start_transmit_async(&outer, hw.packet), SX126xError::SX126X_ERROR_NOT_READY);
EXPECT_EQ(hw.sends, 0u);
return;
}
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_EQ(hw.sends, 1u);
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
EXPECT_EQ(nested.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
EXPECT_TRUE(outer.results.empty());
}
INSTANTIATE_TEST_SUITE_P(NestedSendOrColdSleep, SX126xStartReentry, ::testing::Bool());
class SX126xPollingReentry : public ::testing::TestWithParam<std::tuple<unsigned, bool>> {};
TEST_P(SX126xPollingReentry, WarningCallbackCannotCancelOrDisturbAFinishedSend) {
const auto [failed_command, reuse_listener] = GetParam();
RadioHardware hw;
RadioLogCallback log;
ReentryCompletion first, second;
auto *replacement = reuse_listener ? &first : &second;
hw.configure(true, true);
hw.auto_complete = false;
ASSERT_EQ(hw.radio.start_transmit_async(&first, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.clear_trace();
hw.fail_at = failed_command; // 0: success, 1: IRQ read fails, 2: IRQ clear fails.
hw.irq = IRQ_TX_DONE;
hw.radio.status_set_warning(LOG_STR("Previous communication failure"));
unsigned callbacks = 0;
size_t commands_after_replacement = 0;
log.set_callback([&](const std::string &message) {
if (callbacks == 0 && message.find("cleared Warning flag") != std::string::npos) {
callbacks++;
EXPECT_TRUE(first.results.empty()); // Status is reported before the completion.
EXPECT_FALSE(hw.radio.cancel_transmit_async(&first)); // Already finished: no second completion.
EXPECT_EQ(hw.radio.start_transmit_async(replacement, hw.packet), SX126xError::SX126X_ERROR_NONE);
commands_after_replacement = hw.transactions.size();
}
});
{
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
}
ASSERT_EQ(callbacks, 1u);
const auto expected = failed_command == 0 ? SX126xError::SX126X_ERROR_NONE : SX126xError::SX126X_ERROR_BUSY_TIMEOUT;
EXPECT_EQ(first.results, (std::vector<SX126xError>{expected}));
EXPECT_TRUE(second.results.empty());
EXPECT_TRUE(hw.radio.is_transmitting());
EXPECT_EQ(hw.transactions.size(), commands_after_replacement); // No stale IRQ clear or restoration.
hw.irq = IRQ_TX_DONE;
hw.radio.loop();
ASSERT_EQ(replacement->results.size(), reuse_listener ? 2u : 1u);
EXPECT_EQ(replacement->results.back(), SX126xError::SX126X_ERROR_NONE);
EXPECT_FALSE(hw.radio.is_transmitting());
}
INSTANTIATE_TEST_SUITE_P(ReadSuccessReadFailureAndClearFailure, SX126xPollingReentry,
::testing::Combine(::testing::Values(0u, 1u, 2u), ::testing::Bool()));
} // namespace esphome::sx126x::testing
@@ -0,0 +1,127 @@
#include "sx126x_test_helpers.h"
#include "esphome/core/application.h"
#include <tuple>
namespace esphome::sx126x::testing {
struct RejectedCompletion final : SX126xTransmitListener {
void on_transmit_complete(SX126xError result) override { ADD_FAILURE() << "Rejected transmission completed"; }
};
class SX126xReceiveRetry : public ::testing::TestWithParam<std::tuple<bool, unsigned, bool>> {};
TEST_P(SX126xReceiveRetry, PersistentBusyIsRateLimitedAndRecoversWithoutNewEdge) {
const auto [held_receive, failed_command, rollover] = GetParam();
RadioHardware hw;
PacketListener listener;
RejectedCompletion tx;
hw.configure(true, true);
hw.radio.register_listener(&listener);
hw.radio.call();
hw.dio.internal = true;
if (held_receive) {
auto write = hw.cs.on_write;
hw.cs.on_write = [&](bool high) {
write(high);
if (high && !hw.transactions.back().empty() && hw.transactions.back()[0] == RADIO_SET_STANDBY) {
hw.irq = IRQ_RX_DONE;
}
};
EXPECT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_BUSY);
hw.cs.on_write = write;
}
hw.clear_trace();
hw.irq = IRQ_RX_DONE;
hw.fail_at = failed_command;
const uint32_t start = rollover ? 0xFFFFFFF0u : 1000;
{
LoopBlockingGuard clock{nullptr, nullptr, start};
hw.radio.call();
}
hw.expect_aborted(failed_command);
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
const auto normal_busy = hw.busy.on_read;
hw.busy.on_read = []() { return true; };
for (uint32_t elapsed = 1; elapsed < 200; elapsed++) {
LoopBlockingGuard clock{nullptr, nullptr, start + elapsed};
hw.radio.call();
}
EXPECT_EQ(hw.transactions.size(), failed_command); // No SPI/BUSY waits inside the retry interval.
{
LoopBlockingGuard clock{nullptr, nullptr, start + 200};
hw.radio.call();
}
ASSERT_EQ(hw.transactions.size(), failed_command + 1);
EXPECT_TRUE(hw.transactions.back().empty());
EXPECT_TRUE(hw.radio.is_in_loop_state());
EXPECT_TRUE(hw.cs.level);
EXPECT_EQ(hw.irq, IRQ_RX_DONE);
EXPECT_TRUE(listener.packets.empty());
hw.busy.on_read = normal_busy;
hw.fail_at = 0;
{
LoopBlockingGuard clock{nullptr, nullptr, start + 399};
hw.radio.call();
EXPECT_EQ(hw.transactions.size(), failed_command + 1);
}
{
LoopBlockingGuard clock{nullptr, nullptr, start + 400};
hw.radio.call();
}
ASSERT_EQ(listener.packets.size(), 1u);
EXPECT_EQ(listener.packets.front(), hw.packet);
EXPECT_FALSE(hw.radio.status_has_warning());
EXPECT_TRUE(hw.receiving);
EXPECT_EQ(hw.sends, 0u);
hw.radio.call();
EXPECT_EQ(listener.packets.size(), 1u);
}
INSTANTIATE_TEST_SUITE_P(IdleAndHeldRxWithIrqAndPacketFailuresAcrossRollover, SX126xReceiveRetry,
::testing::Combine(::testing::Bool(), ::testing::Values(1u, 2u), ::testing::Bool()));
TEST(SX126xReceiveRetry, SuccessfulConfigurationClearsBackoffAndOtherRadiosRemainIndependent) {
RadioHardware failed, other;
PacketListener recovered, unaffected;
failed.configure(true, true);
other.configure(true, true);
failed.radio.register_listener(&recovered);
other.radio.register_listener(&unaffected);
failed.clear_trace();
failed.fail_at = 1;
failed.irq = IRQ_RX_DONE;
LoopBlockingGuard clock{nullptr, nullptr, 1000};
failed.radio.loop();
other.irq = IRQ_RX_DONE;
other.radio.loop();
ASSERT_EQ(unaffected.packets.size(), 1u);
EXPECT_TRUE(recovered.packets.empty());
failed.clear_trace();
failed.radio.configure();
failed.irq = IRQ_RX_DONE;
failed.radio.loop();
ASSERT_EQ(recovered.packets.size(), 1u); // Explicit recovery does not wait for the old retry deadline.
}
TEST(SX126xReceiveRetry, ReceiveBackoffDoesNotDelayTransmitCompletion) {
struct Completion final : SX126xTransmitListener {
void on_transmit_complete(SX126xError result) override { this->results.push_back(result); }
std::vector<SX126xError> results;
} tx;
RadioHardware hw;
hw.configure(true, true);
hw.clear_trace();
hw.fail_at = 1;
hw.irq = IRQ_TX_DONE; // Failed read of an unrelated stale IRQ starts the RX retry gate.
LoopBlockingGuard clock{nullptr, nullptr, millis()};
hw.radio.loop();
EXPECT_TRUE(hw.radio.status_has_warning());
hw.clear_trace();
hw.irq = 0;
ASSERT_EQ(hw.radio.start_transmit_async(&tx, hw.packet), SX126xError::SX126X_ERROR_NONE);
hw.radio.loop(); // Same cached tick, before the RX retry gate opens.
EXPECT_EQ(tx.results, (std::vector<SX126xError>{SX126xError::SX126X_ERROR_NONE}));
EXPECT_TRUE(hw.receiving);
}
} // namespace esphome::sx126x::testing
@@ -0,0 +1,237 @@
#pragma once
#include <gtest/gtest.h>
#include <functional>
#include <vector>
#include "esphome/components/sx126x/sx126x.h"
namespace esphome::sx126x::testing {
class TestPin : public GPIOPin {
public:
void setup() override {}
void pin_mode(gpio::Flags flags) override {}
gpio::Flags get_flags() const override { return gpio::FLAG_NONE; }
size_t dump_summary(char *buffer, size_t len) const override { return snprintf(buffer, len, "test"); }
bool digital_read() override {
this->reads++;
return this->on_read ? this->on_read() : this->level;
}
void digital_write(bool level) override {
this->writes++;
this->level = level;
if (this->on_write) {
this->on_write(level);
}
}
bool is_internal() override { return this->internal; }
bool level{false};
bool internal{false};
unsigned reads{0};
unsigned writes{0};
std::function<bool()> on_read;
std::function<void(bool)> on_write;
};
// Exercise the real software SPI adapter and driver through GPIO pins. Each CS
// assertion starts a command; BUSY can reject any one of those commands.
class RadioHardware {
public:
RadioHardware() {
this->cs.level = true;
this->cs.on_write = [this](bool high) {
if (!high) {
EXPECT_FALSE(this->selected_);
this->selected_ = true;
this->transactions.emplace_back();
this->bit_ = 0;
this->byte_ = 0;
this->busy.level = this->transactions.size() == this->fail_at;
} else if (this->selected_) {
this->selected_ = false;
this->ends++;
const auto &command = this->transactions.back();
if (command.empty()) {
return;
}
switch (command[0]) {
case RADIO_SET_PACKETPARAMS:
this->packet_params = command;
break;
case RADIO_CLR_IRQSTATUS:
this->irq &= ~((uint16_t(command[1]) << 8) | command[2]);
break;
case RADIO_SET_TX:
this->sends++;
if (this->auto_complete) {
this->irq |= IRQ_TX_DONE;
}
break;
case RADIO_SET_RX:
this->receiving = true;
break;
case RADIO_SET_SLEEP:
case RADIO_SET_STANDBY:
this->receiving = false;
break;
default:
break;
}
if (this->on_command) {
this->on_command(command[0]);
}
}
};
this->busy.on_read = [this]() {
EXPECT_TRUE(this->selected_); // NSS must wake the chip before waiting.
return this->busy.level;
};
this->miso.on_read = [this]() {
EXPECT_TRUE(this->selected_);
EXPECT_FALSE(this->busy.level); // No clocks/data may follow a BUSY timeout.
auto &command = this->transactions.back();
const bool bit = (this->reply_byte_(command) >> (7 - this->bit_)) & 1;
this->byte_ = (this->byte_ << 1) | this->mosi.level;
if (++this->bit_ == 8) {
command.push_back(this->byte_);
this->byte_ = 0;
this->bit_ = 0;
}
return bit;
};
this->dio.on_read = [this]() { return this->irq != 0; };
this->rst.on_write = [this](bool high) {
if (!high) {
this->irq = 0;
}
};
this->spi.set_clk(&this->clk);
this->spi.set_mosi(&this->mosi);
this->spi.set_miso(&this->miso);
// call() runs setup() and marks the bus set up, so it accepts devices
this->spi.call();
this->radio.set_spi_parent(&this->spi);
this->radio.set_cs_pin(&this->cs);
this->radio.set_busy_pin(&this->busy);
this->radio.set_dio1_pin(&this->dio);
this->radio.set_rst_pin(&this->rst);
this->radio.set_frequency(915000000);
this->radio.set_tcxo_voltage(TCXO_CTRL_1_8V);
this->radio.set_rf_switch(true);
this->radio.set_crc_enable(true);
this->radio.set_sync_value({0x14, 0x24});
}
~RadioHardware() {
// The configuration retry interval (id 0) must not outlive the radio
App.scheduler.cancel_interval(&this->radio, uint32_t{0});
this->radio.spi_teardown();
}
void configure(bool lora, bool rx, bool fixed = false) {
this->radio.set_modulation(lora ? PACKET_TYPE_LORA : PACKET_TYPE_GFSK);
this->radio.set_bandwidth(lora ? SX126X_BW_125000 : SX126X_BW_4800);
this->radio.set_bitrate(4800);
this->radio.set_rx_start(rx);
this->radio.set_payload_length(fixed ? 3 : 0);
this->radio.call(); // The normal component lifecycle runs setup().
}
void clear_trace() {
this->transactions.clear();
this->ends = 0;
this->fail_at = 0;
this->busy.level = false;
this->dio.reads = 0;
}
void expect_aborted(size_t command) {
ASSERT_EQ(this->transactions.size(), command);
EXPECT_TRUE(this->transactions.back().empty());
EXPECT_EQ(this->ends, command);
EXPECT_TRUE(this->cs.level);
}
SX126x radio;
spi::SPIComponent spi;
TestPin cs, busy, dio, rst, clk, mosi, miso;
std::vector<std::vector<uint8_t>> transactions;
std::vector<uint8_t> packet{0x11, 0x22, 0x33};
std::vector<uint8_t> packet_params;
size_t fail_at{0};
size_t ends{0};
unsigned sends{0};
uint16_t irq{0};
// Called as each command ends, with its opcode
std::function<void(uint8_t)> on_command;
bool receiving{false};
bool auto_complete{true};
bool supported_version{true};
protected:
uint8_t reply_byte_(const std::vector<uint8_t> &command) {
if (command.empty()) {
return 0;
}
const size_t pos = command.size();
switch (command[0]) {
case RADIO_READ_REGISTER:
if (pos >= 4) {
static constexpr char VERSION[16] = "SX1262";
return this->supported_version ? VERSION[pos - 4] : 0;
}
break;
case RADIO_GET_IRQSTATUS:
if (pos == 2) {
return this->irq >> 8;
}
if (pos == 3) {
return this->irq & 0xFF;
}
break;
case RADIO_GET_PACKETSTATUS:
if (pos == 2) {
return 80;
}
if (pos == 3) {
return 40;
}
if (pos == 4) {
return 84;
}
break;
case RADIO_GET_RXBUFFERSTATUS:
if (pos == 2) {
return this->packet.size();
}
if (pos == 3) {
return 7;
}
break;
case RADIO_READ_BUFFER:
if (pos >= 3) {
EXPECT_EQ(command[1], 7);
return this->packet[pos - 3];
}
break;
default:
break;
}
return 0;
}
bool selected_{false};
uint8_t bit_{0};
uint8_t byte_{0};
};
class PacketListener : public SX126xListener {
public:
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override {
this->packets.push_back(packet);
this->rssi = rssi;
this->snr = snr;
}
std::vector<std::vector<uint8_t>> packets;
float rssi{};
float snr{};
};
} // namespace esphome::sx126x::testing
+2 -1
View File
@@ -10,7 +10,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.auto_load = ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# Compiles in the Hisense ARG model, which climate.py adds only when it is configured
# Compiles in the Hisense ARG and Hitachi models, which climate.py adds only when configured
cg.add_define("USE_WHIRLPOOL_HISENSE_ARG")
cg.add_define("USE_WHIRLPOOL_HITACHI_HCRA31NEWH")
manifest.to_code = to_code_testing
+5
View File
@@ -8,3 +8,8 @@ climate:
model: HISENSE_ARG
transmitter_id: xmitr
receiver_id: rcvr
- platform: whirlpool
name: Whirlpool Hitachi HCRA31NEWH Climate
model: HITACHI_HCRA31NEWH
transmitter_id: xmitr
receiver_id: rcvr
@@ -252,6 +252,9 @@ TEST(WhirlpoolHisenseArg, SwingIsSentOnEveryTransmit) {
TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already off";
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0x80);
@@ -261,6 +264,9 @@ TEST(WhirlpoolDG11J13A, SwingIsSentOnlyWhenItChanges) {
frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x80, 0);
EXPECT_EQ(frame[8], 0x00);
f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_VERTICAL);
EXPECT_EQ(f.last_frame()[2] & 0x80, 0) << "swing is already vertical";
}
// Cool 24 C (step 8), fan low, powered on assumed
@@ -385,4 +391,90 @@ TEST(WhirlpoolHisenseArg, EveryTransmitHasValidChecksums) {
}
}
// Cool 24 C (step 8), fan low, unit on
static Frame hitachi_cool_frame() {
return with_checksums({0xA9, 0x59, 0x03, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x38, 0x08, 0x00});
}
// The off frame: power bit, cool code, byte 15 0x01 and byte 18 0x28
static Frame hitachi_off_frame() {
return with_checksums({0xA9, 0x59, 0x07, 0x82, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x20,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x28, 0x08, 0x00});
}
TEST(WhirlpoolHitachiHCRA31NEWH, CoolAndOffMatchKnownFrames) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_LOW);
// Turning on sends the full state with no power bit
expect_frame(f.last_frame(), hitachi_cool_frame());
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
expect_frame(f.last_frame(), hitachi_off_frame());
// A second off command still sends the power bit
f.command(climate::CLIMATE_MODE_OFF, 24, climate::CLIMATE_FAN_LOW);
EXPECT_EQ(f.last_frame()[2] & 0x04, 0x04);
f.command(climate::CLIMATE_MODE_HEAT, 24, climate::CLIMATE_FAN_LOW);
const Frame frame = f.last_frame();
EXPECT_EQ(frame[2] & 0x04, 0);
EXPECT_EQ(frame[3], 0x80);
EXPECT_EQ(frame[15], 0x06);
EXPECT_EQ(frame[18], 0x38);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivePowerIsAState) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// An on frame restores the mode even though it has no power bit
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_LOW);
// The same frame again keeps it on
EXPECT_TRUE(receive(f.climate, hitachi_cool_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL);
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
// A repeated off frame does not toggle
EXPECT_TRUE(receive(f.climate, hitachi_off_frame()));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveChecksRemoteId) {
Fixture hitachi(MODEL_HITACHI_HCRA31NEWH);
EXPECT_FALSE(receive(hitachi.climate, hisense_cool_frame()));
Fixture whirlpool(MODEL_DG11J1_91);
EXPECT_FALSE(receive(whirlpool.climate, hitachi_cool_frame()));
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceivedTemperatureIsClamped) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
Frame frame = hitachi_cool_frame();
frame[3] = 0xF2; // step 15 would be 31 C
EXPECT_TRUE(receive(f.climate, with_checksums(frame)));
EXPECT_FLOAT_EQ(f.climate.target_temperature, 30);
}
TEST(WhirlpoolDG11J13A, TargetIsClampedAndRoundedToWhatWasSent) {
Fixture f(MODEL_DG11J1_3A);
f.command(climate::CLIMATE_MODE_COOL, 35, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 32);
f.command(climate::CLIMATE_MODE_COOL, 24.4f, climate::CLIMATE_FAN_LOW);
EXPECT_FLOAT_EQ(f.climate.target_temperature, 24);
}
TEST(WhirlpoolHitachiHCRA31NEWH, ReceiveRejectsShortFrame) {
Fixture f(MODEL_HITACHI_HCRA31NEWH);
remote_base::RawTimings raw = waveform(hitachi_cool_frame());
// Cut after the second divider: 14 bytes, no footer
raw.resize(2 + 14 * 16 + 2 * 2);
remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
EXPECT_FALSE(static_cast<remote_base::RemoteReceiverListener &>(f.climate).on_receive(data));
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF);
}
} // namespace esphome::whirlpool::testing
@@ -0,0 +1,23 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_NAME
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
MULTI_CONF = True
reboot_hook_recorder_ns = cg.esphome_ns.namespace("reboot_hook_recorder")
RebootHookRecorder = reboot_hook_recorder_ns.class_("RebootHookRecorder", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(RebootHookRecorder),
cv.Required(CONF_NAME): cv.string,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_name(config[CONF_NAME]))
@@ -0,0 +1,10 @@
#include "reboot_hook_recorder.h"
#include "esphome/core/log.h"
namespace esphome::reboot_hook_recorder {
ESPHOME_LOG_TAG(TAG, "reboot_hook_recorder");
void RebootHookRecorder::record_(const char *hook) { ESP_LOGI(TAG, "HOOK %s %s", this->name_, hook); }
} // namespace esphome::reboot_hook_recorder
@@ -0,0 +1,33 @@
#pragma once
#include "esphome/core/component.h"
namespace esphome::reboot_hook_recorder {
/// Logs every shutdown lifecycle hook it receives, so a test can check their order.
class RebootHookRecorder : public Component {
public:
void set_name(const char *name) { this->name_ = name; }
void on_safe_shutdown() override { this->record_("on_safe_shutdown"); }
void on_shutdown() override { this->record_("on_shutdown"); }
// Needs a second call to finish, like a component waiting for a connection to close
bool teardown() override {
if (++this->teardown_calls_ < 2) {
this->record_("teardown_pending");
return false;
}
if (this->teardown_calls_ == 2)
this->record_("teardown_done");
return true;
}
void on_powerdown() override { this->record_("on_powerdown"); }
protected:
void record_(const char *hook);
const char *name_{""};
uint8_t teardown_calls_{0};
};
} // namespace esphome::reboot_hook_recorder
@@ -0,0 +1,19 @@
esphome:
name: reboot-hooks-forced
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
@@ -0,0 +1,19 @@
esphome:
name: reboot-hooks-safe
host:
logger:
level: DEBUG
api:
# Reboot once the test has connected, so that it sees every hook
on_client_connected:
- delay: 100ms
- lambda: App.safe_reboot();
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
reboot_hook_recorder:
- name: first
- name: second
@@ -0,0 +1,80 @@
"""Test the order of the shutdown lifecycle hooks on a forced and on a safe reboot."""
import re
import pytest
from .log_utils import LineWaiter
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
HOOK_PATTERN = re.compile(r"HOOK (\w+) (\w+)")
# Hooks run in reverse component order, so "first" gets each hook last.
LAST_HOOK = "HOOK first on_powerdown"
async def _record_reboot_hooks(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> list[tuple[str, str]]:
"""Connect, which makes the device reboot, and return its (component, hook) calls in order."""
waiter = LineWaiter()
hooks: list[tuple[str, str]] = []
def on_line(line: str) -> None:
if match := HOOK_PATTERN.search(line):
hooks.append((match[1], match[2]))
waiter.callback(line)
async with (
run_compiled(yaml_config, line_callback=on_line),
api_client_connected(),
):
await wait_or_fail(
waiter.wait_for(LAST_HOOK),
5.0,
"Device did not run the powerdown hooks",
hooks=hooks,
)
return hooks
@pytest.mark.asyncio
async def test_reboot_hook_order_forced(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A forced reboot runs on_shutdown then on_powerdown, with no safe shutdown or teardown."""
hooks = await _record_reboot_hooks(yaml_config, run_compiled, api_client_connected)
assert hooks == [
("second", "on_shutdown"),
("first", "on_shutdown"),
("second", "on_powerdown"),
("first", "on_powerdown"),
]
@pytest.mark.asyncio
async def test_reboot_hook_order_safe(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A safe reboot powers down only after every component has finished its teardown."""
hooks = await _record_reboot_hooks(yaml_config, run_compiled, api_client_connected)
assert hooks == [
("second", "on_safe_shutdown"),
("first", "on_safe_shutdown"),
("second", "on_shutdown"),
("first", "on_shutdown"),
("second", "teardown_pending"),
("first", "teardown_pending"),
("second", "teardown_done"),
("first", "teardown_done"),
("second", "on_powerdown"),
("first", "on_powerdown"),
]
+22 -3
View File
@@ -1,15 +1,34 @@
"""Tests for the memory analyzer."""
import subprocess
from unittest.mock import patch
from esphome.analyze_memory import MemoryAnalyzer
def make_analyzer(external_components: set[str] | None = None) -> MemoryAnalyzer:
"""Create a MemoryAnalyzer through its real constructor, without an ELF or a toolchain."""
def make_analyzer[T: MemoryAnalyzer](
external_components: set[str] | None = None, cls: type[T] = MemoryAnalyzer
) -> T:
"""Create an analyzer through its real constructor, without an ELF or a toolchain."""
with (
patch("esphome.analyze_memory.resolve_tool_path", return_value=None),
patch("esphome.analyze_memory.find_tool", return_value="nm"),
):
# Any existing file passes the ELF check; nothing reads it
return MemoryAnalyzer(__file__, external_components=external_components)
return cls(__file__, external_components=external_components)
def completed(stdout: str) -> subprocess.CompletedProcess[str]:
"""A successful tool run printing ``stdout``."""
return subprocess.CompletedProcess([], 0, stdout=stdout)
def hex_dump(section: str, data: bytes, address: int = 0) -> str:
"""Format bytes the way ``readelf -x`` does."""
lines = [f"Hex dump of section '{section}':"]
for start in range(0, len(data), 16):
chunk = data[start : start + 16]
words = " ".join(chunk[i : i + 4].hex() for i in range(0, len(chunk), 4))
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
lines.append(f" 0x{address + start:08x} {words:<35} {text}")
return "\n".join(lines) + "\n\n"
@@ -1,5 +1,7 @@
"""Tests for source-file-to-component attribution in memory analyzer."""
import pytest
from . import make_analyzer
@@ -30,3 +32,19 @@ def test_source_file_to_component_known_component() -> None:
"src/esphome/components/wifi/wifi_component.cpp.o"
)
assert result == "[esphome]wifi"
@pytest.mark.parametrize(
("rel_path", "component"),
[
(
"lib/esp32async__ESPAsyncWebServer/WebHandlers.cpp.o",
"[lib]espasyncwebserver",
),
("lib/ESP8266WiFi/ESP8266WiFi.cpp.o", "[lib]esp8266wifi"),
("core/core_esp8266_main.cpp.o", "arduino_core"),
],
)
def test_native_library_objects(rel_path: str, component: str) -> None:
"""Objects of the native ESP8266 build's libraries and core get a component."""
assert make_analyzer()._source_file_to_component(rel_path) == component
@@ -0,0 +1,373 @@
"""Tests for attributing ESP8266 string literals in RAM to components."""
from pathlib import Path
import re
import subprocess
from unittest.mock import patch
import pytest
from esphome.analyze_memory import (
MemorySection,
StringLiteral,
_parse_string_sections,
_ram_string_size,
)
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
from . import completed, hex_dump, make_analyzer
WEB_OBJ = "src/esphome/components/web_server/web_server.cpp.o"
LIGHT_OBJ = "src/esphome/components/light/light_json_schema.cpp.o"
COVER_JSON = "_ZN7esphome10web_server9WebServer11cover_json_Ev"
MATCH_URL = "_ZN7esphome10web_serverL9match_urlEPKcjbb"
DUMP_JSON = "_ZN7esphome5light15LightJSONSchema9dump_jsonEv"
_SECTIONS = """\
File: {web}
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 7] .rodata PROGBITS 00000000 000040 000008 00 A 0 0 4
[209] .rodata.str1.1 PROGBITS 00000000 000598 00000a 01 AMS 0 0 1
[481] .rodata.{cover_json}.str1.1 PROGBITS 00000000 003910 00000c 01 AMS 0 0 1
File: {light}
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 30] .rodata.{dump_json}.str1.1 PROGBITS 00000000 000100 000007 01 AMS 0 0 1
"""
def _sections(web: str, light: str) -> str:
"""``readelf -SW`` output for the two objects."""
return _SECTIONS.format(
web=web, light=light, cover_json=COVER_JSON, dump_json=DUMP_JSON
)
# "OPEN\0CLOSED\0" for cover_json_, "ON\0all\0none\0" shared by match_url
WEB_STRINGS = (
f"""\
Relocation section '.rela.text.{MATCH_URL}' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000004 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
"""
+ hex_dump(".rodata.str1.1", b"ON\0all\0none\0")
+ hex_dump(f".rodata.{COVER_JSON}.str1.1", b"OPEN\0CLOSED\0")
)
# The literal pool word of match_url holds the literal's offset, 3 ("all")
WEB_REFERRERS = hex_dump(f".text.{MATCH_URL}", bytes(4) + (3).to_bytes(4, "little"))
LIGHT_STRINGS = hex_dump(f".rodata.{DUMP_JSON}.str1.1", b"ON\0OFF\0")
# Linked .rodata: "OPEN" and "ON" were dropped
ELF_RODATA = hex_dump(".rodata", b"OFF\0CLOSED\0all\0", 0x3FFE86B0)
def test_parse_string_sections() -> None:
"""Only string literal sections are listed, named after their function or not."""
assert _parse_string_sections(_sections("web.o", "light.o")) == {
"web.o": {
"209": (".rodata.str1.1", None),
"481": (f".rodata.{COVER_JSON}.str1.1", COVER_JSON),
},
"light.o": {"30": (f".rodata.{DUMP_JSON}.str1.1", DUMP_JSON)},
}
@pytest.mark.parametrize(
("text", "expected"),
[
(b"OFF", 4),
(b"MON", 4),
# Stored only as the tail of "MON"
(b"ON", 0),
(b"missing", 0),
# After a float constant ending in 0x3f, not inside another literal
(b"all", 4),
],
)
def test_ram_string_size(text: bytes, expected: int) -> None:
"""A literal stored only as the tail of a longer one, or dropped, takes no RAM."""
rodata = b"MON\0OFF\0CLOSED\0\x00\x00\x80\x3fall\0"
literals = {b"MON", b"OFF", b"ON", b"CLOSED", b"all", b"missing"}
assert _ram_string_size(text, rodata, literals) == expected
def test_ram_string_size_tail_after_other_data() -> None:
"""A tail of a literal that follows binary data is still only a tail."""
rodata = b"\x00\x00\x80\x3fMON\0"
assert _ram_string_size(b"MON", rodata, {b"MON", b"ON"}) == 4
assert _ram_string_size(b"ON", rodata, {b"MON", b"ON"}) == 0
def test_string_literals_missing_rodata_warns(
obj_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Without the ELF's .rodata dump the report says why strings are missing."""
analyzer = _new_analyzer(obj_dir, {COVER_JSON})
with patch("esphome.analyze_memory.run_tool", return_value=completed("")):
analyzer._attribute_string_literals(
_sections(str(obj_dir / WEB_OBJ), str(obj_dir / LIGHT_OBJ)), obj_dir
)
assert analyzer._string_literals == []
assert "No .rodata dump" in caplog.text
@pytest.fixture
def obj_dir(tmp_path: Path) -> Path:
for rel in (WEB_OBJ, LIGHT_OBJ, "src/main.cpp.o"):
path = tmp_path / "obj" / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
return tmp_path / "obj"
def _new_analyzer(obj_dir: Path, kept: set[str]) -> MemoryAnalyzerCLI:
analyzer = make_analyzer(cls=MemoryAnalyzerCLI)
analyzer.elf_path = obj_dir.parent / "firmware.elf"
analyzer.rodata_in_ram = True
analyzer.sections[".data"] = MemorySection(".data", total_size=1000)
analyzer._elf_symbol_names = kept
# Unnamed constants have their own tests
analyzer._attribute_unnamed_constants = lambda *_: None
return analyzer
def _analyze(obj_dir: Path, kept: set[str]) -> MemoryAnalyzerCLI:
analyzer = _new_analyzer(obj_dir, kept)
light = str(obj_dir / LIGHT_OBJ)
sections = _sections(str(obj_dir / WEB_OBJ), light)
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
if "-SW" in cmd:
return completed(sections)
if cmd[-1].endswith("firmware.elf"):
return completed(ELF_RODATA)
if cmd[-1] == light:
return completed(LIGHT_STRINGS)
if "-rW" not in cmd:
assert cmd[1:-1] == ["-x", f".text.{MATCH_URL}"]
return completed(WEB_REFERRERS)
return completed(WEB_STRINGS)
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
analyzer._analyze_object_rodata()
return analyzer
def test_string_literals_attributed(obj_dir: Path) -> None:
"""Each literal is counted once, with the bytes it takes in the linked .rodata."""
analyzer = _analyze(obj_dir, {COVER_JSON, MATCH_URL, DUMP_JSON})
assert analyzer._string_literals == [
StringLiteral("CLOSED", 7, {"[esphome]web_server": {COVER_JSON}}),
StringLiteral("OFF", 4, {"[esphome]light": {DUMP_JSON}}),
StringLiteral("all", 4, {"[esphome]web_server": {MATCH_URL}}),
]
# "OPEN" and "ON" are not in the ELF
assert analyzer.components["[esphome]web_server"].data_size == 11
assert analyzer.components["[esphome]light"].data_size == 4
assert analyzer.sections[".data"].symbol_size == 15
def test_string_literals_skip_dropped_functions(obj_dir: Path) -> None:
"""Literals of functions the linker dropped are not charged."""
analyzer = _analyze(obj_dir, {DUMP_JSON})
assert [s.text for s in analyzer._string_literals] == ["OFF"]
def test_string_literals_shared_between_components(obj_dir: Path) -> None:
"""A literal two components use is listed under both and charged to neither."""
analyzer = _new_analyzer(obj_dir, {COVER_JSON, DUMP_JSON})
with (
patch.object(
analyzer,
"_scan_object_strings",
side_effect=[{b"OFF": {COVER_JSON}}, {b"OFF": {DUMP_JSON}}],
),
patch("esphome.analyze_memory.run_tool", return_value=completed(ELF_RODATA)),
):
analyzer._attribute_string_literals(
_sections(str(obj_dir / WEB_OBJ), str(obj_dir / LIGHT_OBJ)), obj_dir
)
(literal,) = analyzer._string_literals
assert literal.users == {
"[esphome]web_server": {COVER_JSON},
"[esphome]light": {DUMP_JSON},
}
assert analyzer.sections[".data"].symbol_size == 0
analyzer._demangle_cache = {COVER_JSON: "cover_json_()", DUMP_JSON: "dump_json()"}
report = analyzer.generate_report()
# Each component's row names its own user
assert re.search(r'"OFF" +cover_json_\(\)', report)
assert re.search(r'"OFF" +dump_json\(\)', report)
def test_report_groups_by_component() -> None:
"""Strings are listed by component with the function that uses them."""
analyzer = make_analyzer(cls=MemoryAnalyzerCLI)
analyzer._demangle_cache = {
"setup": "setup",
COVER_JSON: "WebServer::cover_json_()",
}
analyzer._string_literals = [
StringLiteral("hunter2", 8, {"[esphome]core": {"setup"}}),
StringLiteral("CLOSED", 7, {"[esphome]web_server": {COVER_JSON}}),
]
report = analyzer.generate_report()
assert "String Literals in RAM (.rodata)" in report
assert "[esphome]core (8 B, 1 strings):" in report
assert '"hunter2"' in report
assert '"CLOSED"' in report
assert "WebServer::cover_json_()" in report
def test_string_literals_through_unnamed_table() -> None:
"""Literals reached through an unnamed pointer table go to the table's users."""
analyzer = make_analyzer()
relocations = """\
Relocation section '.rela.text.setup' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
Relocation section '.rela.rodata' at offset 0xe6fc contains 2 entries:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
00000004 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
""" + hex_dump(".rodata.str1.1", b"0.pool.ntp.org\0001.pool.ntp.org\0")
table = hex_dump(".rodata", (0).to_bytes(4, "little") + (15).to_bytes(4, "little"))
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
if "-rW" in cmd:
return completed(relocations)
assert cmd[1:-1] == ["-x", ".rodata"]
return completed(table)
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
users = analyzer._scan_object_strings(
"main.cpp.o", {"5": (".rodata.str1.1", None)}
)
assert users == {b"0.pool.ntp.org": {"setup"}, b"1.pool.ntp.org": {"setup"}}
@pytest.mark.parametrize(
("symbols", "expected"),
[
# The light object's weak copy is replaced by the web object's strong one
([("light", "WEAK"), ("web", "GLOBAL")], {"[esphome]web_server"}),
# Same-named local functions: neither object can be told to be the linked one
([("light", "LOCAL"), ("web", "LOCAL")], set()),
],
)
def test_string_literals_skip_unlinked_copies(
obj_dir: Path, symbols: list[tuple[str, str]], expected: set[str]
) -> None:
"""A user whose linked copy may be another object's does not make a literal shared."""
analyzer = _new_analyzer(obj_dir, {"_Z3foov"})
paths = {"web": str(obj_dir / WEB_OBJ), "light": str(obj_dir / LIGHT_OBJ)}
output = _sections(paths["web"], paths["light"]) + "".join(
f"File: {paths[obj]}\n 1: 00000000 64 FUNC {bind:<6} DEFAULT 12 _Z3foov\n"
for obj, bind in symbols
)
with (
patch.object(
analyzer,
"_scan_object_strings",
side_effect=lambda *_: {b"OFF": {"_Z3foov"}},
),
patch("esphome.analyze_memory.run_tool", return_value=completed(ELF_RODATA)),
):
analyzer._attribute_string_literals(output, obj_dir)
assert {c for s in analyzer._string_literals for c in s.users} == expected
def test_unnamed_table_and_its_strings_charged_once(tmp_path: Path) -> None:
"""Both passes together: the pointer table and each literal are charged once."""
obj = tmp_path / "obj" / "src" / "main.cpp.o"
obj.parent.mkdir(parents=True)
obj.touch()
analyzer = make_analyzer(cls=MemoryAnalyzerCLI)
analyzer.elf_path = tmp_path / "firmware.elf"
analyzer.rodata_in_ram = True
analyzer.sections[".data"] = MemorySection(".data", total_size=1000)
analyzer._elf_symbol_names = {"setup"}
names = b"0.pool.ntp.org\0001.pool.ntp.org\0002.pool.ntp.org\0"
table = b"".join(offset.to_bytes(4, "little") for offset in (0, 15, 30))
headers = f"""\
File: {obj}
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 5] .rodata PROGBITS 00000000 000040 00000c 00 A 0 0 4
[ 6] .rodata.str1.1 PROGBITS 00000000 000050 00002d 01 AMS 0 0 1
Symbol table '.symtab' contains 2 entries:
Num: Value Size Type Bind Vis Ndx Name
1: 00000000 64 FUNC GLOBAL DEFAULT 2 setup
"""
relocations = """\
Relocation section '.rela.text.setup' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
Relocation section '.rela.rodata' at offset 0x200 contains 3 entries:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
00000004 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
00000008 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
"""
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
args = cmd[1:-1]
if "-SW" in args:
return completed(headers)
if cmd[-1].endswith("firmware.elf"):
return completed(hex_dump(".rodata", table + names, 0x3FFE86B0))
if args == ["-rW", "-x", "5"]:
return completed(relocations + hex_dump(".rodata", table))
if args == ["-rW", "-x", "6"]:
return completed(relocations + hex_dump(".rodata.str1.1", names))
if args == ["-x", ".rodata.str1.1"]:
return completed(hex_dump(".rodata.str1.1", names))
assert args == ["-x", ".rodata"], args
return completed(hex_dump(".rodata", table))
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
analyzer._analyze_object_rodata()
(constant,) = analyzer._unnamed_constants
assert (constant.size, constant.functions) == (12, ["setup"])
assert constant.strings == ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"]
assert [(s.text, s.size, dict(s.users)) for s in analyzer._string_literals] == [
(f"{i}.pool.ntp.org", 15, {"[esphome]core": {"setup"}}) for i in range(3)
]
assert analyzer.components["[esphome]core"].data_size == 12 + 3 * 15
assert analyzer.sections[".data"].symbol_size == 12 + 3 * 15
def test_string_literals_pooled_in_another_function_section() -> None:
"""A literal in one function's section that another function uses names both."""
analyzer = make_analyzer()
section = ".rodata._Z1av.str1.1"
relocations = f"""\
Relocation section '.rela.text._Z1bv' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000004 00000501 R_XTENSA_32 00000000 {section} + 0
""" + hex_dump(section, b"ON\0OFF\0")
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
if "-rW" in cmd:
return completed(relocations)
assert cmd[1:-1] == ["-x", ".text._Z1bv"]
return completed(hex_dump(".text._Z1bv", bytes(4) + (3).to_bytes(4, "little")))
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
users = analyzer._scan_object_strings("a.o", {"7": (section, "_Z1av")})
assert users == {b"ON": {"_Z1av"}, b"OFF": {"_Z1av", "_Z1bv"}}
@@ -0,0 +1,675 @@
"""Tests for ESP8266 .rodata accounting and unnamed constant attribution."""
from collections.abc import Callable
from pathlib import Path
import subprocess
from unittest.mock import patch
import pytest
from esphome.analyze_memory import (
_CODE_SECTION_PATTERN,
MemoryAnalyzer,
MemorySection,
UnnamedConstant,
_FunctionDefinitions,
_parse_unnamed_rodata,
_pointed_literal,
_RodataSection,
)
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
from . import completed, hex_dump, make_analyzer
LIGHT_OBJ = "src/esphome/components/light/light_state.cpp.o"
WEB_OBJ = "src/esphome/components/web_server/web_server.cpp.o"
SETUP = "_ZN7esphome5light10LightState5setupEv"
SAVE = "_ZN7esphome5light10LightState19save_remote_values_Ev"
MATCH_URL = "_ZN7esphome10web_serverL9match_urlEPKcjbb"
# readelf -SW -sW over all objects; the web section index and size vary per test
HEADERS_AND_SYMBOLS = """\
File: {light}
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[124] .rodata._ZN7esphome5light10LightStateC2EPNS0_11LightOutputE.str1.1 PROGBITS 00000000 00067d 000001 01 AMS 0 0 1
[174] .rodata PROGBITS 00000000 00139c 00002c 00 A 0 0 4
[221] .rodata.str1.1 PROGBITS 00000000 001f39 000005 01 AMS 0 0 1
[225] .rodata.BUILTIN_LED PROGBITS 00000000 001f54 000004 00 A 0 0 4
Symbol table '.symtab' contains 348 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
300: 00000000 64 FUNC {bind:<6} DEFAULT 12 {setup}
301: 00000000 64 FUNC {bind:<6} DEFAULT 13 {save}
347: 00000000 4 OBJECT WEAK DEFAULT 225 BUILTIN_LED
File: {web}
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[{web_index:>3}] .rodata PROGBITS 00000000 000400 {web_size:06x} 00 A 0 0 4
Symbol table '.symtab' contains 1 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
{web_symbols}"""
LIGHT_OBJECT_DUMP = f"""\
Relocation section '.rela.text.{SETUP}' at offset 0x7928 contains 2 entries:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
00000094 00003b14 R_XTENSA_SLOT0_OP 00000000 .text.{SETUP} + 82
Relocation section '.rela.text.{SAVE}' at offset 0x7d28 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
Relocation section '.rela.xt.prop' at offset 0x8000 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
000007d4 00003a01 R_XTENSA_32 00000000 .rodata + 0
Hex dump of section '.rodata':
0x00000000 0000803f 0000803f 0000803f 0000803f ...?...?...?...?
0x00000010 0000803f 0000803f 0000803f 0000803f ...?...?...?...?
0x00000020 0000803f 00000000 00000000 ...?........
"""
WEB_OBJECT_DUMP = f"""\
Relocation section '.rela.text.{MATCH_URL}' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
Hex dump of section '.rodata':
0x00000000 00000000 00000000 00000000 00000000 ................
"""
@pytest.fixture
def obj_dir(tmp_path: Path) -> Path:
"""Native ESP8266 build layout: objects under obj/, ELF next to it."""
for rel in (LIGHT_OBJ, WEB_OBJ, "src/main.cpp.o"):
path = tmp_path / "obj" / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
return tmp_path / "obj"
def _esp8266_analyzer(
obj_dir: Path,
kept: set[str],
web_index: int = 9,
web_size: int = 0x1C,
web_dump: str = WEB_OBJECT_DUMP,
bind: str = "GLOBAL",
web_symbols: str = "",
) -> tuple[MemoryAnalyzer, Callable[..., subprocess.CompletedProcess[str]]]:
analyzer = make_analyzer()
analyzer.elf_path = obj_dir.parent / "firmware.elf"
analyzer.rodata_in_ram = True
analyzer.sections[".data"] = MemorySection(".data", total_size=1000)
analyzer._elf_symbol_names = kept
# String literals have their own tests
analyzer._attribute_string_literals = lambda *_: None
light = str(obj_dir / LIGHT_OBJ)
web = str(obj_dir / WEB_OBJ)
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
if "-SW" in cmd:
return completed(
HEADERS_AND_SYMBOLS.format(
light=light,
web=web,
web_index=web_index,
web_size=web_size,
bind=bind,
setup=SETUP,
save=SAVE,
web_symbols=web_symbols,
)
)
# Only sections with uncovered bytes are dumped
if cmd[-1] == light:
assert cmd[1:-1] == ["-rW", "-x", "174"]
return completed(LIGHT_OBJECT_DUMP)
return completed(web_dump)
return analyzer, run_tool
def test_find_object_files_dir_native_layout(obj_dir: Path) -> None:
"""The native ESP8266 build keeps objects under obj/."""
analyzer = make_analyzer()
analyzer.elf_path = obj_dir.parent / "firmware.elf"
assert analyzer._find_object_files_dir() == obj_dir
def test_find_object_files_dir_platformio_layout(tmp_path: Path) -> None:
"""PlatformIO keeps objects next to the ELF."""
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.cpp.o").touch()
analyzer = make_analyzer()
analyzer.elf_path = tmp_path / "firmware.elf"
assert analyzer._find_object_files_dir() == tmp_path
def _parse_sections(code_section: str) -> MemoryAnalyzer:
readelf = f"""\
[ 1] .data PROGBITS 3ffe8000 001000 000668 00 WAX 0 0 16
[ 4] {code_section} PROGBITS 40201010 00b010 06612c 00 WAX 0 0 16
[ 6] .rodata PROGBITS 3ffe86b0 0016b0 000b90 00 A 0 0 16
"""
analyzer = make_analyzer()
with patch(
"esphome.analyze_memory.subprocess.run", return_value=completed(readelf)
):
analyzer._parse_sections()
return analyzer
def test_parse_sections_esp8266_counts_rodata_as_data() -> None:
"""ESP8266 (.irom0.text) copies .rodata to RAM, so it is counted as .data."""
analyzer = _parse_sections(".irom0.text")
assert analyzer.rodata_in_ram
assert ".rodata" not in analyzer.sections
assert analyzer.sections[".data"].total_size == 0x668 + 0xB90
def test_parse_sections_other_platforms_keep_rodata() -> None:
"""Other platforms keep .rodata in flash."""
analyzer = _parse_sections(".flash.text")
assert not analyzer.rodata_in_ram
assert analyzer.sections[".rodata"].total_size == 0xB90
assert analyzer.sections[".data"].total_size == 0x668
def test_unnamed_constants_attributed_to_component(obj_dir: Path) -> None:
"""Unnamed .rodata in a kept function is listed and counted as the component's RAM."""
analyzer, run_tool = _esp8266_analyzer(obj_dir, {SETUP, MATCH_URL})
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
analyzer._analyze_object_rodata()
light, web = analyzer._unnamed_constants
assert light == UnnamedConstant(
size=44,
source_file=LIGHT_OBJ,
component="[esphome]light",
functions=[SAVE, SETUP],
jump_table=False,
data=bytes.fromhex("0000803f") * 4,
users=[SAVE, SETUP],
)
assert (web.size, web.component, web.functions) == (
28,
"[esphome]web_server",
[MATCH_URL],
)
assert analyzer.components["[esphome]light"].data_size == 44
assert analyzer.components["[esphome]web_server"].ram_total == 28
# Attributed bytes no longer count as unattributed RAM
assert analyzer.sections[".data"].symbol_size == 72
def test_unnamed_constants_skip_garbage_collected(obj_dir: Path) -> None:
"""A constant whose functions the linker dropped is not in the firmware."""
analyzer, run_tool = _esp8266_analyzer(obj_dir, {MATCH_URL})
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
analyzer._analyze_object_rodata()
assert [c.component for c in analyzer._unnamed_constants] == ["[esphome]web_server"]
def test_unnamed_constants_skipped_when_rodata_in_flash(obj_dir: Path) -> None:
"""Other platforms keep .rodata in flash, so there is nothing to scan."""
analyzer, run_tool = _esp8266_analyzer(obj_dir, {SETUP, MATCH_URL})
analyzer.rodata_in_ram = False
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool) as mock:
analyzer._analyze_object_rodata()
mock.assert_not_called()
assert analyzer._unnamed_constants == []
@pytest.mark.parametrize(("bind", "expected"), [("WEAK", 1), ("GLOBAL", 2)])
def test_unnamed_constants_inline_copies_counted_once(
obj_dir: Path, bind: str, expected: int
) -> None:
"""Only weak (inline) functions bring the same constant from several objects."""
# The second object emits the same functions, with the same binding
analyzer, run_tool = _esp8266_analyzer(
obj_dir,
{SETUP},
web_index=174,
web_size=0x2C,
web_dump=LIGHT_OBJECT_DUMP,
bind=bind,
web_symbols=(
f" 300: 00000000 64 FUNC {bind:<6} DEFAULT 12 {SETUP}\n"
f" 301: 00000000 64 FUNC {bind:<6} DEFAULT 13 {SAVE}\n"
),
)
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
analyzer._analyze_object_rodata()
assert len(analyzer._unnamed_constants) == expected
assert analyzer.sections[".data"].symbol_size == 44 * expected
def test_parse_symbols_keeps_alias_names() -> None:
"""C1/C2 constructor aliases share an address; both names are in the ELF."""
objdump = """4020b558 g F .irom0.text\t0000007e _ZN7esphome3FooC1Ev
4020b558 g F .irom0.text\t0000007e _ZN7esphome3FooC2Ev
"""
analyzer = make_analyzer()
analyzer.sections[".text"] = MemorySection(".text")
with patch(
"esphome.analyze_memory.subprocess.run", return_value=completed(objdump)
):
analyzer._parse_symbols()
assert {"_ZN7esphome3FooC1Ev", "_ZN7esphome3FooC2Ev"} <= analyzer._elf_symbol_names
assert analyzer.sections[".text"].symbol_size == 0x7E
def test_unnamed_constants_skip_named_bytes() -> None:
"""In a section shared with a named object, only the unnamed bytes are shown."""
analyzer = make_analyzer()
analyzer._elf_symbol_names = {"_Z3fooi"}
dump = """Relocation section '.rela.text._Z3fooi' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 4
Hex dump of section '.rodata':
0x00000000 aaaaaaaa 01020304 ........
"""
section = _RodataSection(".rodata", 8, covered=[(0, 4)])
with patch("esphome.analyze_memory.run_tool", return_value=completed(dump)):
(constant,) = analyzer._scan_object_constants(
"foo.o", {"5": section}, "src/foo.o", "[esphome]foo"
)
assert constant.size == 4
assert constant.data == bytes([1, 2, 3, 4])
def test_unnamed_constants_jump_table() -> None:
"""A .rodata section that points into code is a switch jump table."""
analyzer = make_analyzer()
analyzer._elf_symbol_names = {"_Z3fooi"}
dump = """\
Relocation section '.rela.text._Z3fooi' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
Relocation section '.rela.rodata' at offset 0x200 contains 2 entries:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003b01 R_XTENSA_32 00000000 .text._Z3fooi + 12
00000004 00003b01 R_XTENSA_32 00000000 .text._Z3fooi + 20
"""
with patch("esphome.analyze_memory.run_tool", return_value=completed(dump)):
(constant,) = analyzer._scan_object_constants(
"foo.o", {"5": _RodataSection(".rodata", 8)}, "src/foo.o", "[esphome]foo"
)
assert constant.jump_table
assert constant.functions == ["_Z3fooi"]
def test_report_lists_unnamed_constants() -> None:
"""The report names the component, the object, the functions and the bytes."""
analyzer = make_analyzer(cls=MemoryAnalyzerCLI)
analyzer.rodata_in_ram = True
analyzer._demangle_cache = {SETUP: "esphome::light::LightState::setup()"}
analyzer._unnamed_constants = [
UnnamedConstant(
size=44,
source_file=LIGHT_OBJ,
component="[esphome]light",
functions=[SETUP],
jump_table=False,
data=bytes.fromhex("0000803f") * 4,
)
]
report = analyzer.generate_report()
assert "ESP8266 keeps .rodata in RAM" in report
assert "Unnamed Constants in RAM (.rodata)" in report
assert f"44 B constant [esphome]light ({LIGHT_OBJ})" in report
assert "used by esphome::light::LightState::setup()" in report
assert "bytes: 00 00 80 3f 00 00 80 3f 00 00 80 3f 00 00 80 3f ..." in report
def test_unnamed_constants_resolve_string_pointers() -> None:
"""A constant of pointers to literals lists the strings, so it can be recognized."""
analyzer = make_analyzer()
analyzer._elf_symbol_names = {"setup"}
relocations = """\
Relocation section '.rela.text.setup' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
Relocation section '.rela.rodata' at offset 0xe6fc contains 3 entries:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
00000004 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
00000008 00000501 R_XTENSA_32 00000000 .rodata.str1.1 + 0
Hex dump of section '.rodata':
0x00000000 00000000 0f000000 1e000000 ............
"""
literals = hex_dump(
".rodata.str1.1", b"0.pool.ntp.org\0001.pool.ntp.org\0002.pool.ntp.org\0"
)
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
if "-rW" not in cmd:
assert cmd[1:-1] == ["-x", ".rodata.str1.1"]
return completed(literals)
return completed(relocations)
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
(constant,) = analyzer._scan_object_constants(
"main.cpp.o",
{"5": _RodataSection(".rodata", 12)},
"src/main.cpp.o",
"[esphome]core",
)
assert constant.strings == ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"]
cli = make_analyzer(cls=MemoryAnalyzerCLI)
cli._unnamed_constants = [constant]
assert (
'points to "0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"'
in cli.generate_report()
)
@pytest.mark.parametrize(
("section", "function"),
[
(".text._Z3foov", "_Z3foov"),
(".literal._Z3foov", "_Z3foov"),
(".irom0.text._Z3foov", "_Z3foov"),
(".text.startup._GLOBAL__sub_I_setup", "_GLOBAL__sub_I_setup"),
(".text.unlikely._Z3foov", "_Z3foov"),
(".iram.text._Z3foov", "_Z3foov"),
(".text.hot_path", "hot_path"),
(".text", None),
],
)
def test_code_section_function(section: str, function: str | None) -> None:
"""Placement prefixes and IRAM code still resolve to the function."""
match = _CODE_SECTION_PATTERN.match(section)
assert match is not None
assert match.group(1) == function
def test_unnamed_constants_same_object_not_merged(obj_dir: Path) -> None:
"""Two equal constants of one inline function in one object are both counted."""
analyzer, run_tool = _esp8266_analyzer(obj_dir, {SETUP}, bind="WEAK")
constant = UnnamedConstant(
size=8,
source_file=LIGHT_OBJ,
component="[esphome]light",
functions=[SAVE, SETUP],
jump_table=False,
data=b"",
)
light = str(obj_dir / LIGHT_OBJ)
with (
patch("esphome.analyze_memory.run_tool", side_effect=run_tool),
patch.object(
analyzer,
"_scan_object_constants",
side_effect=lambda path, *_: [constant, constant] if path == light else [],
),
):
analyzer._analyze_object_rodata()
assert len(analyzer._unnamed_constants) == 2
def test_unnamed_constants_jump_table_only_from_unnamed_bytes() -> None:
"""Code pointers in a named table at the start do not make the rest a jump table."""
analyzer = make_analyzer()
analyzer._elf_symbol_names = {"_Z3fooi"}
dump = """\
Relocation section '.rela.text._Z3fooi' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 4
Relocation section '.rela.rodata' at offset 0x200 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003b01 R_XTENSA_32 00000000 .text._Z3fooi + 12
"""
section = _RodataSection(".rodata", 8, covered=[(0, 4)])
with patch("esphome.analyze_memory.run_tool", return_value=completed(dump)):
(constant,) = analyzer._scan_object_constants(
"foo.o", {"5": section}, "src/foo.o", "[esphome]foo"
)
assert not constant.jump_table
def _definitions(rows: dict[str, list[tuple[str, str]]]) -> _FunctionDefinitions:
"""Parse symbol rows (binding, function) per object, as readelf -sW prints them."""
output = "".join(
f"File: {path}\n"
+ "".join(
f" {i}: 00000000 64 FUNC {bind:<6} DEFAULT 12 {name}\n"
for i, (bind, name) in enumerate(symbols)
)
for path, symbols in rows.items()
)
return _parse_unnamed_rodata(output)[1]
def test_linked_copy_unknown_weak_overridden_by_strong() -> None:
"""A strong definition replaces every weak copy, so their constants are not linked."""
definitions = _definitions(
{"a.o": [("WEAK", "_Z3foov")], "b.o": [("GLOBAL", "_Z3foov")]}
)
assert definitions.linked_copy_unknown("a.o", ["_Z3foov"])
assert not definitions.linked_copy_unknown("b.o", ["_Z3foov"])
def test_linked_copy_unknown_weak_partly_overridden() -> None:
"""A constant stays linked while one of its weak users has no strong override."""
definitions = _definitions(
{
"a.o": [("WEAK", "_Z3foov"), ("WEAK", "_Z3barv")],
"b.o": [("GLOBAL", "_Z3foov")],
}
)
assert not definitions.linked_copy_unknown("a.o", ["_Z3barv", "_Z3foov"])
def test_linked_copy_unknown_same_named_locals() -> None:
"""Local functions with one name in two objects can't be told apart in the ELF."""
definitions = _definitions(
{
"a.o": [("LOCAL", "_ZL3barv"), ("LOCAL", "_ZL3bazv")],
"b.o": [("LOCAL", "_ZL3barv")],
}
)
assert definitions.linked_copy_unknown("a.o", ["_ZL3barv"])
# Another, unique user decides it
assert not definitions.linked_copy_unknown("a.o", ["_ZL3barv", "_ZL3bazv"])
def test_unnamed_constants_partial_readelf_output(
obj_dir: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unreadable object warns, and the objects readelf did print are still used."""
analyzer, run_tool = _esp8266_analyzer(obj_dir, {SETUP, MATCH_URL})
def failing_run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
result = run_tool(cmd)
if "-SW" in cmd:
return subprocess.CompletedProcess([], 1, result.stdout, "readelf: bad.o")
return result
with patch("esphome.analyze_memory.run_tool", side_effect=failing_run_tool):
analyzer._analyze_object_rodata()
assert len(analyzer._unnamed_constants) == 2
assert "readelf could not read every object: readelf: bad.o" in caplog.text
def test_unnamed_constants_missing_objects_warn(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Without object files the report says why constants are missing."""
analyzer = make_analyzer()
analyzer.elf_path = tmp_path / "firmware.elf"
analyzer.rodata_in_ram = True
analyzer._analyze_object_rodata()
assert "No object files next to the ELF" in caplog.text
def test_unnamed_constants_mixed_section_users_by_offset() -> None:
"""Only functions pointing at the unnamed bytes are listed; any user keeps it live."""
analyzer = make_analyzer()
analyzer._elf_symbol_names = {"_Z5namedv"}
dump = """\
Relocation section '.rela.text._Z5namedv' at offset 0x100 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
Relocation section '.rela.text._Z7unnamedv' at offset 0x200 contains 1 entry:
Offset Info Type Sym. Value Symbol's Name + Addend
00000000 00003a01 R_XTENSA_32 00000000 .rodata + 0
""" + hex_dump(".rodata", bytes([0xAA] * 4 + [1, 2, 3, 4]))
# Literal-pool words: _Z5namedv points at offset 0 (named), _Z7unnamedv at 4
code = hex_dump(".text._Z5namedv", (0).to_bytes(4, "little")) + hex_dump(
".text._Z7unnamedv", (4).to_bytes(4, "little")
)
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
return completed(dump if "-rW" in cmd else code)
section = _RodataSection(".rodata", 8, covered=[(0, 4)])
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
(constant,) = analyzer._scan_object_constants(
"foo.o", {"5": section}, "src/foo.o", "[esphome]foo"
)
# Kept because _Z5namedv is linked, but only _Z7unnamedv uses the unnamed bytes
assert constant.functions == ["_Z7unnamedv"]
@pytest.mark.parametrize(
("referrer", "offset", "addend", "expected"),
[
((3).to_bytes(4, "little"), 0, 0, b"lo"),
((3).to_bytes(4, "little"), 0, -9, None),
((50).to_bytes(4, "little"), 0, 0, None),
(b"\x00\x00", 0, 0, None),
],
)
def test_pointed_literal_bounds(
referrer: bytes, offset: int, addend: int, expected: bytes | None
) -> None:
"""A pointer outside the dumped bytes yields nothing instead of a made-up string."""
assert _pointed_literal(referrer, offset, addend, b"hello\0") == expected
def test_report_escapes_pointed_strings() -> None:
"""Control characters in a literal are escaped in the report."""
analyzer = make_analyzer(cls=MemoryAnalyzerCLI)
analyzer._unnamed_constants = [
UnnamedConstant(
size=8,
source_file="src/main.cpp.o",
component="[esphome]core",
functions=["setup"],
jump_table=False,
data=b"",
strings=["a\nb", "\x1b[2J"],
)
]
report = analyzer.generate_report()
assert 'points to "a\\nb", "\\u001b[2J"' in report
assert "\x1b" not in report
def test_read_pointed_strings_falls_back_on_bad_pointer() -> None:
"""One unreadable pointer drops the string list so the byte preview is shown."""
analyzer = make_analyzer()
table = bytearray((0).to_bytes(4, "little") + (99).to_bytes(4, "little"))
pointers = {".rodata": [(0, ".rodata.str1.1", 0), (4, ".rodata.str1.1", 0)]}
with patch(
"esphome.analyze_memory.run_tool",
return_value=completed(hex_dump(".rodata.str1.1", b"ok\0")),
):
assert analyzer._read_pointed_strings("a.o", pointers, {".rodata": table}) == {}
def test_unnamed_constants_mixed_section_without_unnamed_user(obj_dir: Path) -> None:
"""Unnamed bytes no function points at are still listed and charged."""
analyzer, run_tool = _esp8266_analyzer(obj_dir, {SETUP, MATCH_URL})
constant = UnnamedConstant(
size=4,
source_file=LIGHT_OBJ,
component="[esphome]light",
functions=[],
jump_table=False,
data=b"",
users=[SETUP],
)
light = str(obj_dir / LIGHT_OBJ)
with (
patch("esphome.analyze_memory.run_tool", side_effect=run_tool),
patch.object(
analyzer,
"_scan_object_constants",
side_effect=lambda path, *_: [constant] if path == light else [],
),
):
analyzer._analyze_object_rodata()
assert analyzer._unnamed_constants == [constant]
assert analyzer.components["[esphome]light"].data_size == 4
def test_unnamed_constants_discarded_unique_user_does_not_vouch(obj_dir: Path) -> None:
"""A dropped unique user can't keep a constant whose only linked user is ambiguous."""
analyzer, run_tool = _esp8266_analyzer(obj_dir, {SETUP})
constant = UnnamedConstant(
size=4,
source_file=LIGHT_OBJ,
component="[esphome]light",
functions=[SETUP],
jump_table=False,
data=b"",
# SAVE is unique but was dropped; SETUP is a local defined in two objects
users=[SAVE, SETUP],
)
light = str(obj_dir / LIGHT_OBJ)
with (
patch("esphome.analyze_memory.run_tool", side_effect=run_tool),
patch.object(
analyzer,
"_scan_object_constants",
side_effect=lambda path, *_: [constant] if path == light else [],
),
patch(
"esphome.analyze_memory._FunctionDefinitions.linked_copy_unknown",
side_effect=lambda _path, functions: functions == [SETUP],
),
):
analyzer._analyze_object_rodata()
assert analyzer._unnamed_constants == []
def test_unnamed_constants_single_object(tmp_path: Path) -> None:
"""With one object readelf prints no File: header; its constants are still found."""
single = tmp_path / "obj" / "src" / "only.cpp.o"
single.parent.mkdir(parents=True)
single.touch()
analyzer, _ = _esp8266_analyzer(tmp_path / "obj", {SETUP})
headers = HEADERS_AND_SYMBOLS.split("File: {web}", maxsplit=1)[0].replace(
"File: {light}\n", ""
)
def run_tool(cmd: list[str]) -> subprocess.CompletedProcess[str]:
if "-SW" in cmd:
assert cmd[-1] == str(single)
return completed(headers.format(bind="GLOBAL", setup=SETUP, save=SAVE))
return completed(LIGHT_OBJECT_DUMP)
with patch("esphome.analyze_memory.run_tool", side_effect=run_tool):
analyzer._analyze_object_rodata()
(constant,) = analyzer._unnamed_constants
assert (constant.size, constant.source_file) == (44, "src/only.cpp.o")
+15 -35
View File
@@ -9,14 +9,14 @@ from esphome import automation
from esphome.components.const import CONF_ON_STOP
from esphome.components.script import (
CONF_SCRIPT_STOP,
CONFIG_SCHEMA,
SCRIPT_MODES,
SingleScript,
default_script_stop_ids,
to_code,
)
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_THEN
from esphome.core import ID
from esphome.cpp_generator import MockObj
def test_bare_script_stop_defaults_to_enclosing_script() -> None:
@@ -103,78 +103,58 @@ async def test_on_stop_builds_stop_trigger_automation() -> None:
stop_conf = {CONF_THEN: []}
config = [
{
CONF_ID: ID("my_script", is_declaration=True, type=SCRIPT_MODES["single"]),
CONF_ID: ID("my_script", is_declaration=True, type=SingleScript),
CONF_MODE: "single",
CONF_PARAMETERS: {},
CONF_THEN: [],
CONF_ON_STOP: stop_conf,
},
{
CONF_ID: ID(
"other_script", is_declaration=True, type=SCRIPT_MODES["single"]
),
CONF_ID: ID("other_script", is_declaration=True, type=SingleScript),
CONF_MODE: "single",
CONF_PARAMETERS: {},
CONF_THEN: [],
},
]
with (
patch("esphome.codegen.new_Pvariable") as new_pvariable,
patch("esphome.codegen.add"),
patch("esphome.codegen.add_define") as add_define,
patch(
"esphome.automation.build_automation", new_callable=AsyncMock
) as build_automation,
patch(
"esphome.automation.build_automation_or_run_function",
new_callable=AsyncMock,
return_value=None,
) as build_or_run,
):
new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->")
await to_code(config)
add_define.assert_called_once_with("USE_SCRIPT_ON_STOP")
assert [(str(c.args[0]), *c.args[1:3]) for c in build_or_run.await_args_list] == [
("my_script", [], config[0]),
("other_script", [], config[1]),
]
assert [
(str(c.args[0]), *c.args[1:]) for c in build_automation.await_args_list
] == [("my_script->get_stop_trigger()", [], stop_conf)]
] == [
("my_script", [], config[0]),
("my_script->get_stop_trigger()", [], stop_conf),
("other_script", [], config[1]),
]
@pytest.mark.asyncio
async def test_no_on_stop_define_without_on_stop() -> None:
config = [
{
CONF_ID: ID("my_script", is_declaration=True, type=SCRIPT_MODES["single"]),
CONF_ID: ID("my_script", is_declaration=True, type=SingleScript),
CONF_MODE: "single",
CONF_PARAMETERS: {},
CONF_THEN: [],
}
]
with (
patch("esphome.codegen.new_Pvariable") as new_pvariable,
patch("esphome.codegen.add"),
patch("esphome.codegen.add_define") as add_define,
patch(
"esphome.automation.build_automation_or_run_function",
new_callable=AsyncMock,
return_value=None,
),
patch("esphome.automation.build_automation", new_callable=AsyncMock),
):
new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->")
await to_code(config)
add_define.assert_not_called()
@pytest.mark.parametrize("name", ["esphome__running", "esphome__arg_0"])
def test_reserved_parameter_names_rejected(name: str) -> None:
"""The reason reaches the user, at the parameter's path."""
config = [{CONF_ID: "my_script", CONF_PARAMETERS: {name: "int"}, CONF_THEN: []}]
with pytest.raises(cv.MultipleInvalid) as err:
CONFIG_SCHEMA(config)
(error,) = err.value.errors
assert (
error.msg == "Names starting with 'esphome__' are reserved for generated code"
)
assert error.path == [0, CONF_PARAMETERS, name]
+1 -5
View File
@@ -1595,11 +1595,7 @@ def apply_functions(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str, str
emitted: list[tuple[str, str, str]] = []
def fake_static_function(
name: str,
return_type: Any,
parameters: list[tuple[Any, str]],
body: list[str],
add_global_: Any = None,
name: str, return_type: Any, parameters: list[tuple[Any, str]], body: list[str]
) -> RawExpression:
assert str(return_type) == "void"
params = ", ".join(f"{type_} {arg}" for type_, arg in parameters)
-26
View File
@@ -1173,32 +1173,6 @@ def test_static_function_declares_early_and_defines_after_includes() -> None:
]
def test_static_function_declaration_sink() -> None:
declarations: list[cg.Statement] = []
cg.static_function(
"esphome__act__fn", ct.void, [], ["return;"], declarations.append
)
assert [str(st) for st in declarations] == ["static void esphome__act__fn();"]
assert CORE.global_statements == []
def test_late_pvariable_is_created_where_declared() -> None:
"""A late variable is registered at once and emitted in its place, like new_Pvariable."""
id_ = ID("late", is_declaration=True, type=ct.esphome_ns.class_("Late"))
late = cg.LatePvariable(id_)
assert CORE.variables[id_] is late.var
CORE.add_global(cg.RawStatement("int after;"))
cg.add(cg.RawStatement("after();"))
late.global_slot.add(cg.RawStatement("int before_late;"))
late.define(ct.esphome_ns.class_("Final"), cg.TemplateArguments(ct.int32), 5)
assert str(id_.type) == "Final<int32_t>"
globals_ = CORE.cpp_global_section
assert globals_.index("Final<int32_t> *const late") < globals_.index("int after;")
main = CORE.cpp_main_section
assert main.index("new(late) Final<int32_t>(5);") < main.index("after();")
assert cg.is_static_pointer(late.var)
def test_static_function_accepts_statements() -> None:
call = cg.MockObj("foo")(cg.MockObj("x") >> 2)
cg.static_function(