Changelog

# Changelog / 更新日志

## 1.6.0
- **BREAKING — on-air format (空口格式破坏性变更)**: the beacon grew from 27 to 29 bytes for
  the uplink acknowledgement field, which shifts `cmac_tag` from offset 23 to 25, and three
  packet types are new: `DATA_FRAG` (0x05), `JOIN_REQ` (0x06) and `JOIN_RESP` (0x07). Both
  sides verify the beacon CMAC over those bytes, so a 1.6.0 device and a 1.5.0 device each
  reject the other's beacons outright — this is not a mixed-version upgrade. **Update the
  whole fleet together.** The 1.2.0 and 1.5.0 breaks below still apply, so a jump from
  1.1.x or 1.2.0–1.4.0 lands on the same requirement.
  *(信标因新增上行确认字段由 27 字节增至 29 字节,`cmac_tag` 偏移由 23 前移至 25;新增
  `DATA_FRAG`(0x05)、`JOIN_REQ`(0x06)、`JOIN_RESP`(0x07)三种包型。双方都按这些偏移校验
  信标 CMAC,因此 1.6.0 与 1.5.0 会直接互相丢弃对方信标,不存在混版本升级路径。**必须全网同版本
  升级。** 下方 1.2.0 与 1.5.0 的破坏性变更同样成立,因此从 1.1.x 或 1.2.0–1.4.0 跳升也落在同一
  个要求上。)*
- **Guarded two range-versus-arithmetic edges found by substituting every option range into the code**
  *(把每个选项的范围代进代码,堵住两处范围与算术的交界)*:
  - **The bitmaps have 16 bits and the node count is not tied to them.** One membership bit and one
    acknowledgement bit travel per node ID, both in a `uint16`; the option's range caps the count at 15,
    so it fits today, but nothing said so. Raising that range would silently truncate the bit of every node
    above 16 — those nodes would never be admitted and nothing in any log would say why, because the bit
    they wait for is dropped on the wire. A `_Static_assert` now fails the build instead.
    *(每个节点号在信标里各占一个成员位与一个确认位,都是 `uint16`;选项范围把数量上限定在 15,今天装得下,
    但没有任何东西把两者绑在一起。调高该范围会静默截断 16 号以上节点的位——它们永远不会被准入,而日志里
    不会有任何解释,因为它们等待的那个位在空口上就被丢了。现在由 `_Static_assert` 直接编不过。)*
  - **The join offset is an unguarded subtraction on a MEASURED value.** `offset = measured_period -
    JOIN_TAIL_US - jitter`; a measured period below the window plus its jitter wraps the unsigned result into
    a timer of about 71 minutes, and the node would silently never send a join request. It is **not reachable
    through normal operation** — two beacons from the configured gateway are at least
    `BEACON_INTERVAL_MS` apart and replay protection rejects a timestamp that does not advance — so this is
    hardening, not a fix, and it is labelled that way in the code. The period is the one term here the file
    does not control, which is reason enough to bound it.
    *(`offset = 实测周期 − JOIN_TAIL_US − 抖动`;实测周期小于窗口加抖动时,无符号结果会回绕成约 71 分钟的
    定时器,节点将静默地永远不发加入请求。**正常运行下不可达**——所配置网关的两个信标至少相隔
    `BEACON_INTERVAL_MS`,且重放保护会拒绝不前进的时间戳——所以这是加固而非修复,代码里就是这么标注的。
    但周期是本文件唯一无法掌控的一项,给它加上边界已经足够成为理由。)*
- **FIX — a legal configuration could put the last slots on top of the downlink (修复:合法配置也会把尾部时隙
  压到下行上)**: `compute_slot_step_us()` clamps the step **up** to `SLOT_STEP_MIN_US`, and that clamp can
  push the whole schedule out of the frame, because the last node starts at `highest_id * step` while the
  usable window is only `period - tail_margin`. Nothing was checking it, and the two existing geometry
  checks both pass while the slots are already outside the window — they ask whether the *tail margin* can
  host the downlink and the join window, not whether the slots end before them. Two legal configurations
  break it: a 5 ms beacon interval, and a tail margin of 9000 µs, both with 15 nodes; in each case the last
  slot is still on the air at ~4650 µs while the downlink goes out at 3400 or 1400 µs. The symptom in the
  field would be loss on the later slots with no diagnostic at all. The gateway now checks the frame
  budget at startup and on every registration, and names the numbers plus the three options to adjust.
  *(…步长被**向上**钳位,而这一钳位会把整个调度推出帧外:最后一个节点从 `最高节点号 × 步长` 开始,而可用窗口
  只有 `帧周期 − 尾部余量`。此前没有任何检查,且既有的两项几何检查在时隙已经越界时依然全部通过——它们问的是
  **尾部余量**能否容纳下行与加入窗口,而不是时隙是否在它们之前结束。有两种合法配置会破:5 ms 信标周期、
  以及 9000 µs 尾部余量,均配 15 节点;两种情况下最后一个时隙在约 4650 µs 时仍在空口上,而下行在 3400 或
  1400 µs 就发出。现场表现将是尾部节点丢包且毫无诊断。网关现在在启动时与每次注册时检查帧预算,并报出具体
  数字与三个可调选项。)*
- **FIX — the two shipped examples could not talk to each other (修复:两个示例之间根本通不了)**: the
  gateway example signed every beacon with a CMAC key of `01..10` while the node example
  verified against all zeros. A node in that state rejects every beacon as failing
  verification and stays silent, so anyone flashing both examples as shipped would have seen a
  stream of CMAC errors and concluded the crypto was broken, rather than that two constants
  disagreed. Both examples now carry the same key, both READMEs say so, and they also state the
  part that is easy to get backwards: leaving the key unset is **not** the same as setting it to
  zeros — unset means the gateway does not sign, so a node holding a key still rejects it.
  *(网关示例用 `01..10` 给每个信标签名,而节点示例用全零验签。此状态下的节点会把每个信标都判为验签
  失败并保持静默——照原样烧写两个示例的人会看到一串 CMAC 报错,从而得出"密码学实现坏了"的结论,
  而真相只是两个常量不一致。现在两个示例使用同一把密钥,两个 README 也都写明,并且写清了最容易搞反的
  一点:**不设**密钥与设为全零**不是一回事**——不设意味着网关不签名,因此持有密钥的节点依然会拒绝。)*
- **FIX — an abandoned message was reported as failed twice (修复:被放弃的消息会报两次失败)**: when a
  partially reassembled message timed out, nothing recorded *which* message had been abandoned.
  The sender's next fragment — it has no way to know the gateway gave up — therefore started a
  fresh reassembly, landed at an index past the start, and was reported as out-of-order. One
  message produced two failure reports, the second with the wrong reason, since the truth was a
  timeout. The gateway now remembers the abandoned id and absorbs the rest of that stream
  quietly; the marker is cleared when a genuinely new message begins, so the id wrap-around at
  256 cannot resurrect it.
  *(部分重组超时时没有记录**被放弃的是哪条消息**。发送方无从得知网关已放弃,于是它的下一个分片会开启一次
  新的重组、落在起始位置之后,被报成乱序——同一条消息产生两次失败报告,且第二次原因错误(真相是超时)。
  现在网关记住被放弃的消息号并静默吸收该流的其余分片;标记在真正的新消息开始时清除,因此 256 的编号回绕
  也不会让它复活。)*
- **FIX — a message could be truncated silently (修复:消息会被静默截断)**: the fragment count was
  computed into a `uint8` while the option ranges allow a combination needing more than 255 of them.
  With `TDMA_MSG_SIZE=4096` and `TDMA_PAYLOAD_SIZE=8` a message needs 820 fragments of 5 bytes; the
  count narrowed to 52, so the sender transmitted 52 of them, the gateway reassembled 260 bytes, and
  both sides treated that as the complete 4096-byte message. No error, no warning, no assert — the
  worst shape a bug can take. The count is 32-bit now and an impossible one is **refused** with a
  message naming both options, because where truncation is silent, refusing is the only honest
  answer.
  *(分片数被算进 `uint8`,而选项范围允许需要超过 255 片的组合:`MSG_SIZE=4096` + `PAYLOAD_SIZE=8`
  时一条消息要 820 个 5 字节分片,窄化后变成 52——发送方只发 52 片,网关重组出 260 字节,双方都把它
  当作完整的 4096 字节。没有错误、没有告警、没有断言,这是 bug 最坏的形态。现在计数为 32 位,不可能的
  计数会**被拒绝**并报出两个选项的名字——在截断是静默的地方,拒绝是唯一诚实的答案。)*
- **FIX — the downlink outlived its suspension (修复:下行没有跟随发送暂停)**: `TX suspended` exists so
  the application can take the radio, but the gateway kept arming its downlink into the tail margin
  regardless — precisely the collision the suspension is meant to prevent. The queued entry and its
  remaining retries are kept, so resuming continues where it left off rather than restarting.
  *(发送暂停的意义是把射频让给应用,但网关仍照常把下行排进帧尾余量——正是暂停要避免的那种碰撞。
  队列条目与剩余重试次数原样保留,恢复后接着发而非重来。)*
- **Documented the callback context (写明回调上下文)**: every callback runs from the ESP-NOW receive
  task or the esp_timer task, never from an interrupt, and must not block — a delay there stalls the
  receive path, which is where slot timing is decided. This was implied by the code and stated
  nowhere.
  *(所有回调都在 ESP-NOW 收包任务或 esp_timer 任务中执行,绝不在中断里,且不得阻塞——在那里延时
  会拖住收包路径,而时隙时序正是在那里决定的。这一点此前只隐含在代码里,没有任何文档说明。)*
- **The node no longer wakes its transmitter when it has nothing to send (无事可发时不再唤醒发送任务)**: the
  TX task was notified on every beacon regardless, a hundred wakeups a second of a task that
  usually fell straight through its gates. It is now armed only when one of four things is true:
  queued data, a REG_ACK that has not completed its repeats, a join request, or an unacknowledged
  payload the ARQ may have to retransmit. No latency is lost, because a payload enqueued after that
  decision waits for the next beacon either way. While the gateway has TX suspended the slot is not
  armed at all, and the pending retries are left untouched so resuming re-arms the next frame.
  *(TX 任务此前每个信标都被通知一次,100 次/秒地空转。现在只在四类情况之一成立时才装定时器:有排队数据、
  REG_ACK 未发满、有待发的 join 请求、或有未确认载荷需要 ARQ 重传。不损失时延——在该判断之后入队的载荷
  本来也要等下一个信标。网关暂停发送期间完全不装定时器,且未用的重试次数原样保留,恢复后下一帧重新装。)*
- **Reported as a duty ratio you can measure (以可测的占空比呈现)**: `get_slot_wakeups()` and
  `_slot_skips()` join `get_clock_samples()` (one per authenticated beacon), so
  `wakeups / clock_samples` is the fraction of frames the radio is actually used in. That is the
  number an energy budget has to start from, and the number to measure before deciding whether a
  duty-cycled wake window is worth its cost.
  *(`get_slot_wakeups()` / `_slot_skips()` 与 `get_clock_samples()`(每个通过认证的信标计一次)
  配合,`wakeups / clock_samples` 即射频真实使用帧的比例——能耗预算的起点,也是决定是否值得做占空比唤醒窗
  之前该测的数字。)*
- **Node power management is NOT implemented, and why (节点电源管理未实现,及原因)**: it is the one item
  in this list that is not additive. Every other feature assumes the node receives beacons
  continuously, and a node that sleeps for N frames weakens four of them at once: the AFH warning
  countdown, the ARQ acknowledgement, clock recovery's sample rate, and the speed at which a node
  notices it has been removed from the schedule. Whether that trade is worth taking is an
  application decision about latency versus energy, and the platform question behind it (what a
  light-sleep wake actually costs before ESP-NOW works again) can only be answered by measuring on
  hardware. Both are written up in `docs/节点电源管理_设计取舍.md`, including the three options with
  their costs and the experiment that decides between them.
  *(这是清单里唯一一项**非增量**的改动。其余每项功能都假定节点持续收信标,而一个每 N 帧休眠一次的节点会同时
  削弱四项:AFH 预警倒计时、ARQ 确认、时钟恢复的样本率、以及发现"自己被移出调度"的速度。这个取舍是否值得
  属于应用层对时延与能耗的判断,而其背后的平台问题(轻睡唤醒后多久 ESP-NOW 才能恢复工作)只能靠真机实测回答。
  两者都写进了 `docs/节点电源管理_设计取舍.md`,含三个选项的代价与决定取舍的那个实验。)*
- **A related defect found while analysing it (分析时发现的相关缺陷)**: an idle node is reported offline.
  `esp_tdma_master_is_node_online()` keys on the last data packet, and an idle node sends none once
  its REG_ACK repeats are done, so a perfectly healthy node with nothing to report ages out after six
  seconds. The examples never show it because they enqueue every 100 ms. Two fixes are noted in the
  design note; both change behaviour, so neither is applied here.
  *(空闲节点会被判离线:`is_node_online()` 以"最后一个数据包"为准,而空闲节点在 REG_ACK 发满后不再发数据,
  于是完全健康但无事可报的节点会在 6 秒后过期。示例因为每 100 ms 入队一次而看不出来。设计说明里记了两条修法,
  都会改变行为,故本轮不动。)*
- **SECURITY — a frame can no longer claim another node's identity (安全:报文无法再冒充其他节点)**: the
  gateway accepted a data packet based on the node ID *inside* it and never looked at where it came
  from. That made several attacks possible without holding any key: set another node's
  acknowledgement bit and silence its retransmissions, feed its PER window and trigger an AFH hop on
  its behalf, fake its heartbeat so it looks online, and have bytes delivered to the application as
  that node's data. Every data packet and fragment is now accepted only when its source address
  matches the one registered under the ID it carries, and a frame from an unregistered source is
  refused too. `esp_tdma_master_get_rejected_frames()` reports the attempts.
  *(网关此前仅凭报文**内部**的节点号接受数据包,从不检查来源。这使得若干无需任何密钥的攻击成立:
  置位他人的确认位以压制其重传、替他人喂 PER 窗口并触发跳频、伪造其心跳使其显示在线、以及把字节作为
  该节点的数据交给应用。现在每个数据包与分片都要求源地址与该 ID 登记在册的地址一致,未登记来源同样
  拒绝。`get_rejected_frames()` 上报尝试次数。)*
- **SECURITY — beacons must come from the configured gateway (安全:信标必须来自所配置的网关)**: the beacon
  CMAC is computed with a key shared by every node, so any node can produce a beacon that verifies; a
  node would then accept a forged schedule, channel and set of acknowledgement bits. Beacons are now
  also checked against the gateway address this node was configured with, which raises the bar from
  "any node holding the group key" to "a node that deliberately spoofs the gateway's MAC" —
  defeatable, but no longer free. `esp_tdma_slave_get_foreign_beacons()` reports the attempts, so a
  spoofing campaign is visible rather than silent.
  *(信标 CMAC 用的是全网共享密钥,因此任一节点都能生成可通过验签的信标;节点会接受伪造的调度、信道与
  确认位。现在信标还要求源地址等于本节点被配置的网关地址,这把门槛从"任何持有组密钥的节点"抬高到
  "蓄意伪造网关 MAC 的节点"——可被击败,但不再零成本。`get_foreign_beacons()` 上报尝试,使伪造行为
  可见而非静默。)*
- **Documented what the shared key does and does not prove (已写明共享密钥能证明什么、不能证明什么)**: the
  header now carries an explicit security section. The important disclosure is a consequence of the
  join flow: because a derived LMK is AES-128-CMAC of a node's MAC under the shared beacon key, and
  every node holds that key, **any node can derive any other node's LMK**, read its downlink traffic
  and forge unicast frames to it. Provisioning a node with its own LMK (node_id != 0) is the way to
  keep nodes from reading each other; the derived form is for networks whose nodes trust each other.
  Genuinely per-node broadcast authentication needs asymmetric signatures (too slow to verify per
  10 ms beacon here) or a TESLA-style hash chain over the per-node encrypted channel, which verifies
  one frame late and needs re-anchoring — neither is implemented, and the header says so.
  *(头文件现在带有明确的安全章节。最重要的一条披露是 join 流程的推论:由于派生 LMK 是以共享信标密钥对
  节点 MAC 做的 AES-128-CMAC,而每个节点都持有该密钥,**任一节点都能推导出其他任一节点的 LMK**,进而
  读取其下行流量并向其伪造单播帧。要阻止节点互相读取,就为节点配置各自的 LMK(node_id != 0);派生
  形式适用于节点之间互相信任的网络。真正的逐节点广播认证需要非对称签名(在本类硬件上按 10 ms 信标逐帧
  验签太慢)或 TESLA 式哈希链配合逐节点加密信道(晚一帧验证且需要重新锚定)——两者均未实现,头文件
  如实写明。)*
- **A real join flow: request, approve, assign (真正的 join 流程:申请→批准→分配ID)**: a node ID had
  to be provisioned before a node could speak. `esp_tdma_slave_set_config()` now accepts
  **`node_id == 0`, meaning "ask the gateway for one"**: the node broadcasts a join request, the
  gateway approves it (optionally through the new `on_join_request` callback, which may also choose
  the ID itself), installs the node's peer entry and unicasts the assignment back. Two nodes can no
  longer be provisioned with the same ID, and the answer is idempotent — a node that re-sends its
  request because the answer was lost is told the ID it already has, rather than being given a
  second one.
  *(此前节点必须先配好 ID 才能开口。`esp_tdma_slave_set_config()` 现在接受 **`node_id == 0`,语义为
  "向网关申请一个"**:节点广播加入请求,网关批准(可选经新增的 `on_join_request` 回调,该回调还可以
  自行指定 ID),安装该节点的 peer 表项,再把分配结果单播回去。两个节点再也不可能被配成同一个 ID;
  且应答是幂等的——因应答丢失而重发请求的节点会被告知它已有的 ID,而不是拿到第二个。)*
- **No key material on the air: the unicast LMK is derived, not transported (密钥不上空口:单播密钥
  靠推导,不靠传输)**: the gateway computes a node's LMK as AES-128-CMAC over that node's MAC with the
  shared beacon key, and the node computes the same value locally. So the join request is **one
  byte**, nothing secret is transmitted, each node still gets a distinct key, and a node cannot
  choose a key that would let it read another node's unicast traffic. Note the consequence: a node
  configured with `node_id == 0` must pass `NULL` for the LMK, and the call is refused (with an
  explanation) if it passes one — a supplied key could never match what the gateway derives.
  *(网关以共享信标密钥对"该节点 MAC"做 AES-128-CMAC 得到其单播密钥,节点本地算出同一个值。因此加入
  请求只有**一个字节**,没有任何秘密上行,每个节点仍各持不同密钥,且节点无法选择一把能读别人单播流量的
  密钥。代价要说清:`node_id == 0` 时 LMK 必须传 `NULL`,传了会被拒绝并说明原因——因为外部提供的密钥
  永远不可能与网关推导出的一致。)*
- **ADDITIVE on air (空口只增不改)**: the join flow adds two packet types and **leaves the beacon
  layout alone**, so an older node interoperates: it simply ignores join traffic and keeps whatever
  ID it was provisioned with. This is unlike the last three releases.
  *(加入流程只新增两个包型、**未改信标布局**,因此旧节点可以互通:它只是忽略加入流量、继续用它被配好的
  ID。这与前三个版本不同。)*
- **Where a node with no slot transmits (没有时隙的节点在哪发言)**: it cannot use the schedule, so the
  gateway listens in a window at the very end of the frame (`TDMA_JOIN_TAIL_US`, default 300 µs before
  the next beacon). A joining node locates it from three things it already has — the advertised slot
  step, the active-node bitmap whose highest set bit identifies the last occupied slot, and the frame
  period it measures from two consecutive beacons. Requests are jittered inside the window with a
  MAC-seeded sequence, so two nodes joining at once usually miss each other instead of colliding
  forever.
  *(无时隙可用的节点改在帧最末尾的窗口发言(`TDMA_JOIN_TAIL_US`,默认下一个信标前 300 µs)。加入节点
  用它已有的三样东西定位该窗口:广播的时隙步长、最高置位即最后占用时隙的活跃节点位图、以及它从连续两个
  信标测出的帧周期。请求在窗口内以 MAC 作种子的序列抖动,因此同时加入的两个节点通常会错开而不是永远碰撞。)*
- **Reclamation is explicit (ID 回收是显式的)**: an assigned ID stays reserved until
  `esp_tdma_master_release_node()` or deinit. Nothing is reclaimed automatically, because the gateway
  cannot tell "gone forever" from "out of range for a while", and evicting a node that comes back
  would be worse than holding an ID. The release also deletes the ESP-NOW peer entry, which is the
  resource it is really giving back.
  *(已分配的 ID 会一直保留到 `esp_tdma_master_release_node()` 或 deinit。不做自动回收,因为网关无法
  区分"永久消失"与"暂时超出范围",而踢掉一个会回来的节点比占着一个 ID 更糟。释放同时会删除 ESP-NOW
  peer 表项——那才是它真正归还的资源。)*
- **An unapproved request can occupy an ID (未经批准的请求能占用 ID)**: the join request is
  unauthenticated, so without `on_join_request` any neighbour in range can take a slot. The callback
  is the policy hook: return false to refuse, and the node keeps asking rather than failing, so the
  application may change its mind later. If no beacon CMAC key is set the gateway refuses every
  request, since it has nothing to derive the node's key from — it will not silently fall back to an
  unencrypted peer.
  *(加入请求未经认证,因此不装 `on_join_request` 时,任何在范围内的邻居都能占一个时隙。该回调就是策略
  钩子:返回 false 即拒绝,而节点会继续询问而不是失败,所以应用稍后可以改变主意。若未设置信标 CMAC 密钥,
  网关会拒绝所有请求——它无从推导节点密钥,也不会静默退化成不加密的 peer。)*
- **New statistics and accessors (新增统计与读取接口)**: `esp_tdma_master_get_join_requests()` /
  `_join_rejections()`, `esp_tdma_slave_get_node_id()`, `esp_tdma_slave_get_join_attempts()` /
  `_join_rejections()`. *(同上。)*
- **Uplink message fragmentation and reassembly (上行消息分片与重组)**: a payload had to fit one
  packet. `esp_tdma_slave_enqueue_message()` now takes a message up to `CONFIG_TDMA_MSG_SIZE`
  (default 1024), splits it across as many slots as it needs, and the gateway delivers it whole
  through the new `on_message_received` callback. Fragments ride a new packet type
  (`TDMA_PKT_DATA_FRAG`), so a receiver that does not implement reassembly drops them instead of
  misreading them as ordinary payloads, and a message that cannot be completed is reported through
  `on_message_dropped` rather than handed up short.
  *(载荷此前必须装进一个包。`esp_tdma_slave_enqueue_message()` 现在接受最大
  `CONFIG_TDMA_MSG_SIZE`(默认 1024)的消息,按需拆到多个时隙,网关通过新增的 `on_message_received`
  整条交付。分片走新的包型 `TDMA_PKT_DATA_FRAG`,因此未实现重组的接收端会丢弃它们、而不会误当成普通
  载荷;无法完成的消息通过 `on_message_dropped` 报告,绝不短交。)*
- **The fragment metadata comes out of the payload budget, not on top of it (分片元数据占载荷预算,
  不是额外叠加)**: `CONFIG_TDMA_PAYLOAD_SIZE` keeps its single meaning — how many application bytes
  fit in a packet — so `ESP_TDMA_FRAG_BYTES` is three bytes smaller, and the worst case stays
  exactly at the 250-byte ESP-NOW v1.0 limit. A `_Static_assert` states that relationship, so
  raising the payload option past what a fragment header allows fails the build instead of
  silently overrunning the limit.
  *(`CONFIG_TDMA_PAYLOAD_SIZE` 保持唯一含义——一个包能装多少应用字节——因此
  `ESP_TDMA_FRAG_BYTES` 小三字节,最坏情况仍恰好落在 ESP-NOW v1.0 的 250 字节上限。`_Static_assert`
  把这一关系钉死:把载荷选项调过"分片头还能容纳"的范围会直接编不过,而不是静默越限。)*
- **All-or-nothing enqueue (入队全有或全无)**: either the whole message enters the ring or none of
  it does, so a rejected call can never leave a partial message for the gateway to reassemble.
  Discard-stale then works in whole messages: `tdma_ringbuf_discard_older_messages()` keeps the
  newest message unit — one plain payload, or a complete run of fragments sharing a message id —
  because dropping individual fragments would leave a partial message the receiver could not
  recognise as truncated. Plain payloads keep exactly their previous behaviour.
  *(要么整条消息进入环形缓冲,要么一个分片都不进,因此被拒绝的调用绝不会留下半条消息让网关去重组。
  丢弃旧帧策略也随之以整条消息为单位:`tdma_ringbuf_discard_older_messages()` 只保留最新的"消息
  单元"(一个普通载荷,或一段共享消息号的完整分片序列),因为丢弃单个分片会留下接收端无法识别为
  残缺的部分消息。普通载荷行为完全不变。)*
- **Reassembly buffers are lazy, per node, and were paid for by design (重组缓冲按节点惰性分配)**: one
  buffer per node is enough because the uplink ARQ is stop-and-wait — a sender cannot start the next
  message before the current one is acknowledged. The buffer is allocated the first time a node sends
  a fragmented message and released by `esp_tdma_master_deinit()`, so a node that only sends
  single-packet payloads costs nothing, and the worst case is bounded at
  `MAX_NODES * CONFIG_TDMA_MSG_SIZE` and documented in the option's help.
  *(每个节点一个缓冲就够,因为上行 ARQ 是 stop-and-wait——发送方在当前消息被确认前无法开始下一条。
  缓冲在某节点首次发送分片消息时分配,由 `esp_tdma_master_deinit()` 释放;因此只发单包载荷的节点
  零成本,最坏情况受 `MAX_NODES * CONFIG_TDMA_MSG_SIZE` 约束并写在该选项的 help 里。)*
- **A late retransmission of the last fragment is not a failure (末片重传不算失败)**: if the
  acknowledgement of the FINAL fragment was what got lost, the node resends it after the gateway has
  already delivered the message. Treated naively that looks like a new message arriving at its last
  position, which would report a delivered message as dropped. The gateway now remembers the last
  delivered message id per node and absorbs it as a duplicate, counting it with the other suppressed
  repeats. Within a fragmented message a repeat whose index was already consumed is likewise
  absorbed, and only an index that skips ahead abandons the message.
  *(若丢失的正是**末片**的确认,节点会在网关已交付该消息之后重发它。按朴素逻辑处理,这看起来像一条
  新消息从末位开始到达,会把已交付的消息报成丢弃。网关现在按节点记住最后交付过的消息号并将其作为重复
  吸收,与其他被抑制的重复一起计数。分片消息内部,序号已被消费过的重复同样被吸收,只有**跳过**位置的
  索引才会放弃整条消息。)*
- **New statistics (新增统计)**: `esp_tdma_master_get_messages_completed()` and
  `_messages_dropped()`, plus an `esp_tdma_msg_drop_reason_t` on the dropped callback so a timeout, an
  oversize message, an out-of-order fragment and a mid-message id change are distinguishable.
  *(`get_messages_completed()` 与 `_messages_dropped()`,并在丢弃回调上带
  `esp_tdma_msg_drop_reason_t`,使超时、超长、乱序与消息号中途变化可以区分。)*
- **BREAKING — uplink retransmission, and with it a larger beacon (破坏性变更:上行重传,信标随之变大)**:
  a node had no way to learn whether its data arrived. The gateway now acknowledges the
  frame that just ended through a new `ack_bitmask` field in the beacon — bit
  `node_id - 1` means "that node's data was heard in the frame that just ended" — and the
  beacon therefore grows from 27 to 29 bytes. It is rebuilt every frame and never sticky,
  so a node can distinguish "my last transmission arrived" from "one once did".
  **The whole fleet must be updated together: a node running an older build cannot parse
  the new beacon.**
  *(此前节点无从得知自己的数据是否送达。网关现在通过信标新增的 `ack_bitmask` 字段确认刚刚结束的那一帧
  ——第 `node_id - 1` 位表示"该节点的数据在刚结束的帧里被听到了"——信标因此由 27 字节增至 29 字节。
  该位图每帧重建、绝不粘滞,因此节点能区分"我上一次发送到了"与"曾经到过一次"。
  **必须全网同版本升级:旧固件的节点无法解析新信标。**)*
- **Stop-and-wait uplink ARQ (上行 stop-and-wait 重传)**: an unacknowledged payload is
  retransmitted before any fresh data, with its **original sequence number**, which is what
  lets the gateway count the attempts as one delivery. Up to `TDMA_UPLINK_RETRIES` extra
  attempts (default 3, 0 disables); the worst case adds about 30 ms of latency at the default
  10 ms interval, since one frame carries one slot per node. A payload whose retries are
  exhausted is counted (`esp_tdma_slave_get_tx_gave_up()`) and the next slot takes fresh data.
  *(未确认的载荷在任何新数据之前重传,且**沿用原序号**——这正是网关能把多次尝试计为一次投递的原因。
  最多再试 `TDMA_UPLINK_RETRIES` 次(默认 3,0 关闭);由于每帧每节点只有一个时隙,最坏额外延迟约
  30 ms。重试用尽的载荷计入 `get_tx_gave_up()`,下一个时隙取新数据。)*
- **The sequence number now identifies a payload, not a slot (序号改为标识载荷而非时隙)**:
  it is assigned when a fresh payload is dequeued and reused verbatim by every retransmission,
  so it no longer advances on slots that carry nothing. This reverses a 1.2.0 decision, which
  advanced it on every slot precisely so a suspension would not look like one enormous sequence
  gap. Freezing it is safe now because the gateway resets the PER window when transmission
  resumes, which is the guarantee that decision was buying.
  *(序号在取出新载荷时分配,并被每次重传原样沿用,因此不再在空时隙上递增。这推翻了 1.2.0 的一个决定
  ——当时让它在每个时隙递增,正是为了不让暂停期看起来像一个巨大的序号缺口。现在冻结它是安全的,因为
  网关在恢复发送时会重置 PER 窗口,而那正是当初那个决定买到的保证。)*
- **Exactly-once delivery to the application in both directions (双向对应用只投递一次)**:
  retransmission would otherwise push duplicates upward whenever the acknowledgement itself was
  the thing that got lost. The gateway drops a repeat of the last delivered sequence number, and
  a node drops a repeat downlink sequence number. `esp_tdma_master_get_duplicate_rx_count()` and
  `esp_tdma_slave_get_downlink_duplicates()` count those drops, which makes them a direct
  measurement of lost acknowledgements. The delivery filters are cleared wherever a sequence is
  known to restart — on registration, on a detected rollback, and when a node goes back to
  REGISTERING — so a restarted peer's first packet is never mistaken for a repeat.
  *(否则只要丢失的是确认本身,重传就会把重复推给应用。网关丢弃与"最后已投递序号"相同的重复,节点丢弃
  重复的下行序号。`get_duplicate_rx_count()` 与 `get_downlink_duplicates()` 统计这些丢弃,因此它们
  直接度量了确认丢失的频率。凡序号确定会重置之处都会清空去重过滤——注册时、检测到回退时、节点回到
  REGISTERING 时——因此重启过的对端,其第一个包永远不会被误判为重复。)*
- **New statistics on both sides (两侧新增统计)**: `esp_tdma_slave_get_delivered()` counts
  payloads the gateway actually acknowledged, as distinct from
  `esp_tdma_slave_get_send_ok()`, which counts frames the driver accepted;
  `esp_tdma_slave_get_retransmissions()` and `esp_tdma_master_get_duplicate_rx_count()` make the
  ARQ visible, and an unacknowledged first attempt no longer waits for a callback that a refused
  `esp_now_send()` will never produce.
  *(`get_delivered()` 统计网关确实确认过的载荷,与"驱动接受了的帧"`get_send_ok()` 区分开;
  `get_retransmissions()` 与 `get_duplicate_rx_count()` 让 ARQ 可观测;首次发送被驱动拒绝时不再
  死等一个永远不会到来的回调。)*
- **Downlink retransmission (下行重传)**: a downlink whose link-layer ACK did not come back is now
  sent again, up to `TDMA_DOWNLINK_RETRIES` extra attempts (default 3, 0 disables). No on-air
  change was needed for this: ESP-NOW already acknowledges unicast, and the existing per-target
  sequence number identifies the packet. A retransmission is byte-identical to the original, and
  the receiving node delivers each sequence number exactly once, so enabling this cannot
  duplicate a payload at the application — `esp_tdma_slave_get_downlink_duplicates()` counts the
  second arrivals, which measures how often the ACK itself is lost.
  *(此前链路层 ACK 没回来就只计一次失败并丢弃。现在最多再发 `TDMA_DOWNLINK_RETRIES` 次(默认 3,
  0 关闭)。这一步**不需要改空口**:ESP-NOW 单播本就有 ACK,既有的逐目标序号也足以标识报文。重传与
  原包逐字节相同,接收节点对每个序号只投递一次,因此开启后不会在应用侧产生重复——
  `get_downlink_duplicates()` 统计第二次到达,它直接度量了 ACK 本身丢失的频率。)*
- **Retries take priority over fresh work, and give up cleanly (重传优先于新任务,且失败收尾干净)**:
  the unacknowledged entry is held outside the queue so it keeps its sequence number and its order;
  a packet whose retries are exhausted is counted (`get_downlink_gave_up()`) and dropped, so one
  unreachable node cannot stall the queue behind it. Retransmissions are counted separately
  (`get_downlink_retransmissions()`), and `get_downlink_pending()` now includes the entry awaiting
  its ACK.
  *(未确认的条目放在队列之外,以保持其序号与顺序;重试用尽的报文计入 `get_downlink_gave_up()` 并
  丢弃,使一个不可达节点无法拖住它之后的队列。重传次数单独统计,`get_downlink_pending()` 现在也计入
  等待 ACK 的那一条。)*
- **Two ordering traps fixed while wiring it up (接线时修掉两个顺序陷阱)**: the arming decision
  looked only at the queue, so a pending retransmission with an empty queue would never have been
  given a window; and a refused `esp_now_send()` produces no send callback at all, so the ARQ state
  is now settled on the spot instead of waiting for a report that never arrives.
  *(装定时器的判断只看队列,导致"队列为空但有待重传"时永远拿不到发送窗口;而 `esp_now_send()` 被
  拒绝时根本不会产生发送回调,因此 ARQ 状态现在当场结算,而不是等一个永不到来的回报。)*
- **The node clears its duplicate filter when it re-registers (节点重注册时清空去重过滤)**: a
  restarted gateway begins its per-target sequence again, and keeping the old last value would
  discard the first packet of the new run as a duplicate.
  *(网关重启后逐目标序号从头开始,若保留旧的"最后序号"会把新一轮的第一个包误判为重复而丢弃。)*
- **RSSI now qualifies the AFH decision instead of only being reported (RSSI 从"只上报"变为参与判决)**:
  the master hopped purely on PER. But PER and RSSI diagnose different faults: loss with a
  healthy signal is interference, where changing channel is the fix, while loss with a signal
  at the receiver's floor is a link-budget problem, where the next channel is no better — and
  the hop still disrupts the whole fleet and discards the window of evidence that identified
  the problem. A node whose mean RSSI is below the new `TDMA_RSSI_FLOOR_DBM` (default
  -85 dBm) no longer gets a hop; the master logs the diagnosis and the application can still
  force one with `esp_tdma_master_trigger_afh()`.
  *(此前只要 PER 超阈值就跳频。但 PER 与 RSSI 诊断的是不同故障:信号健康时的丢包是干扰,换信道正是
  解法;信号已到接收灵敏度底噪时的丢包是链路预算问题,换信道不会更好——而这次跳频依然会打断全网、
  并丢掉识别出问题的那一窗证据。均值 RSSI 低于新增 `TDMA_RSSI_FLOOR_DBM`(默认 -85 dBm)的节点不再
  被跳频,网关只记录诊断,应用仍可用 `esp_tdma_master_trigger_afh()` 强制跳。)*
- **Node-side gateway clock recovery (节点侧网关时钟恢复)**: each beacon is stamped with the
  gateway's clock, so every authenticated beacon yields a sample of the offset between the two
  clocks. `esp_tdma_slave_get_clock_offset_us()` publishes the estimate,
  `esp_tdma_slave_get_gateway_time_us()` converts local time to gateway time, and
  `esp_tdma_slave_get_clock_skew_ppm()` reports the drift between the last two windows.
  *(每个信标都带网关时间戳,因此每个通过认证的信标都给出一个两钟偏移样本。
  `get_clock_offset_us()` 发布估计值,`get_gateway_time_us()` 把本地时间换算成网关时间,
  `get_clock_skew_ppm()` 报告最近两个窗口之间的漂移。)*
- **The estimator keeps the BEST sample of a window, and that direction matters (取窗口内最优样本,
  方向不能搞反)**: a sample equals the true offset minus that beacon's own one-way latency, so
  every sample is biased low and the largest one is the best. Filtering for the minimum would
  be exactly backwards. The window length is `TDMA_CLOCK_REPORT_BEACONS` (default 1000, i.e.
  10 s at the default interval), re-seeded each time so one lucky sample cannot dominate.
  *(样本 = 真实偏移 − 该信标自身的单向时延,因此每个样本都偏低,最大者最优;取最小值恰好是反的。
  窗口长度由 `TDMA_CLOCK_REPORT_BEACONS` 决定(默认 1000,即默认间隔下 10 秒),每窗重新播种,
  避免某个偶然的好样本永久主导估计。)*
- **What the recovered clock is not (恢复出来的时钟不是什么)**: the estimate still contains the
  minimum one-way latency (beacon airtime plus driver and scheduler delay), so the gateway time
  it reports runs behind the real gateway clock by at least that much. It is a measurement aid —
  stamping samples, detecting drift — and not a synchronisation-accuracy figure. It is
  deliberately NOT fed back into the slot timer: slot timing comes from beacon arrival, which is
  self-consistent across nodes, whereas a latency-biased clock estimate would only make it worse.
  *(估计值仍包含最小单向时延(信标空口时长 + 驱动与调度延迟),因此它报出的网关时间至少滞后真实
  网关时钟这么多。它是测量辅助——给样本打时间戳、检测漂移——而不是同步精度指标。它**刻意不反馈**
  进时隙定时器:时隙时序来自信标到达时刻,这在各节点间是自洽的,而带时延偏差的时钟估计只会让它更糟。)*
- **Lifecycle: stop and deinit on both roles (两个角色都补上 stop / deinit)**: the component
  had no way back. `esp_tdma_master_stop()` / `esp_tdma_slave_stop()` halt the engine while
  keeping it initialized, and `esp_tdma_master_deinit()` / `esp_tdma_slave_deinit()` tear it
  down completely — deleting both timers, stopping the slave TX task and freeing its ring
  buffer, clearing the configuration — so a reconfiguration no longer needs a reboot. The
  timers were previously created and never deleted, and the ring buffer was allocated and
  never freed, so every init/deinit cycle leaked both.
  *(组件此前没有回头路:`stop()` 停引擎但保留初始化,`deinit()` 彻底拆除——删除两个定时器、
  停掉从节点 TX 任务并释放其环形缓冲、清空配置,改配置不再需要重启。此前定时器只创建不删除、
  环形缓冲只 malloc 不 free,每次 init/deinit 都会泄漏。)*
- **The TX task stops by itself (TX 任务自行退出)**: deinit cannot safely call
  `vTaskDelete()` on a task that may be inside `esp_now_send()`. It sets a flag and wakes the
  task through the same notification it uses for slots; the task then deletes itself. Deinit
  waits up to a second, and if the task has not exited it reports `ESP_ERR_TIMEOUT` and
  deliberately leaves the ring buffer allocated rather than freeing memory a live task could
  still be reading.
  *(deinit 不能对可能正处在 `esp_now_send()` 中的任务调 `vTaskDelete()`。改为置标志并用时隙
  通知唤醒,任务自行删除。deinit 最多等 1 秒;若任务未退出则返回 `ESP_ERR_TIMEOUT` 并**故意不释放**
  环形缓冲,而不是释放一个活任务可能还在读的内存。)*
- **An explicit role, not a bool (角色改为显式三态)**: `s_role_is_master` could not express
  "no engine is running". Deinit leaves the ESP-NOW callbacks registered — there is no
  unregister that is safe when another stack may own them — so with a bool the dispatcher
  would have fallen through to the *slave* handler after a master deinit and processed
  packets with uninitialized slave state. It is now a tri-state
  (`NONE` / `MASTER` / `SLAVE`) and a packet that arrives with no engine running is dropped.
  *(布尔量表达不了"没有引擎在跑"。deinit 会保留 ESP-NOW 回调(其他协议栈可能在用时没有安全的
  注销方式),因此用布尔量会导致 master deinit 之后收包落进 **slave** 处理器、拿未初始化的从节点
  状态去处理报文。现在改为三态,无引擎时直接丢包。)*
- **The ring buffer's allocation failure is no longer silent (环形缓冲分配失败不再静默)**:
  `tdma_ringbuf_init()` returned `void`, so a failed `malloc` only left `buffer == NULL` and
  the node ran on as a node that could never transmit a payload. It now returns `bool`, and
  `esp_tdma_slave_init()` refuses to initialize with `ESP_ERR_NO_MEM`. The later failure paths
  in that function roll back what they already allocated instead of leaking it.
  *(此前 malloc 失败只是让 `buffer == NULL`,节点会"活着但永远发不出数据"。现在返回 `bool`,
  `esp_tdma_slave_init()` 以 `ESP_ERR_NO_MEM` 拒绝初始化;该函数后续的失败路径也会回滚已分配资源。)*
- **`TDMA_MAX_NODES` default is now self-consistent (默认值自洽)**: the default was 15 while
  the real ceiling is the ESP-NOW encrypted-peer capacity, whose default is 7 — the Kconfig
  help text said so itself. An out-of-the-box configuration was therefore rejected by
  `esp_tdma_master_init()`. The default is now 7, matching that capacity.
  *(默认值原为 15,而真实上限是 ESP-NOW 加密配对容量、其默认值为 7——Kconfig 自己的 help 就写着
  这句话,因此开箱配置会被 `esp_tdma_master_init()` 拒绝。默认值改为 7。)*

- **A deleted timer could survive its own teardown (被删除的定时器可能活过自己的 deinit)**:
  `esp_tdma_slave_deinit()` stops the slot timer and then waits up to a second for the TX task
  to leave, while the ESP-NOW receive callback stays registered on purpose. A beacon arriving
  in that window was still handled (the state is IDLE, but the beacon path does not gate the
  arming on it) and re-armed the slot timer through `trigger_my_slot()`. `esp_timer_delete()`
  refuses a timer that is still armed — it returns `ESP_ERR_INVALID_STATE` and deletes nothing
  — so the handle was dropped while the timer stayed in the esp_timer list, firing
  `on_slot_timer_tick` for the rest of the program's life. Removing the timer is the normal
  case rather than an edge case: the wait is at least one 10 ms poll and the default beacon
  interval is also 10 ms. `trigger_my_slot()` now returns immediately in
  `TDMA_STATE_IDLE`, so a stopped node arms nothing.
  *(deinit 先停表、再等 TX 任务退出,而接收回调是刻意保留注册的;等待窗口里到达的信标此前仍会走到
  `trigger_my_slot()` 把时隙定时器重新装上。`esp_timer_delete()` 对已装填的定时器会返回
  `ESP_ERR_INVALID_STATE` 且什么都不删,于是句柄被丢掉、定时器却留在链表中继续每帧触发。由于等待
  至少一个 10 ms 轮询、默认信标周期也是 10 ms,这是常规路径而非边界情形。现在 `trigger_my_slot()`
  在 `TDMA_STATE_IDLE` 下直接返回。)*
- **`stop()` now really stops registration traffic (stop() 现在真的停发注册报文)**:
  the join and REG_ACK branches sit deliberately in front of the `state != TDMA_STATE_RUNNING`
  guard, so a node that was stopped while still `REGISTERING` kept `s_slave_reg_ack_pending`
  set and went on announcing itself into a frame it had been told to leave. The TX task now
  rejects `TDMA_STATE_IDLE` before both branches, which is what the header already promised:
  "stop listening and transmitting".
  *(join 与 REG_ACK 分支刻意排在 `state != TDMA_STATE_RUNNING` 守卫之前,因此在 REGISTERING
  状态下被 stop 的节点会继续往已经被告知离开的帧里发注册报文。TX 任务现在在两个分支之前拒绝
  `TDMA_STATE_IDLE`,与头文件"停止收发"的承诺一致。)*
- **The beacon-loss stamp can no longer tear between tasks (信标丢失时间戳不再跨任务撕裂)**:
  the node's last-beacon time is written by the Wi-Fi receive task and read by the esp_timer
  watchdog, and a 64-bit access is two 32-bit accesses on this target, so the reader could
  combine half of one write with half of the next. A torn value differs from a real timestamp
  by about 4295 s, which is far past any timeout, so this is a liveness verdict rather than a
  statistic. It is now a 32-bit millisecond stamp: a 32-bit aligned access cannot tear, and
  the unsigned difference stays exact for any age below 49.7 days while the timeout is capped
  at 10 s by Kconfig. `volatile` was not sufficient and a critical section would have put a
  lock in the beacon receive path.
  *(节点最近信标时间由 Wi-Fi 接收任务写、esp_timer 看门狗读,而本平台上 64 位访问是两次 32 位
  访问,读者可能把一次写入的高半部分与下一次的低半部分拼起来;撕裂值会偏差约 4295 秒,远超任何
  超时,因此影响的是"链路是否还活着"的判断。现改为 32 位毫秒时间戳:对齐的 32 位访问不会撕裂,
  无符号差值在age 小于 49.7 天时精确,而超时上限被 Kconfig 限制在 10 秒。)*
- **A refused timer deletion is no longer silent (定时器删除被拒不再静默)**:
  both deinit paths discarded the return value of `esp_timer_delete()`, which is the one call
  that reports whether a timer actually went away. They now go through `tdma_delete_timer()`,
  which logs the refusal and names the timer, so this class of leak cannot come back quietly.
  *(两条 deinit 路径此前都丢弃了 `esp_timer_delete()` 的返回值,而这个返回值正是判断定时器是否
  真正被删除的唯一依据。现在统一经 `tdma_delete_timer()`,被拒时会带定时器名报错,此类泄漏不会再
  悄悄回来。)*
- **`esp_tdma_slave_deinit()` now clears the role, like the master (从节点 deinit 现在与主节点
  一样清空角色)**: the master deinit relies on `s_role = TDMA_ROLE_NONE` to drop packets that
  arrive after teardown, and stated so in a comment; the slave deinit never did it, so the
  dispatcher kept routing packets into the slave handler for the whole teardown and afterwards.
  *(主节点 deinit 依赖 `s_role = TDMA_ROLE_NONE` 来丢弃拆除后到达的报文,并在注释里写明了这一点;
  从节点 deinit 一直没有做,导致拆除期间及之后收包仍会进入从节点处理器。)*
- **The callback-context section was wrong about the state callback (回调上下文一节对状态回调的
  描述有误)**: it said the state callback comes from the esp_timer task, but
  `esp_tdma_set_state()` is public, so calling it — or calling `esp_tdma_master_stop()` /
  `esp_tdma_slave_stop()` — invokes the callback in the caller's own task. The section now says
  so, and draws the consequence the old text hid: two invocations can run concurrently.
  *(该节称状态回调来自 esp_timer 任务,但 `esp_tdma_set_state()` 是公开 API,调用它或调用
  `stop()` 都会在调用者自己的任务里触发回调。文档已更正,并补上旧文字掩盖的后果:两次回调可能并
  发。)*
- **The enqueue contract now says "one producer" (入队契约改为一生产者)**: it read
  "Thread-safe … may be called from any task", while the buffer is SPSC and the write index is
  read-modify-written. Two producers corrupt the queue. The contract now names the single
  producer, and deinit's rule against racing an enqueue is stated alongside it.
  *(原文为"线程安全……可从任意任务调用",但该缓冲区是 SPSC 且写索引是读-改-写,两个生产者会破坏
  队列。契约现在明确单生产者,并同时写明 deinit 不得与入队并发。)*
- **The master's jitter timestamp is left as a documented race (主节点抖动时间戳按已记录的竞态保留)**:
  the same 64-bit crossing exists for the beacon stamp used to score slot jitter, and it is
  deliberately not fixed: it only feeds the mean/max/min of a diagnostic log line and no
  control decision (the slot step comes from `compute_slot_step_us()`), a harmful tear needs
  the microsecond counter's low word to wrap inside a few cycles, and an exact fix would mean
  a critical section in the beacon receive path. The trade-off is now written where the
  variable is declared instead of being implied by a bare `volatile`.
  *(用于抖动统计的信标时间戳存在同样的 64 位跨任务访问,此处刻意不改:它只进入一条诊断日志的
  均值/最大/最小值,不参与任何控制决策(时隙步长来自 `compute_slot_step_us()`),而有害撕裂还
  需要微秒计数器低 32 位正好在那几个周期内回绕,精确修复则要在信标接收路径里加临界区。该取舍现在
  写在变量声明处,而不是靠一个孤零零的 `volatile` 暗示。)*

## 1.5.0
- **BREAKING — battery removed from the wire format and the API (破坏性变更:电量移出线格式与接口)**:
  the component used to carry a per-packet `battery_pct` and call a weak
  `battery_get_percentage()` hook, which is exactly the coupling 1.2.0 removed for OTA: the
  MAC layer has no business being the courier for an application's battery gauge. The field
  cost every data packet a byte and forced every application to supply a hook.
  `tdma_data_pkt_t`'s header is now 7 bytes instead of 8 (the uplink `packet_seq` and
  `payload` offsets shift accordingly, so 1.5.0 does not interoperate with 1.2.0–1.4.0), and
  `on_data_received()` no longer takes a `battery` argument. Report the battery inside your own
  payload, where the MAC layer never has to know what it means.
  *(组件此前逐包携带 `battery_pct` 并调用弱符号 `battery_get_percentage()`,这正是 1.2.0 为 OTA
  清掉的那类耦合:MAC 层没有理由当应用电量表的搬运工。该字段让每个数据包白占 1 字节,还强制每个应用
  提供一个钩子。`tdma_data_pkt_t` 包头由 8 字节变为 7 字节(上行 `packet_seq` 与 `payload` 偏移随之
  前移,因此 1.5.0 与 1.2.0–1.4.0 不互通),`on_data_received()` 不再有 `battery` 参数。电量请放进
  应用自己的载荷里,MAC 层无需知道其含义。)*
- **The NFC provisioning format is gone from the component (配网格式移出组件)**: the header
  declared `tdma_nfc_payload_t` and `TDMA_NFC_MAGIC`, plus a `cmd_type` with "join / wake
  only" semantics — and nothing in the component ever read any of it: no ST25DV access, no
  magic check, no CRC check, no handler for `cmd_type`. It was a format definition with zero
  implementation, describing a mailbox that the component never touches. Because the NFC
  read/write lives in the application, the format belongs there too; the component's inbound
  interface is unchanged, so provisioning code keeps calling
  `esp_tdma_slave_set_config(node_id, gateway_mac, node_unicast_lmk, beacon_cmac_key)` and
  `esp_tdma_master_register_node(node_id, mac_addr, node_unicast_lmk)` exactly as before.
  For reference, the layout that used to be declared here was, little-endian and packed:
  `uint16 magic_word(0xAA55); uint8 cmd_type; uint8 node_id; uint8 gateway_mac[6];
  uint8 node_unicast_lmk[16]; uint8 beacon_cmac_key[16]; uint32 crc32;` — 46 bytes, with the
  CRC-32 covering the preceding 42.
  *(头文件曾声明 `tdma_nfc_payload_t`、`TDMA_NFC_MAGIC` 以及带 "join / wake only" 语义的
  `cmd_type`,而组件从未读过其中任何一个:没有 ST25DV 访问、没有 magic 校验、没有 CRC 校验、
  没有 `cmd_type` 的处理分支。那是一份零实现的格式定义,描述的邮箱组件根本不碰。既然 NFC 读写属于
  应用,格式也应归应用;组件的入站接口未变,配网代码照旧调用 `esp_tdma_slave_set_config()` 与
  `esp_tdma_master_register_node()`。作为参考,原先在此声明的布局为小端紧凑排布:…… 共 46 字节,
  CRC-32 覆盖前 42 字节。)*
- **Kconfig (配置项)**: `TDMA_PAYLOAD_SIZE`'s upper bound rises from 242 to 243, because the
  uplink header is one byte shorter; the help text now derives the ceiling from the 7-byte
  header instead of the old 8-byte one. *(上行包头缩短 1 字节,`TDMA_PAYLOAD_SIZE` 上限由 242
  提到 243,help 文本改为按 7 字节包头推导上限。)*
- **Compile-time guards (编译期守卫)**: the uplink header offset is now pinned with
  `_Static_assert` alongside the beacon, REG_ACK and downlink ones, so the next accidental
  padding change fails the build instead of desynchronising a deployed fleet.
  *(上行包头偏移现与信标、REG_ACK、下行一同用 `_Static_assert` 钉死。)*
- **Compatibility (兼容性)**: the uplink layout change means 1.5.0 does not interoperate with
  1.2.0–1.4.0; update the whole fleet together. 1.4.0 itself was additive and did interoperate
  with 1.2.0/1.3.0. *(上行布局变更意味着 1.5.0 与 1.2.0–1.4.0 不互通,需全网同版本升级;
  1.4.0 本身是纯增量,与 1.2.0/1.3.0 互通。)*

## 1.4.0
- **Downlink payload channel (下行载荷通道)**: the gateway had no way to send a payload to a
  node. The only thing it could transmit was the beacon, whose single opaque `user_state`
  byte is an application mode, not data — the payload path was effectively uplink-only.
  `esp_tdma_master_send_downlink(node_id, data, len)` closes that gap: it unicasts a payload
  to one node and the node receives it through the new
  `esp_tdma_slave_cfg_t::on_downlink(seq, data, len)` callback.
  *(网关此前无法向节点下发载荷——它唯一能发的就是信标,而信标里那个不透明的 `user_state`
  字节是应用模式而非数据,载荷通道事实上只有上行。`esp_tdma_master_send_downlink()` 补齐了这一侧:
  单播给指定节点,节点通过新增的 `on_downlink` 回调收取。)*
- **Sent from the frame's tail margin (从帧尾余量发出)**: the downlink is transmitted
  `BEACON_TAIL_MARGIN_US` into the frame, after the last node's slot has cleared and before
  the next beacon — a window that was previously reserved and completely unused. One packet
  per frame, i.e. 100 packets/s at the default 10 ms interval, so it can never collide with
  uplink traffic. The new `TDMA_DOWNLINK_GUARD_US` (default 400 us) covers the worst-case
  airtime of the last node's packet plus its link-layer ACK. If the configured tail margin
  cannot fit a packet, the downlink is refused with an error at startup rather than silently
  dropping traffic.
  *(下行在帧内 `BEACON_TAIL_MARGIN_US` 时刻发出——最后一个节点的时隙已清空、下一个信标尚未到来,
  这块窗口此前只是预留、完全没用。每帧一包(默认 10 ms 下 100 包/秒),因此不可能与时隙上行冲突。
  新增 `TDMA_DOWNLINK_GUARD_US`(默认 400 µs)覆盖最后节点报文的最坏空口时长加链路 ACK。若配置的
  尾部余量装不下一个包,启动时就报错并拒发,而不是静默丢包。)*
- **Encrypted and acknowledged (可加密、有链路 ACK)**: because the downlink is unicast it goes
  through the node's ESP-NOW peer entry, so a node that registered with a unicast LMK receives
  it encrypted, and ESP-NOW's link-layer ACK gives a real delivery figure:
  `esp_tdma_master_get_downlink_sent_ok()` / `_sent_fail()`, plus `_dropped()` for queue
  overflow and `_pending()`. The receiving side reports
  `esp_tdma_slave_get_downlink_rx_count()`, so a delivery ratio can be computed without
  trusting the sender's own counters. Each packet carries a per-target sequence number, so the
  application can measure downlink loss from gaps.
  *(因为是单播,它走节点的 ESP-NOW peer 表项:以单播密钥注册的节点收到的是密文;ESP-NOW 的链路层
  ACK 给出真实送达结果。接收侧上报 `esp_tdma_slave_get_downlink_rx_count()`,因此投递率可以在不信任
  发送方计数的情况下算出来。每包带逐目标序号,应用可由缺口算出下行丢包。)*
- **Validation on the receiving side (接收侧校验)**: a unicast frame proves nothing about its
  sender, so a downlink is accepted only when the source address equals this node's gateway and
  the target ID matches this node; a `payload_len` that exceeds the bytes actually received is
  rejected too. All three drop paths are counter-limited to avoid console flooding.
  *(单播帧本身不能证明发送方身份,因此只有源地址等于本节点的网关、且目标 ID 等于本节点时才接受;
  `payload_len` 超过实收字节数同样拒绝。三条丢弃路径都做了计数限频,避免刷屏。)*
- **Send status callback (发送状态回调)**: the master now registers `esp_now_register_send_cb()`
  to attribute link-layer ACKs. The callback is shared with every other transmission, so it
  filters by destination MAC; the one known limitation is documented in the code — two
  in-flight downlinks to the *same* node could mis-count one ok/fail pair, which cannot
  mis-deliver anything.
  *(网关注册发送状态回调以归因链路 ACK。该回调与所有其他发送共用,因此按目的 MAC 过滤;唯一已知
  局限写在代码里:同一节点上有两个在途下行时可能误记一次 ok/fail,但不会造成误投递。)*
- **Compatibility (兼容性)**: the downlink adds a packet type but leaves the beacon layout
  alone, so 1.4.0 interoperates with 1.2.0/1.3.0 in both directions. An older node ignores
  downlink packets, since its receive path only recognises the beacon, and simply never
  receives one. *(下行只新增包型、不改信标布局,因此 1.4.0 与 1.2.0/1.3.0 双向互通;旧节点收包路径
  只识别信标,会静默忽略下行包,即收不到而已。)*

## 1.3.0
- **No on-air change (空口格式不变)**: 1.3.0 is API-only, so a 1.3.0 device interoperates
  with 1.2.0 — but not with 1.1.x, whose beacon layout differs. *(1.3.0 只动 API,不动空口格式,
  因此与 1.2.0 可以互通;但与信标布局不同的 1.1.x 仍不兼容。)*
- **BREAKING — one flat state machine (破坏性变更:合并为单一扁平状态机)**: the component had
  two state machines. `esp_tdma_state_t` (IDLE / REGISTERING / RUNNING / SILENT_ERROR) is the
  one the engine actually acts on: it gates slave transmission and the beacon-loss watchdog
  sets `SILENT_ERROR`. `esp_tdma_link_state_t` (OFFLINE / REGISTERING / CONNECTED) was a pure
  *projection* of it, kept in sync by `esp_tdma_slave_update_link_state()`, i.e. a second
  variable, a second callback and a synchronisation routine carrying no information the state
  did not already have. The projection is deleted, along with
  `esp_tdma_master_get_state()` / `_set_state()` and `esp_tdma_slave_get_state()` /
  `_set_state()`, which collapse into one `esp_tdma_get_state()` / `esp_tdma_set_state()` pair.
  The four state values keep their names, so a `node_fsm.c` guard on
  `TDMA_STATE_SILENT_ERROR` keeps compiling.
  *(组件原本有两套状态机。`esp_tdma_state_t` 是引擎真正据以行动的:它把关从节点发送,信标丢失
  看门狗设置的就是 `SILENT_ERROR`;而 `esp_tdma_link_state_t` 只是它的**投影**,靠
  `esp_tdma_slave_update_link_state()` 保持同步——等于多一个变量、多一个回调、多一段同步逻辑,
  却不含状态本身没有的任何信息。投影层已删除,四个角色专属的 get/set 也合并为一对
  `esp_tdma_get_state()` / `esp_tdma_set_state()`。四个状态取值保留原名,因此
  `node_fsm.c` 里对 `TDMA_STATE_SILENT_ERROR` 的守卫仍可编译。)*
- **BREAKING — leftover compatibility surface removed (破坏性变更:清掉遗留兼容面)**:
  - `esp_tdma_slave_enqueue()` — a wrapper around
    `esp_tdma_slave_enqueue_with_policy(..., ESP_TDMA_QUEUE_DISCARD_STALE)` with no behaviour
    of its own.
  - `esp_tdma_restore_recv_cb()` — an OTA-era helper whose own comment said "for the OTA
    case"; 1.2.0 removed the component's knowledge of OTA, so it no longer belongs here.
    An application that shares the radio with espressif/esp-now's managed component
    re-registers its own receive callback, which is its own concern.
  - `TDMA_PKT_LOG` and `tdma_log_pkt_t` — dead code: nothing ever sent a log packet and the
    master only ever parsed DATA and REG_ACK.
  - The commented-out master registration FSM left in `master_handle_rx()` since 1.0.x.
  *(删掉四类遗留:无自身行为的入队包装器;注释自称"OTA 场景专用"的 `esp_tdma_restore_recv_cb()`
  (1.2.0 已移除组件对 OTA 的认知,它不再属于这里);从未被发送也从未被解析的 `TDMA_PKT_LOG`
  / `tdma_log_pkt_t` 死代码;以及 1.0.x 起留在 `master_handle_rx()` 里的整段注释掉的注册状态机。)*
- **The master now reaches RUNNING on start (网关启动即进入 RUNNING)**: `esp_tdma_master_start()`
  moves the state to `TDMA_STATE_RUNNING`, and initialization no longer leaves it at
  `REGISTERING` — a master registers with nobody, so that value was simply wrong. This is a
  behaviour fix, not cosmetics: the PER window is gated on `RUNNING`, so before this change
  no application that did not set the state by hand ever accumulated a PER window, and AFH
  could therefore never engage. An application that wants to hold the engine back until
  every node has reported in can still call `esp_tdma_set_state(TDMA_STATE_REGISTERING)`.
  *(启动即 RUNNING,初始化不再把它留在 REGISTERING——网关不向任何人注册,那个取值本身就是错的。
  这是行为修复而非装饰:PER 窗口以 RUNNING 为前提,改动前任何没有手工设状态的应用都永远不会积累出
  PER 窗口,AFH 因此永远无法触发。应用若想等所有节点上报后再放行,仍可自己调
  `esp_tdma_set_state(TDMA_STATE_REGISTERING)`。)*
- **Internal ring buffer no longer in the public header (内部环形缓冲移出公开头)**:
  `tdma_payload_t`, `tdma_ringbuf_t` and the four `tdma_ringbuf_*()` functions moved from
  `include/esp_tdma_mac.h` into a private `src/tdma_ringbuf.h`. Applications only ever hand
  bytes to the enqueue call and never see an entry or a ring, so publishing them only made
  the API look larger than it is. *(应用只需要把字节交给入队接口,永远看不到 entry 或 ring,
  公开它们只会让 API 看起来比实际更大。)*

## 1.2.0
- **BREAKING — on-air format (空口格式破坏性变更)**: the beacon is now 27 bytes, the NFC
  provisioning payload 46 bytes and the REG_ACK 18 bytes. `sys_state` is gone, replaced by
  `slot_step_us`, `tx_suspended` and the opaque `user_state`; the firmware-version and
  slot-offset fields are gone entirely. A 1.2.0 master can talk to nothing but 1.2.0 slaves:
  the whole fleet must be updated together. *(信标 27 字节、NFC 配网包 46 字节、REG_ACK 18 字节;
  `sys_state` 被 `slot_step_us`、`tx_suspended` 与不透明的 `user_state` 取代;固件版本与时隙偏移
  字段被彻底删除。1.2.0 网关只能与 1.2.0 节点通信,必须全网同版本升级。)*
- **BREAKING — API (接口破坏性变更)**:
  - `esp_tdma_slave_set_config_ex()` and the legacy 4-argument `esp_tdma_slave_set_config()`
    are merged into one `esp_tdma_slave_set_config(node_id, gateway_mac, node_unicast_lmk,
    beacon_cmac_key)`. The slot offset is no longer a parameter.
  - `on_node_registered(node_id)` replaces `on_node_registered(node_id, fw_ver, upgrade_required)`.
  - `on_sys_state_changed` is replaced by `on_user_state_changed`, fed by the beacon's opaque
    `user_state` byte.
  - `TDMA_STATE_OTA` is removed from `esp_tdma_state_t`.
  *(接口合并与回调重命名;时隙偏移不再是参数;OTA 状态从状态机移除。)*
- **The OTA coupling is gone (移除 OTA 耦合)**: the component no longer knows what OTA is. The
  firmware-version handshake was provably inert — both sides always reported `20260622` and
  `upgrade_required = false` — and the beacon's `sys_state` field, which drove slave transitions,
  was the application's business state leaking into the link layer. Firmware versioning and OTA
  belong entirely to the application payload now.
  *(组件不再知道 OTA 是什么。固件版本握手经查证是死代码——两端恒为 `20260622` 与
  `upgrade_required = false`;而驱动从节点状态跳转的信标 `sys_state` 字段,本质是业务状态
  渗入链路层。固件版本与 OTA 现在完全归应用载荷所有。)*
- **Generic network-wide TX suspension (通用全网发送暂停)**: new
  `esp_tdma_master_set_tx_suspended()` / `is_tx_suspended()` and `esp_tdma_slave_is_suspended()`.
  Suspension gates the *entire* slave TX path — user payloads and the REG_ACK alike — so the
  application can hand the radio to another stack and take it back deterministically. The master
  keeps beaconing while suspended, so the fleet stays synchronised and resumes on the next beacon.
  A caller must wait at least three beacon periods after setting the flag, because nodes already
  mid-transmission can only observe the change at their next beacon.
  To make that safe, the slave's packet sequence counter **keeps advancing while suspended** and
  the master **suspends its own PER evaluation**, resetting the window on resume: otherwise the
  silent window would be scored as a burst of losses and trip a spurious AFH hop on the very first
  packet back.
  *(暂停闸门覆盖从节点**全部**发送路径(含 REG_ACK);暂停期间网关仍广播信标,全网保持同步。
  置位后调用方须等待至少三个信标周期。为此从节点序号在暂停期间**继续递增**、网关**暂停 PER 评估**
  并在恢复时重置窗口,否则静默期会被判为一串丢包、恢复瞬间误触发跳频。)*
- **Slot geometry moved into the beacon (时隙几何改由信标下发)**: the beacon carries
  `slot_step_us` and each node derives its own offset as `node_id * slot_step_us`. Nothing about
  the frame geometry is provisioned any more, so resizing the frame or rebooting a node after an
  OTA needs no re-provisioning. The step's divisor is the **highest node ID in use**, not a node
  count: offsets are positional, so a sparse ID range would otherwise push the top node past the
  end of the frame. The node validates the advertised step against its own offset before arming
  its slot timer. *(信标携带 `slot_step_us`,节点自行推导偏移。除数取**当前占用的最高节点号**而非
  节点数量——偏移是位置量,稀疏 ID 会把最大号节点顶出帧外。节点会先校验再装定时器。)*
- **`active_nodes_bitmask` is now read (该字段现在真的被读取)**: it was written but never
  consumed. Bit `node_id - 1` now decides whether a node is admitted to the schedule, replacing the
  global `sys_state` transition. A consequence is that a gateway reboot recovers by itself: the
  master's registry starts empty, every node sees its bit clear, returns to REGISTERING and
  re-announces. *(该字段过去只写不读。现在由 `node_id - 1` 位决定节点是否纳入调度,取代全局
  `sys_state` 跳转。附带效果:网关重启可自愈——登记册为空→节点发现自己的位被清零→回到
  REGISTERING 重新宣告。)*
- **Beacon-loss detection (信标丢失检测)**: a node that receives no valid beacon for
  `CONFIG_TDMA_BEACON_LOSS_TIMEOUT_MS` (default 500 ms) reports `TDMA_STATE_SILENT_ERROR` and
  `ESP_TDMA_LINK_OFFLINE` instead of staying in RUNNING on an undisciplined clock, and
  re-registers by itself when beacons return. Beacons that fail CMAC or replay checks do not count
  as received. *(超时未收到有效信标即进入 `TDMA_STATE_SILENT_ERROR`;验签或重放校验失败的信标
  不计入"收到";信标恢复后自动重新注册。)*
- **PER window no longer freezes after a node restarts (节点重启不再冻结 PER)**: a node's
  sequence counter rolling backwards was not handled, so after a reboot the window could not
  advance until the node caught up — minutes of frozen or bogus PER, and an AFH decision based on
  it. The window is now reset when the sequence rolls back, when TX resumes, and when AFH hops.
  *(节点序号回绕此前未处理,重启后窗口长时间无法推进,PER 冻结甚至失真并影响跳频决策。现在在
  序号回退、暂停恢复与跳频时都会重置窗口。)*
- **`TDMA_PKT_CMD_OTA` and the 54-byte legacy provisioning layout are gone (删除 OTA 命令码与旧配网布局)**.
  The NFC payload is 46 bytes with its CRC over the preceding 42 bytes.
  *(NFC 配网包改为 46 字节,CRC 覆盖前 42 字节。)*
- **Kconfig**: new `TDMA_BEACON_LOSS_TIMEOUT_MS`; `TDMA_SLOT_STEP_US` renamed in the menu to
  `Fallback time slot step per node` and re-documented, since the master derives the real step.
  *(新增信标丢失超时;`TDMA_SLOT_STEP_US` 菜单名与说明改为"兜底值"。)*

## 1.1.3
- **24 Mbps rate lock migrated to the per-peer API (24 Mbps 速率锁定迁移到逐 peer 接口)**: the
  component now calls `esp_now_set_peer_rate_config()` for every peer it installs — node peers,
  the gateway peer and the broadcast beacon peer — instead of the interface-wide
  `esp_wifi_config_espnow_rate()`, which is deprecated since IDF 5.2 and **removed in IDF 6.0**.
  A peer whose rate cannot be locked is not registered and does not start, because at the 1 Mbps
  default its packets would overrun the slot frame. The beacon peer only warns, since a 22-byte
  beacon at 1 Mbps still fits inside the frame.
  The `(phymode, rate)` pairing is `WIFI_PHY_MODE_11G` + `WIFI_PHY_RATE_24M` (24 Mbps is a legacy
  OFDM rate). This pairing is **not documented anywhere in the ESP-IDF documentation** — it was
  reported upstream as [espressif/esp-idf#18766](https://github.com/espressif/esp-idf/issues/18766),
  which is still open — so it was **verified on ESP32-S3 hardware** instead: the driver accepts it
  and `esp_now_set_peer_rate_config()` returns `ESP_OK`.
  *(无法锁定速率的 peer 不予注册、不予启动,因为 1 Mbps 默认值下其数据包会贯穿时隙帧;信标 peer
  仅告警,因为 22 字节信标在 1 Mbps 下仍装得进帧。配对为 `WIFI_PHY_MODE_11G` + `WIFI_PHY_RATE_24M`
  (24 Mbps 是 11g legacy OFDM 速率)。该配对在 ESP-IDF 文档中**无任何记载**——已作为
  espressif/esp-idf#18766 上报上游且仍未关闭——因此改为**在 ESP32-S3 实机上验证**:驱动接受该配对,
  `esp_now_set_peer_rate_config()` 返回 `ESP_OK`。)*
- **Manifest (清单)**: minimum IDF raised to `>= 5.2.0` (the version that introduced the per-peer
  API) and the `< 6.0.0` upper bound dropped, since the component is now forward-compatible with
  IDF 6.0. *(最低版本提到 `>= 5.2.0`(逐 peer 接口的引入版本),并移除 `< 6.0.0` 上限——组件已向前
  兼容 IDF 6.0。)*
- **Radio configuration ownership (射频配置归属)**: the component now pins the protocol bitmap,
  bandwidth and power-save mode **only when it performed the Wi-Fi initialization itself**. If the
  application brought Wi-Fi up, those settings belong to the application: the component verifies
  them instead of overriding them, logging an error for every premise that does not hold (HT20,
  11G/11N enabled, LR disabled, power save off).
  *(组件现在**仅在自己完成 Wi-Fi 初始化时**固定协议位图、带宽与省电模式。若 Wi-Fi 由应用启动,这些
  设置归应用所有:组件改为校验而非覆盖,并对每条不成立的前提报错。)*
- **Documentation correction (文档更正)**: the 1.0.7 entry claimed the OTA state had been removed
  from the core state machine. It had not been; the field and the state were still there and still
  drove transitions. That removal is now planned for 1.2.0, and the 1.0.7 wording has been corrected
  to say what actually happened.
  *(修正 1.0.7 的条目:它声称已把 OTA 状态从核心状态机中移除,实际并未移除——字段与状态都还在,
  且仍在驱动状态跳转。该移除改为在 1.2.0 完成,1.0.7 的措辞已改为与事实一致。)*
- No on-air format change and no public API removal: 1.1.3 is drop-in compatible with 1.1.2.
  *(无空口格式变更、无公开接口删除:1.1.3 可直接替换 1.1.2。)*

## 1.1.2
- **Slave-side robustness: the same "fail loudly" treatment the master got in 1.1.0
  (从节点侧健壮性:与 1.1.0 主节点侧同等的"失败即显式报错")**:
  - `esp_tdma_slave_set_config_ex()` now checks `esp_now_add_peer()` for the gateway peer.
    Unicast ESP-NOW requires a peer entry, so a failure there means the node can never
    transmit; previously the error was discarded and the node ran as a silent dead node.
    *(从节点配置现在检查网关 peer 的安装结果。单播 ESP-NOW 必须有 peer 条目,安装失败意味着
    该节点永远发不出数据;此前该错误被直接丢弃,节点会以"静默死节点"的形态运行。)*
  - **Behaviour change:** `esp_tdma_slave_start()` returns `ESP_ERR_INVALID_STATE` when no
    gateway peer is installed, instead of always returning `ESP_OK`.
    *(**行为变更**:未成功安装网关 peer 时 `esp_tdma_slave_start()` 返回 `ESP_ERR_INVALID_STATE`,
    不再无条件返回 `ESP_OK`。)*
  - Checked the remaining unchecked calls: `esp_now_register_recv_cb()` (master and slave init),
    `xTaskCreatePinnedToCore()` (slave init now fails with `ESP_ERR_NO_MEM`),
    `esp_timer_start_once()` (slot arming) and the beacon `esp_now_send()` — a failed beacon
    send used to cost every node a frame with no trace in the log.
    *(补齐其余未检查的调用:`esp_now_register_recv_cb()`(主/从初始化)、`xTaskCreatePinnedToCore()`
    (从节点初始化失败时返回 `ESP_ERR_NO_MEM`)、`esp_timer_start_once()`(时隙定时器装载)以及信标
    的 `esp_now_send()`——此前信标发送失败会让全网丢一帧且日志里毫无痕迹。)*
  - Added NULL guards to the public entry points that memcpy from caller pointers:
    `esp_tdma_master_register_node()`, `esp_tdma_master_get_node_mac()` and
    `esp_tdma_slave_set_config_ex()`.
    *(为会从调用方指针 memcpy 的公开接口补上 NULL 防护。)*
- **README trimmed (README 精简)**: the radio-parameter notes and the 11-row transmit-power
  quantisation table were **removed from the README entirely**. They documented ESP-IDF API
  semantics and internal pinning rather than what a user of this component needs to decide, and a
  duplicated IDF table would silently rot. What the component actually does is unchanged and is
  described in the source comments and in this changelog. The two constraints users actually hit —
  the encrypted-peer node-count cap and the unassociated-station requirement for AFH — stay at the
  top of the page in compressed form.
  *(README 删除了射频参数说明与 11 行发射功率量化表:它们讲的是 ESP-IDF 的 API 语义与组件内部固定项,
  而非使用者需要做的决策,且复制 IDF 的表格只会悄悄过时。组件实际行为未变,改由源码注释与本更新日志
  描述。用户真正会踩的两条约束(加密配对节点数上限、AFH 需未关联 AP)压缩后保留在页面顶部。)*
- **Manifest**: added an `issues` link for the registry page. *(清单补充 `issues` 链接。)*
- No API removal and no on-air format change: 1.1.2 is drop-in compatible with 1.1.1 apart from the
  stricter `esp_tdma_slave_start()` failure signalling described above.
  *(无接口删除、无空口格式变更:除上述 `esp_tdma_slave_start()` 更严格的失败信号外,1.1.2 可直接替换 1.1.1。)*

## 1.1.1
- **Pinned RF parameters the TDMA schedule depends on (固定时序所依赖的射频参数)**: when the
  component owns the Wi-Fi initialization it now also sets the protocol bitmap to
  `11B | 11G | 11N` and the bandwidth to `HT20`, and logs a single confirmation line. The
  bitmap deliberately excludes `WIFI_PROTOCOL_LR`: the proprietary long-range mode is mutually
  exclusive with OFDM and therefore cannot coexist with the 24 Mbps data rate. The bandwidth
  matters because the AFH channels 1/6/11 are only non-overlapping at HT20 — at HT40 a channel
  spans its neighbours and the hopping premise collapses.
  *(组件负责 Wi-Fi 初始化时,现会显式固定协议位图 `11B|11G|11N` 与带宽 `HT20`,并打印一行确认日志。
  位图刻意排除 `WIFI_PROTOCOL_LR`——该长距离模式与 OFDM 互斥,无法与 24 Mbps 速率共存;带宽则因为
  AFH 的 1/6/11 只在 HT20 下互不重叠,40 MHz 会横跨邻道、使跳频前提失效。)*
- **AFH re-checks its own premise at hop time (跳频时复检前提)**: the master now re-reads the
  bandwidth immediately before switching channel and logs an error if it is no longer HT20, so a
  runtime change cannot silently turn a hop into a move onto an equally interfered channel.
  *(主节点在换信道前会重新读取带宽,若非 HT20 则报错,避免运行时改动悄悄让跳频落到同样受干扰的信道。)*
- **Transmit power documented (发射功率文档化)**: the READMEs now state that transmit power is an
  application decision and document the full quantisation table of
  `esp_wifi_set_max_tx_power()`. In particular set value 32 yields **7.0 dBm, not 8 dBm** —
  8 dBm is not reachable at all; the nearest steps are 7.0 dBm and 8.5 dBm.
  *(README 说明发射功率属应用决策,并给出完整量化映射表:设定值 32 实得 7.0 dBm 而非 8 dBm,
  8 dBm 不在可取值集合内。)*
- No API or on-air format change: 1.1.1 is drop-in compatible with 1.1.0.
  *(无 API 与空口格式变更:1.1.1 与 1.1.0 可直接替换。)*

## 1.1.0
- **Robustness: fail loudly instead of silently (健壮性:失败即显式报错)**:
  - The 24 Mbps PHY-rate lock is now checked and **fatal on failure**. ESP-NOW defaults to
    1 Mbps, where a 235-byte packet occupies ~1.88 ms of airtime and overruns the slot frame;
    a silent fallback used to corrupt the whole TDMA schedule with no visible error.
    *(24 Mbps 速率锁定改为检查返回值并在失败时中止初始化:ESP-NOW 默认 1 Mbps,235 字节单包空口
    约 1.88 ms,会贯穿整个时隙帧;旧实现静默回退会导致全网时序崩坏且无任何报错。)*
  - `esp_tdma_master_register_node()` now installs the ESP-NOW peer **before** committing the
    registry entry, and escalates the failure to `ESP_LOGE`. Previously a node was marked
    registered even when `esp_now_add_peer()` failed, leaving a phantom "online" node whose
    traffic could never be delivered. *(节点登记改为「peer 安装成功后才写入登记册」,失败升级为
    ESP_LOGE;此前 peer 安装失败仍会标记为已注册,形成永远收不到数据的假在线节点。)*
  - `esp_tdma_master_init()` rejects a `target_node_count` larger than the ESP-NOW
    **encrypted-peer capacity** (`CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM`, default 7, max 17;
    ESP32-C2 max 4). Each node is an encrypted peer, so the previous "up to 15" claim was
    silently capped at 7 by the IDF default. *(主节点初始化会校验目标节点数不超过 ESP-NOW 加密
    配对容量(默认 7、最大 17);此前「最多 15 节点」的说法在默认配置下实际只能到 7。)*
  - Both AFH channel switches (master and slave) now check `esp_wifi_set_channel()` and act on
    failure: the master aborts and re-arms the hop instead of desynchronising the network, and
    the slave keeps its target and retries on the next beacon instead of silently staying put.
    `esp_wifi_set_channel()` cannot change the channel while the station is associated with an
    AP, and `esp_tdma_master_init()` now probes that capability at startup.
    *(主/从节点跳频均检查换信道返回值:主节点失败则中止并重新计时而非让全网失步;从节点保留目标
    并在下一个信标重试。`esp_tdma_master_init()` 新增启动时换信道能力探测。)*
  - The AFH trigger now requires a successful `esp_wifi_get_channel()` read, so the network can
    no longer be hopped blind from an unknown channel. *(AFH 触发前必须成功读到当前信道,避免从
    未知信道盲目跳频。)*
- **Power-save mode pinned (省电模式显式化)**: `WIFI_PS_NONE` is set explicitly when the
  component owns the Wi-Fi initialization, so the radio is never parked in modem sleep while the
  slot timer depends on beacon reception. *(组件负责初始化 Wi-Fi 时显式设置 `WIFI_PS_NONE`。)*
- **RSSI reported with the PER window (PER 窗口附带 RSSI)**: the per-node report now includes the
  last and mean RSSI taken from `esp_now_recv_info_t::rx_ctrl`, giving the channel-quality input
  that a PER-only AFH decision lacks. *(每节点 PER 上报新增末次与平均 RSSI,取自收包回调的
  `rx_ctrl`,为纯 PER 的跳频决策补上信道质量输入。)*
- **Documentation corrections (文档修正)**: the package length comment in `tdma_protocol.h`
  is corrected (54, not 38 bytes); the README now defines what the reported "jitter" actually
  measures (end-to-end slot arrival deviation, not slot-timer accuracy); the AFH countdown
  wording no longer claims that a node missing the entire warning window is still recalled; the
  `tdma_data_pkt_t` payload ceiling is documented as a deliberate design choice rather than a
  protocol limit (ESP-NOW v2.0 raises it to 1470 bytes); and the node example's slot offset is
  made consistent with the gateway example. *(修正 NFC 包长注释、补充 jitter 指标口径定义、修正
  跳频预警窗措辞、澄清 242 字节上限为设计选择而非协议上限、对齐示例时隙偏移。)*
- **IDF version bound (IDF 版本上限)**: the manifest now declares `idf: ">=5.0.0,<6.0.0"` because
  `esp_wifi_config_espnow_rate()` (still used for the rate lock) is removed in ESP-IDF v6.0.
  *(因 `esp_wifi_config_espnow_rate()` 在 v6.0 被移除,清单加上 `<6.0.0` 上限。)*
- **Adaptive Slot Step & Runtime Node Count (自适应时隙步长与运行时节点数)**:
  Ported the master's runtime slot-step algorithm. The slot step is now
  `(CONFIG_TDMA_BEACON_INTERVAL_MS * 1000 - CONFIG_TDMA_BEACON_TAIL_MARGIN_US) / target_node_count`,
  clamped to `CONFIG_TDMA_SLOT_STEP_MIN_US`. Adds `esp_tdma_master_set_target_node_count()`,
  `esp_tdma_master_get_target_node_count()` and `esp_tdma_master_get_slot_step_us()`.
  *(引入自适应时隙步长:时隙步长 = (信标周期 - 尾部余量) / 目标节点数,并按最小保护带钳位;
  新增目标节点数运行时读写与时隙步长查询接口。)*
- **Beacon Authentication (信标 CMAC 验签)**: Beacons now carry a 4-byte AES-128-CMAC tag
  (`tdma_beacon_t::cmac_tag`, beacon grows to 22 bytes). The master signs beacons after
  `esp_tdma_master_set_beacon_key()`; slaves provisioned with a `beacon_cmac_key` silently
  drop beacons that fail verification. *(信标携带 CMAC 标签,网关签名、节点验签,抵御伪造信标。)*
- **Replay Protection (重放保护)**: Slaves reject beacons whose `global_time_us` does not
  advance, and automatically resynchronise when the master reboots or restarts its clock.
  *(节点拒绝时间戳不前进的信标,并在网关重启后自动重新对齐基线。)*
- **Dual-Key NFC Provisioning (双密钥配网)**: `tdma_nfc_payload_t` is now 54 bytes, carrying
  both `node_unicast_lmk` and `beacon_cmac_key` in place of the 1.0.x single 16-byte key.
  *(配网包由 38 字节单密钥升级为 54 字节双密钥:单播加密密钥 + 信标验签密钥。)*
- **Node Registry Query APIs (节点登记册查询接口)**: `esp_tdma_master_get_node_mac()`,
  `esp_tdma_master_get_node_lmk()` (for re-provisioning without rotating keys) and
  `esp_tdma_master_is_node_online()` (6 s heartbeat timeout).
- **24 Mbps PHY Rate Lock (物理速率锁定)**: ESP-NOW is pinned to `WIFI_PHY_RATE_24M`.
  *(1 Mbps 下 235 字节单包空口时长高达 1.88 ms,远超 700 µs 时隙并会贯穿后续节点时隙;
  锁定 24 Mbps 后压缩至约 78 µs,是 TDMA 时序能够闭环的物理前提。)*
- **Registration Handshake Fix (注册握手修复)**: REG_ACK is now transmitted whenever it is
  pending instead of being gated on the slave still being in `TDMA_STATE_REGISTERING`.
  *(旧逻辑下若信标已把节点状态推到 RUNNING,REG_ACK 将永不发出,节点永远无法完成注册。)*
- **RX Robustness (接收健壮性)**: the master drops data packets whose `payload_len` exceeds
  the actually received byte count; `safe_err_to_name()` guards against NULL error strings;
  the SPSC ring buffer now asserts a power-of-two capacity, which its bitmask wrap-around
  addressing silently depends on.
- **OTA Callback Restore (OTA 后回调恢复)**: `esp_tdma_restore_recv_cb()` re-installs the TDMA
  packet dispatcher after the managed `espressif/esp-now` component overwrote the raw receive
  callback. *(OTA 完成后用于恢复 TDMA 收包分发。)*
- **Configurable TX Task Placement (发送任务放置可配置)**: new Kconfig options
  `TDMA_TX_TASK_CORE` / `TDMA_TX_TASK_PRIORITY` / `TDMA_TX_TASK_STACK`,
  `TDMA_PER_REPORT_PACKETS`, `TDMA_BEACON_TAIL_MARGIN_US`, `TDMA_SLOT_STEP_MIN_US`.
  These expose the variables needed for slot-timing experiments (core, priority,
  evaluation cadence, frame geometry) without recompiling the component.
- **API compatibility (API 兼容)**: `esp_tdma_slave_set_config()` keeps its 1.0.x 4-argument
  signature (beacon authentication disabled) and delegates to the new
  `esp_tdma_slave_set_config_ex()`. Existing applications keep compiling unchanged.
- **Air-format break (空口格式变更)**: beacon 18 -> 22 bytes, NFC payload 38 -> 54 bytes.
  A 1.1.0 master is **not** interoperable with 1.0.x slaves, and vice versa — update the whole
  fleet together. *(空口不兼容,必须全网同版本升级。)*

## 1.0.7
- **Architecture Decoupling (架构解耦与重构)**:
  - Decoupled Wi-Fi & ESP-NOW auto-initialization. Added `.skip_wifi_init` configurations and `ESP_TDMA_AUTO_INIT_WIFI` Kconfig option. *(解耦 Wi-Fi 与 ESP-NOW 自动初始化,增加 `.skip_wifi_init` 配置以及相应的 Kconfig 选项。)*
  - Decoupled data queue policies. Added selectable FIFO or DISCARD_STALE policies to allow custom application queueing logic. *(解耦数据排队策略,支持在入队时传入 FIFO 或零延迟覆盖(DISCARD_STALE)模式。)*
  - Decoupled application business states: introduced a generic system-state notification callback plus decoupled link-state updates. Correction: contrary to what this entry originally claimed, the OTA state was NOT removed from the core state machine in 1.0.7 — `TDMA_STATE_OTA` and the beacon `sys_state` field were still present and still drove slave transitions. Their removal is completed in 1.2.0. *(解耦业务状态机:引入通用系统状态通知回调与解耦的链路状态更新。更正:与本条最初的说法相反,1.0.7 并未把 OTA 状态从核心状态机中移除——TDMA_STATE_OTA 与信标 sys_state 字段都还在,且仍在驱动从节点状态跳转。其移除在 1.2.0 完成。)* *(解耦业务状态机,将 OTA 从链路同步状态机中剥离,仅通过回调回调通知高级系统状态更新。)*
- **Bilingual Documentation (中英双语自述)**: Synchronized examples and changelog in both English and Chinese. *(全面同步了示例说明和更新日志的双语支持。)*
- **Backward Compatibility (向后兼容层)**: Fully preserved legacy structures, callbacks, and the original 2-argument enqueue function signature to guarantee zero disruption to existing projects. *(完全保留原状态回调和双参数入队函数,保障主项目编译零报错、稳定运行。)*

## 1.0.6
- **Multilingual Support (多语言支持)**: Added `readme_zh.md` to support English/Chinese toggle on the registry page. *(新增 `readme_zh.md` 支持组件库网页端的英/中双语切换。)*
- **Bilingual Updates (双语日志与代码注释)**: Added Chinese comments to example codes and translated changelog. *(为示例项目代码添加了中文注释,并翻译了更新日志。)*

## 1.0.5
- **Changelog Separation (独立更新日志)**: Refactored version history into a dedicated `CHANGELOG.md` file to enable the Changelog tab in the registry. *(将版本历史整理至独立的 `CHANGELOG.md` 文件中,启用组件库独立的 Changelog 选项卡。)*

## 1.0.4
- **Registry Version Sync (组件库版本同步)**: Synchronized component version with ESP Component Registry release. *(同步代码版本号与组件库发布版。)*

## 1.0.3
- **Example Projects Added (添加示例程序)**: Added `gateway_example` and `node_example` under the `examples/` directory to show how to use the TDMA component. *(在 `examples/` 目录下新增了网关和从节点示例,展示组件的使用方法。)*
- **Example Documentation (示例说明文档)**: Added example `README.md` documentation for both Gateway and Node roles. *(为网关和从节点示例分别编写了 `README.md` 说明文件。)*

## 1.0.2
- **Homepage Migration (迁移主页及仓库)**: Migrated component homepage and repository URL to `https://github.com/yangcong-bit/esp-now-tdma`. *(迁移组件主页及开源仓库地址。)*

## 1.0.1
- **Zero-Latency Stale Frame Discarding (零延迟数据丢弃策略)**: Added backlog cleanup in the Slave's TX loop. Discards older frames and keeps only the newest frame, guaranteeing zero accumulation latency (ideal for high-rate sensors like IMUs). *(在从节点的发送循环中加入过载清理机制,自动丢弃旧帧保留最新单帧,消除高频传感器如 IMU 带来的累积延迟。)*
- **Automatic Wi-Fi & ESP-NOW Initialization (免样板无线初始化)**: Encapsulated Wi-Fi (STA mode) and ESP-NOW startup boilerplate inside `init_wifi_and_espnow()`. *(将 STA 模式 Wi-Fi 及 ESP-NOW 初始化封装进组件内部,免去应用层手动编写样板代码。)*
- **Dynamic Key Exchange & Registration (动态密钥交换与注册)**: Encapsulated `aes_lmk` in `tdma_reg_ack_t`, allowing the Master to dynamically register and modify peers with encryption upon joining. *(在注册响应包中携带 AES 局部主密钥,允许网关在节点加入时动态配置加密 Peer。)*
- **Dynamic State Synchronization (状态自动跳变)**: Slave nodes now autonomously transition to `RUNNING`, `REGISTERING`, or `OTA` based on the Master's broadcasted Beacon status (`sys_state`). *(节点根据接收到的 Beacon 系统状态标志,自动跳变至 `RUNNING`、`REGISTERING` 或 `OTA` 状态。)*
- **Console Log Flooding Prevention (控制台日志防刷限流)**: Added log throttling for `esp_now_send` failures in the Slave's TX task (logs only the first 5 errors and then throttles to once every 100 failures). *(限制发包失败日志打印频率,前 5 次错误正常输出,之后每 100 次失败才打印一次。)*
- **Enhanced Telemetry & Diagnostics (增强型遥测与链路诊断)**: Upgraded `TX Task SLOT REACHED` to output ring buffer and transmission counters (`rb_empty`, `send_ok`, `send_fail`, `reg_ack_ok/fail`), and added Master RX diagnostics logs. *(诊断日志输出新增发送计数和环形队列状态,网关端新增注册回调日志。)*
- **UTF-8 Transcoding (编码转换)**: Transcoded source files to clean UTF-8. *(将所有源文件转码为 UTF-8 编码。)*

## 1.0.0
- Initial release. *(初始版本发布。)*