Skip to content

[lightning-ln882h] Fix duplicate MAC addresses on LN882H (fixes #338)#381

Open
Bl00d-B0b wants to merge 1 commit into
libretiny-eu:masterfrom
Bl00d-B0b:lightning/ln882h-unique-mac
Open

[lightning-ln882h] Fix duplicate MAC addresses on LN882H (fixes #338)#381
Bl00d-B0b wants to merge 1 commit into
libretiny-eu:masterfrom
Bl00d-B0b:lightning/ln882h-unique-mac

Conversation

@Bl00d-B0b
Copy link
Copy Markdown
Contributor

@Bl00d-B0b Bl00d-B0b commented May 24, 2026

Fixes #338

Problem

Every LN882H device ships with the same factory-default MAC address (00:50:C2:5E:10:88) stored in KV flash. sysparam_integrity_check_all() (called during lt_init_family()) writes this placeholder on first boot and never replaces it. Every device on the same network therefore shares one MAC, causing ARP conflicts and connection failures.

Fix

Add lt_init_unique_mac() called immediately after sysparam_integrity_check_all() in lt_init.c. It resolves the MAC address in priority order — once, on first boot after flash. Every subsequent boot returns in step 1 immediately.

Priority order

Step 1 — sysparam KV already unique
Read the stored STA MAC via sysparam_sta_mac_get(). If it differs from the factory sentinel, return immediately. This is the fast path on every boot after the first, and fully covers the LibreTiny→LibreTiny OTA case: the sysparam KV lives at 0x1E0000, which is outside the OTA partition and is never erased by OTA.

Step 2 — Restore Tuya factory MAC from BLK_V1.0 KV
Tuya assigns every LN882H a unique MAC at the factory and writes it as a second 6_sta_mac entry in a BLK_V1.0 append-log KV store at flash offset 0x1C5000. Scan for the last valid non-sentinel entry and restore it to sysparam KV.

This region lies within the OTA partition (0x1330000x1DD000), but no partition layout changes are needed: OTA writes are block-addressed and the current firmware (~530 KB) ends at ~0x1B4700, well short of 0x1C5000. UART full-flash similarly only covers 0x0000000x088000. The Tuya KV is naturally preserved under both flash methods.

Step 3 — TRNG fallback
If the Tuya KV is absent or erased (e.g. UART flash with full-chip erase option), generate a unique address via ln_generate_random_mac() (hardware TRNG, already compiled in via ln882h_net). This mirrors the approach used in OpenBeken.

In all cases the SoftAP MAC is derived by setting the locally-administered bit (mac[0] |= 0x02) on the STA MAC.

Analysis

This fix is based on binary examination of 5 unique Tuya LN882H flash dumps (4× DS-101JL wall switch + 1× GU10 bulb from issue #338), including devices in three states: never provisioned, provisioned via Tuya app, and provisioned and in use for weeks.

Key findings:

  • The unique MAC is factory-assigned at manufacturing time, not during Tuya app provisioning — even devices that were never connected to a Tuya account carry a unique 6_sta_mac entry
  • The BLK_V1.0 KV region (0x1C50000x1C9000, 4 blocks × 4 KB) has an identical layout across all examined devices: sentinel at 0x1C51EB, unique MAC at 0x1C5423
  • BLK_V1.0 keys are not null-terminated; the format is it_magic(8) + crc16(2) + val_size_u16_le(2) + prev_ptr_u32_le(4) + key_raw(N) + value_raw
  • LibreTiny's OTA writer (uf2_write) uses offset = part->offset + block->addr — it only erases sectors for UF2 blocks actually present in the image; no block targets 0x1C5000

Detailed analysis is posted as a comment on this PR.

Safety

  • lt_tuya_kv_read_sta_mac() is read-only; it never writes to flash
  • ln_generate_random_mac() is not thread-safe but is called before the RTOS scheduler starts
  • The sentinel check (memcmp against {0x00,0x50,0xC2,0x5E,0x10,0x88}) ensures existing devices with a persisted unique MAC in sysparam KV are never affected

@Bl00d-B0b Bl00d-B0b force-pushed the lightning/ln882h-unique-mac branch 3 times, most recently from 502e397 to c10548a Compare May 24, 2026 07:40
@Bl00d-B0b
Copy link
Copy Markdown
Contributor Author

Bl00d-B0b commented May 24, 2026

Real-world validation — migration from WiFi.setMacAddress() workaround

Tested on DS-101JL (BSEED) dual gang wall switches with LN882H (ln-cb3s-v1.0).

Previously these devices used a manual WiFi.setMacAddress() workaround in ESPHome config to assign unique MACs, since all devices boot with the factory default 00:50:C2:5E:10:88. LibreTiny's WiFi.setMacAddress() implementation persists the MAC to sysparam KV via sysparam_sta_mac_update().

After flashing firmware with this fix applied:

  • lt_init_unique_mac() reads the sysparam KV MAC
  • Finds it is not the factory default (already set by the previous workaround)
  • Returns immediately — preserving the existing unique MAC, no flash scan needed

Removing the WiFi.setMacAddress() workaround from config and reflashing via OTA: devices retain the same MACs across reboots and OTA updates, confirmed via ARP.

This means the fix handles all cases correctly:

  1. Device with existing unique MAC in sysparam KV (previous workaround, or any boot after the first) — step 1 fast path, no action taken
  2. Fresh device, Tuya KV intact (standard UART flash or Kickstart/cloudcutter OTA from Tuya stock) — step 2 restores the Tuya factory-assigned MAC from BLK_V1.0 KV; no new address generated
  3. Fresh device, Tuya KV erased (UART flash with full-chip erase) — step 3 generates a unique MAC via hardware TRNG as fallback

In cases 2 and 3 the resolved MAC is persisted to sysparam KV at 0x1E0000 (outside the OTA partition, never erased by OTA), so every subsequent boot returns in step 1 immediately.

@kuba2k2
Copy link
Copy Markdown
Member

kuba2k2 commented May 24, 2026

See my comments here:
#338 (comment)
#338 (comment)
#338 (comment)
#338 (comment)

TL;DR: Generating a MAC address randomly is a workaround. A real solution would be to adjust the partition layout, or - as mentioned in the linked thread - create a separate board definition for Tuya devices.
Then, LibreTiny should read the address from that KV store. No random generation or writing new addresses should be needed, as it's already there on every Tuya device.
It seems to be correct that Tuya firmware generates it randomly and writes it to storage - and we're here to read it from there, instead of generating new ones.

@Bl00d-B0b Bl00d-B0b force-pushed the lightning/ln882h-unique-mac branch from c10548a to 5dfd201 Compare May 25, 2026 06:27
@Bl00d-B0b Bl00d-B0b changed the title [lightning-ln882h] Generate unique MAC on first boot via TRNG [lightning-ln882h] Restore Tuya factory MAC on first boot; fall back to TRNG May 25, 2026
@Bl00d-B0b
Copy link
Copy Markdown
Contributor Author

Bl00d-B0b commented May 25, 2026

Flash dump analysis — MAC recovery across all use cases

I analyzed 5 unique 2 MB flash dumps from Tuya LN882H devices (plus verified binary details of the OTA write path in LibreTiny itself) to confirm the approach and document why no partition changes are needed.


Devices analyzed

Dump Device State before dump Active 6_sta_mac
D1 DS-101JL wall switch Tuya-provisioned, ~1 month in use 00:33:7A:2B:86:95
D2 DS-101JL wall switch Tuya-provisioned, briefly BC:35:1E:BE:41:EB
D3 DS-101JL wall switch Never provisioned 00:33:7A:2B:84:1D
D4 DS-101JL wall switch Never provisioned 00:33:7A:2B:87:C6
GU10 Tuya GU10 bulb (from #338) Tuya-provisioned C0:F8:53:CF:3A:83

BLK_V1.0 KV structure — confirmed across all devices

Every device has the same 6_sta_mac layout in its first KV block:

Offset Entry Value Note
0x1C51EB 6_sta_mac #1 00:50:C2:5E:10:88 Factory sentinel (sysparam_factory_setting.h)
0x1C5423 6_sta_mac #2 device-unique Active MAC — last entry wins in BLK_V1.0 log

Important: BLK_V1.0 keys are not null-terminated. The format is:

it_magic[8] | crc16[2] | val_size_u16_le[2] | prev_ptr_u32_le[4] | key_raw[N] | value_raw[val_size]

A parser must use known key lengths, not null scanning.

The unique MAC is factory-assigned, not provisioning-assigned. D3 and D4 were never connected to a Tuya account (no ty_ada_psk WiFi credential entries) yet both carry a unique 6_sta_mac at 0x1C5423. The MAC is written at manufacturing time.

Provisioned devices additionally accumulate ty_ada_psk entries (139 B each, home WiFi SSID+PSK in plaintext) appended on each reconnect — D1 has 7 copies after ~1 month. This is normal BLK_V1.0 compaction behaviour and does not affect the MAC entries.


Why the Tuya KV at 0x1C5000 is preserved — no partition changes needed

The Tuya KV occupies 0x1C50000x1C9000 (4 x 4 KB blocks), which falls inside LibreTiny's OTA partition (0x1330000x1DD000). At first glance this looks like a problem. In practice it isn't, for both flash paths:

OTA (Kickstart / cloudcutter / LibreTiny OTA update)

uf2_write() uses per-block addressing:

uint32_t offset = part->offset + block->addr;  // physical = OTA_base + UF2_addr
flash->ops.erase(offset, block->len);           // only this sector is erased

The current LibreTiny firmware for LN882H contains UF2 blocks with addresses 0x0000000x081700 (~530 KB). To reach 0x1C5000, a block address of 0x1C5000 - 0x133000 = 0x92000 would be needed. The firmware is 66 KB short of that threshold. No UF2 block targets 0x1C5000; the sector is never erased.

UART full-flash (image_firmware.0x000000.bin)

The UART flash image is ~548 KB and covers physical addresses 0x0000000x088000. The flash tool erases and writes only those sectors. 0x1C5000 is not covered.

Conclusion: The Tuya KV is naturally preserved under both flash methods as long as the firmware stays below ~592 KB (~66 KB of headroom remaining). Shrinking the OTA partition to protect this region is not recommended — the trade-off (680 KB → 584 KB, losing 96 KB of firmware capacity) is not worth it for MAC address preservation alone when a software solution covers all realistic flash scenarios.


Three use cases — MAC resolution behaviour

Use case 0x1C5000 on first LT boot Result
UART flash (standard, no full-chip erase) Intact Step 2: Tuya MAC restored
Kickstart / cloudcutter OTA from Tuya Intact Step 2: Tuya MAC restored
UART flash with full-chip erase All-FF Step 3: TRNG random MAC
LibreTiny to LibreTiny OTA Irrelevant Step 1: MAC already in sysparam KV

In all cases the MAC is persisted to sysparam KV at 0x1E0000 (outside OTA, never erased), so every subsequent boot hits step 1 immediately.

@Bl00d-B0b Bl00d-B0b force-pushed the lightning/ln882h-unique-mac branch 2 times, most recently from 50448cf to bfbfce3 Compare May 25, 2026 06:51
@Bl00d-B0b Bl00d-B0b changed the title [lightning-ln882h] Restore Tuya factory MAC on first boot; fall back to TRNG [lightning-ln882h] Fix duplicate MAC addresses on LN882H (fixes #338) May 25, 2026
@Bl00d-B0b Bl00d-B0b force-pushed the lightning/ln882h-unique-mac branch 2 times, most recently from c257f4f to 4fa56fd Compare May 25, 2026 07:39
@kuba2k2
Copy link
Copy Markdown
Member

kuba2k2 commented May 25, 2026

It's a good thing that the MAC address is not erased during OTA updates - yet. If someone builds firmware that's larger than that 0x92000, then it definitely will get erased.

Tuya designed such a partition layout where 0x1C5000 was used for KV. But they also use OTA.

Shrinking the OTA partition to protect this region is not recommended — the trade-off (losing OTA capacity) is not worth it for MAC address preservation alone

That doesn't quite make sense.
If we shrink OTA, we lose some capacity - not a problem since firmware is usually smaller, right?
If we don't shrink OTA, we might lose the MAC address and other calibration parameters if someone builds a large firmware - seems like a problem to me.

@Bl00d-B0b
Copy link
Copy Markdown
Contributor Author

Bl00d-B0b commented May 25, 2026

Design reflection: approach alignment and partition trade-off

On the Tuya KV reading approach

The current PR satisfies the stated preference from #338 and the PR comment above: step 2 reads the factory-assigned MAC directly from the Tuya BLK_V1.0 KV store. The TRNG (step 3) is a fallback only for the edge case where the KV region is erased, which happens exclusively with a full-chip erase UART flash. Under all normal flash paths (standard UART flash, Kickstart, cloudcutter OTA) the Tuya KV is intact and the factory MAC is restored without any random generation.

It is also worth noting that users migrating from a WiFi.setMacAddress() workaround — which is the most common real-world path — never reach step 2 at all. Their MAC is already unique in sysparam KV and step 1 returns immediately, preserving whatever address was previously set regardless of whether it matches the Tuya factory MAC. In practice this means the factory MAC recovery in step 2 only matters for a completely clean first flash with no prior MAC set, and even then the end result (a stable unique MAC persisted to sysparam) is identical whether it came from the Tuya KV or from TRNG.

On repartitioning

Adjusting the partition layout to protect 0x1C5000 would shrink the OTA partition from 680 KB to 584 KB — a permanent 96 KB reduction in maximum firmware size paid by all users, in exchange for protecting against a risk that only materialises if firmware ever exceeds ~592 KB. Current firmware is ~530 KB with 66 KB of headroom, and the resolved MAC is persisted to sysparam KV at 0x1E0000 which is outside the OTA partition and safe permanently regardless of firmware size.

There is also a practical deployment problem: the partition table sits at 0x006000, entirely outside the OTA partition (0x133000). OTA updates never touch it. Changing the partition layout therefore requires a full UART reflash of every device — it cannot be rolled out over the air. For devices already installed (e.g. wall switches mounted inside walls), this means physical access to each unit individually. That is not a realistic migration path.

Additionally, even with repartitioning there is no guarantee that future Tuya firmware releases will keep the MAC address at the same offset within the KV region. The BLK_V1.0 layout and the 6_sta_mac key placement at 0x1C5423 are observed facts from current devices — not a documented stable contract. If Tuya changes their factory provisioning process, the hardcoded offset assumption breaks regardless of partition protection.

Recommendation

Given the above, I would suggest following the same KISS approach already used by OpenBeken for LN882H: sysparam fast path → TRNG. Their implementation in wifi_init_sta() (src/hal/ln882h/hal_wifi_ln882h.c, lines 318–330) does exactly this — check sentinel, call ln_generate_random_mac(), persist to sysparam. LibreTiny is itself a new approach to these devices and should follow the same established pattern. Keeping the MAC resolution simple and dependency-free is more maintainable long term. Once the MAC is written to sysparam KV on first boot it is stable forever — whether it came from Tuya's factory or from TRNG makes no practical difference for a device behind NAT.

Summary

Option Factory MAC preserved OTA partition size
This PR (KV read + TRNG fallback) Yes 680 KB
TRNG only (KISS, recommended) No — generates new random MAC 680 KB
Repartition Yes (current Tuya layout only) 584 KB

The static IP fix (WiFiEvents.cpp, PR #375) is included in this PR and would remain regardless of which MAC approach is chosen — that part is independently useful and has been a long-standing gap for LN882H.

@kuba2k2
Copy link
Copy Markdown
Member

kuba2k2 commented May 25, 2026

I'm currently short on time, so I can't really look deeper into this issue - perhaps I'll be able to in a few days from now.

@Bl00d-B0b
Copy link
Copy Markdown
Contributor Author

Bl00d-B0b commented Jun 1, 2026

Hi @kuba2k2,

I appreciate the feedback and want to share where I stand, because I don't fully agree with the direction the discussion has taken.

For context: my original implementation used exactly the TRNG approach — check sysparam sentinel, generate via hardware TRNG, persist. I extended it to include Tuya BLK_V1.0 KV reading in response to the review feedback. After going through the full analysis I'm now coming back to the original approach, and here is why.

On repartitioning — not a viable option. The partition table lives at 0x006000, outside the OTA partition, so it can never be updated over the air. Any device already installed inside a wall or ceiling would need physical UART access. That rules it out for a fix that affects every LN882H device.

On a separate Tuya board definition — this is the same hardware regardless of original firmware. A board split adds maintenance overhead without solving the root problem.

TRNG is the right solution. OpenBeken uses exactly this approach and it works. The MAC is generated once on first boot, persisted to sysparam KV at 0x1E0000 (outside OTA, survives all flash operations), and never changes again. Simple, dependency-free, and proven.

The detailed BLK_V1.0 flash analysis already in this PR can live as documentation: we know exactly where the original factory MAC sits (0x1C5423, key 6_sta_mac) and what other parameters (power calibration etc.) are stored nearby. A user who wants to preserve their original Tuya-assigned MAC can take a firmware backup before flashing LibreTiny, read the address from that known offset, and set it once via WiFi.setMacAddress() in their ESPHome config. After the first boot it is persisted to sysparam KV and stays there permanently — the manual step is only needed once, and can then be removed from the config entirely. The same approach applies to other device-specific parameters stored in that region.


Wider context — full LN882H platform effort

This PR is part of a set of changes that together make LN882H a fully functional LibreTiny platform:

Everything is tested and working from my side. I'm ready to follow the review process properly.


What I need help with

Code ownership on ESPHomeln882h_ble_tracker is a new component and needs a CODEOWNERS entry in ESPHome. Do you know anyone familiar with LN882H or LibreTiny who would be a good fit as code owner? Alternatively, if you are willing to cover it yourself since you already own libretiny/* there, that would be great — but I'm equally happy with whoever you think is the right person.

Tests and reviewers — ESPHome PR esphome/esphome#16691 has a needs-tests label. I need to add tests/components/ln882h_ble_tracker/ YAML test files (compile-only, same pattern as other LibreTiny components). Do you know any contributors or LN882H users who could help review or validate on real hardware? I can provide ready-to-flash firmware builds.


On a broader note — BLE support is a genuinely significant addition to LibreTiny. LN882H has a full BLE 5.1 stack built into its SDK and it works well. Once this lands, the same approach could be extended to the BK7231N/T chipset which also has BLE hardware. Having passive BLE scanning and BTHome sensor support on these cheap widely-deployed chips opens up a lot of home automation use cases that weren't possible before. I think it's worth getting right.

Thanks for the project and for your time.

@kuba2k2
Copy link
Copy Markdown
Member

kuba2k2 commented Jun 1, 2026

First, "repartitioning" in this case doesn't mean modifying the partition table at 0x6000. I don't know what that table defines, but LibreTiny doesn't use it - instead it has its own, defined in board JSON files.
As is the case for BK72xx, pointing LT at the right calibration data offset should make the SDK use that data. So whatever's defined in the JSON file is used by LT, not what's written at 0x6000. That also allows to adjust the offsets easily in YAML by end users, should devices have vendor-specific or customized firmware.

Second - separate Tuya board definitions are already going to be implemented for BK7238 and perhaps even BK7231N. I know it's "just" the firmware and hardware stays the same (though even that's not always the case). Truth is, big vendors like Tuya use custom partitioning, sometimes custom encryption keys (like on Beken), modified bootloaders (which require specific support), and have their own KV storage in the flash (which should be avoided by LT to keep its original contents). That's all defined by a specific board JSON - and since virtually all boards use base JSON files as well, there's minimal maintenance overhead anyway. And it makes the board list way less ambiguous.
Most smaller vendors, in contrast, won't change their partition layouts, so MAC address will be in OTP or in KV storage somewhere else. Sometimes they might have a real OUI and not just randomly generated bytes, and we want to preserve that. Some people also use OEM devboards to either develop LibreTiny, ESPHome, or their own devices.

An lastly - what OpenBeken does is not considered the golden standard of what we should do. I've seen their code and odd solutions to simple problems way too many times, and many of them just aren't good enough to be used in LibreTiny. Take BK7238 support for example - instead of providing proper OTA support on Tuya bootloaders, they just overwrite it with a OEM bootloader, which leaves devices with mismatched bootloaders and calibration partition layouts, and people migrating from OBK to LT are having issues due to that.


I know there are more PRs related to LN882H support. It's a lot to review, and I have limited free time. Also note that I write these comments and review code without using AI agents.


As for the code owners, you should probably assign yourself - that's usually what people do. The one who creates code is the owner - seems simple. I definitely wouldn't be a good fit, since I haven't ever flashed a single LN882H device yet.

@Bl00d-B0b Bl00d-B0b force-pushed the lightning/ln882h-unique-mac branch 12 times, most recently from f9bdfea to a61f94f Compare June 2, 2026 15:22
@Bl00d-B0b Bl00d-B0b force-pushed the lightning/ln882h-unique-mac branch 3 times, most recently from 3178939 to e746402 Compare June 2, 2026 15:30
@Bl00d-B0b Bl00d-B0b force-pushed the lightning/ln882h-unique-mac branch from e746402 to 3ec72c5 Compare June 2, 2026 15:35
@Bl00d-B0b
Copy link
Copy Markdown
Contributor Author

Bl00d-B0b commented Jun 2, 2026

Hi @kuba2k2,

This PR has been updated to implement the board JSON approach you suggested. Here is a summary of what was done.

Board JSON (your requested architecture)

  • boards/_base/lightning-ln882hki-tuya.json — a new base fragment that adds a tuya_kv flash section (0x1C5000+0x4000). The board generator produces FLASH_TUYA_KV_OFFSET = 0x1C5000 and FLASH_TUYA_KV_LENGTH = 0x4000 for any board that includes this base.
  • boards/generic-ln882hki-tuya.json — a new buildable board for Tuya-manufactured LN882H devices, inheriting the standard generic-ln882hki chain plus the new Tuya base.

Non-Tuya boards (generic-ln882hki) are not affected.

lt_init.c — board-aware MAC recovery

The WiFi STA MAC resolution now uses FLASH_TUYA_KV_OFFSET from the board JSON as the gate:

  1. sysparam KV already unique → fast path, nothing to do
  2. #ifdef FLASH_TUYA_KV_OFFSET (Tuya boards only): scan BLK_V1.0 KV store for 6_sta_mac entry → restore factory MAC
  3. TRNG fallback — non-Tuya boards, or Tuya KV erased

The factory sentinel {0x00, 0x50, 0xC2, 0x5E, 0x10, 0x88} is defined once at file scope (WIFI_MAC_FACTORY_DEFAULT) and shared between both functions. The KV offset and region size come entirely from the board JSON — no hardcoded addresses in C.


BLE MAC (related work)

The same pattern extends to BLE MAC recovery in ESPHome PR esphome/esphome#16691. Tuya LN882H devices store a factory-assigned BLE MAC in the same BLK_V1.0 KV region under key 24_ble_mac. This has been confirmed across 4 real Tuya flash dumps. The ln882h_ble_tracker component uses FLASH_TUYA_KV_OFFSET (from the board JSON) to recover the original BLE MAC on first boot — the same #ifdef guard, the same zero-code impact on non-Tuya boards.

Confirmed from flash dumps: BLE_MAC = reverse(WiFi_MAC) with byte[0]+1, stored in little-endian format matching the BLE stack's ln_bd_addr_v_t directly.


Thanks again for guiding the architecture — the board JSON approach is clearly the right design.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LN882HKI - MAC address not recognized

2 participants