Open Thread Border Router (ESP32-C6)

Open Thread Boder Router (OTBR)サンプルコード

ESP32-C6の開発ボードにOTBRのサンプルファームウェアを書き込みます。

注)ESP32-C6はWi-Fi(2.4GHz)とThread(802.15.4, 2.4GHz)が同じ2.4GHz帯のRFを共有しているため、同時使用時は干渉を避けるためコエグジスタンス(共存)制御が行われます。このためスループットが下がる場合があります。

注)Wi-FiとThreadを独立して運用するため、ホスト(Wi-Fi担当)にESP32系チップ、RCP(Radio Co-Processor、Thread担当)にESP32-H2をUART接続した構成が選ばれます。

開発環境

VSCode + PlatformIO, esp32-c6-devkitc-1


platformio.ini

[platformio]
src_dir = main

[env:esp32-c6-devkitc-1]
platform = espressif32
board = esp32-c6-devkitc-1
framework = espidf

upload_port = /dev/ttyACM0
monitor_port = /dev/ttyACM0
monitor_speed = 115200

upload_flags =
    --before=default_reset
    --after=hard_reset

board_build.partitions = partitions.csv

OTBRサンプルコード

注) [platformio]でsrc_dir=mainを指定すれば、mainディレクトリをsrcディレクトリへ名称変更する必要なし

上記のplatformio.iniをプロジェクトフォルダot_brへコピー。

VSCodeのPIO HomeからOpen Projectをクリックし、ot_brフォルダを選択します。

以下のボード設定ファイルが自動生成されるので、WiFiとThreadを同時に有効にするため
一部編集します。

sdkconfig.esp32-c6-devkitc-1

#
# Wireless Coexistence
#
# default:
CONFIG_ESP_COEX_ENABLED=y
# default:
CONFIG_ESP_COEX_SW_COEXIST_ENABLE=y
# default:
# CONFIG_ESP_COEX_EXTERNAL_COEXIST_ENABLE is not set
# default:
# CONFIG_ESP_COEX_POWER_MANAGEMENT is not set
# default:
# CONFIG_ESP_COEX_GPIO_DEBUG is not set
# end of Wireless Coexistence

#
# ESP-STDIO
#
# default:
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
# default:
# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG is not set
# default:
# CONFIG_ESP_CONSOLE_UART_CUSTOM is not set
# default:
# CONFIG_ESP_CONSOLE_NONE is not set
# default:
# CONFIG_ESP_CONSOLE_SECONDARY_NONE is not set
# default:
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
# default:
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y
# default:
CONFIG_ESP_CONSOLE_UART=y
# default:
CONFIG_ESP_CONSOLE_UART_NUM=0
# default:
CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM=0
# default:
CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200
# end of ESP-STDIO

#
# Thread 15.4 Radio Link
#
CONFIG_OPENTHREAD_RADIO_NATIVE=y
# CONFIG_OPENTHREAD_RADIO_SPINEL_UART is not set
# CONFIG_OPENTHREAD_RADIO_SPINEL_SPI is not set
# CONFIG_OPENTHREAD_RADIO_154_NONE is not set
# end of Thread 15.4 Radio Link

ビルド(ビルドアイコンレまたは下記コマンド)

$ pio run
or
$ rm -rf .pio && pio run

PIOのバグ?によるエラー(131行をコメントアウト)

~/.platformio/packages/framework-espidf/components/openthread/CMakeLists.txt

PRIVATE
 "PACKAGE_VERSION=\"${IDF_VERSION_FOR_OPENTHREAD_PACKAGE}-${OPENTHREAD_VERSION}\""
#"OPENTHREAD_BUILD_DATETIME=\"${OT_BUILD_TIMESTAMP}\""
        )

ボード詳細確認

$ pio pkg exec -- esptool.py --port /dev/ttyACM0 flash_id

OTBRのWiFi、Thread設定

$ pio device monitor
esp32c6> ot wifi
---wifi parameter---
connect -s <ssid> -p <psk>               :       connect to a wifi network with an ssid and a psk
connect -s <ssid>                        :       connect to a wifi network with an ssid
disconnect                               :       wifi disconnect
state                                    :       get wifi state, disconnect, connect or reconnecting
mac <role>                               :       get mac address of wifi netif, <role> can be 'sta' or 'ap'
config                                   :       get stored wifi configurations
config clear                             :       clear stored wifi configurations
---example---
join a wifi:
ssid: threadcertAP
psk: threadcertAP                        :       wifi connect -s threadcertAP -p threadcertAP
join a wifi:
ssid: threadAP
does not have a psk                      :       wifi connect -s threadAP
get wifi state                           :       wifi state
wifi disconnect once                     :       wifi disconnect
get mac address of Wi-Fi station         :       wifi mac sta
get mac address of Wi-Fi soft-AP         :       wifi mac ap
get stored wifi configurations           :       wifi config
clear stored wifi configurations         :       wifi config clear
Done

esp32c6> ot help
attachtime
ba
bbr
br
bufferinfo
ccathreshold
ccm
channel
child
childip
childmax
childrouterlinks
childsupervision
childtimeout
coap
contextreusedelay
counters
csl
dataset
debug
delaytimermin
detach
deviceprops
diag
discover
dns
domainname
dua
eidcache
eui64
extaddr
extpanid
factoryreset
fake
fem
ifconfig
instanceid
ipaddr
ipmaddr
joinerport
keysequence
leaderdata
leaderweight
log
mac
meshdiag
mleadvimax
mliid
mode
multiradio
nat64
neighbor
netdata
netstat
networkdiagnostic
networkidtimeout
networkkey
networkkeyref
networkname
nexthop
panid
parent
parentpriority
partitionid
ping
platform
pollperiod
preferrouterid
prefix
promiscuous
pskc
pskcref
rcp
region
releaserouterid
reset
rloc16
route
router
routerdowngradethreshold
routereligible
routeridrange
routerselectionjitter
routerupgradethreshold
scan
service
singleton
srp
state
targetpower
test
thread
timeinqueue
tvcheck
txpower
udp
unsecureport
uptime
vendor
verhoeff
version
curl
heapdiag
ip
loglevel
mcast
tcpsockclient
tcpsockserver
udpsockclient
udpsockserver
wifi
Done

WiFi接続

esp32s6> ot wifi connect -s threadcertAP -p threadcertAP

WiFiステータス

esp32c6> ot wifi state

Threadネットワーク作成

esp32c6> ot dataset init new
esp32c6> ot dataset commit active
esp32c6> ot ifconfig up
esp32c6> ot thread start

Threadステータス

esp32c6> ot state

Threadネットワークの確認・設定

確認コマンド

esp32c6> ot dataset active -x

現在のデータセットを16進数で表示(全情報含む)

個別に確認する場合:

esp32c6> ot networkname
esp32c6> ot channel
esp32c6> ot panid
esp32c6> ot networkkey
esp32c6> ot extpanid

設定コマンド(新規作成の場合)

esp32c6> ot dataset init new
esp32c6> ot dataset networkname MyNetwork
esp32c6> ot dataset channel 15
esp32c6> ot dataset networkkey 00112233445566778899aabbccddeeff
esp32c6> ot dataset panid 0x1234
esp32c6> ot dataset commit active

既存ネットワークに参加する場合

他のThreadデバイスと同じネットワークキー・チャンネル・PANIDを設定してから:

esp32c6> ot ifconfig up
esp32c6> ot thread start

各種操作

sdkconfigファイルの設定・変更

以下のコマンドを実行するか、直接デバイスのsdkconfigファイルを編集して下さい。

$ pio run -t menuconfig
(Top)
                 Espressif IoT Development Framework Configuration
    Build type  --->
    App Update config  ----
    Bootloader config  --->
    Security features  --->
    Application manager  --->
    Boot ROM Behavior  --->
    ESP-TEE (Trusted Execution Environment)  --->
    Serial flasher config  --->
    ↓↓↓↓↓↓↓↓↓↓↓↓↓↓
[Space/Enter] Toggle/enter  [ESC] Leave menu                   [S] Save
[O] Load                    [?] Symbol info                    [/] Jump to symbol
[F] Toggle show-help mode   [C] Toggle show-name mode          [A] Toggle show-all mode
[Q] Quit (prompts for save) [D] Save minimal config (advanced) [R] Reset to default value

ビルド環境・パッケージの削除(リセット)

$ rm -rf .pio managed_components dependencies.lock

Open Thread CLI リファレンス

esp32c6> ot attachtime
2d.07:03:04
Done

esp32c6> ot bbr
BBR Primary:
server16: 0x0C00
seqno:    54
delay:    5 secs
timeout:  3600 secs
Done

OTBR推奨モデル

WiFiとThreadのRFを独立させるため、以下のモデルの組合わせによる構成をメーカは推奨しています。

Recommended main processor and RCP combinations:

Main Processor RCP Use Case
ESP32-P4 ESP32-C6 High-performance applications with AI/ML capabilities
ESP32-C5 ESP32-H2 Dual-band Wi-Fi (2.4 GHz + 5 GHz) support
ESP32-C61 ESP32-H2 Wi-Fi 6 (802.11ax) support
ESP32-S3 ESP32-H2 General-purpose with display/camera support

前記事のesp-idfのサンプルコードを参考に、下記の開発キット用otbrのコード(ESP32をWiFi用とThread用に2個必要)をesp32-c6-devkitc-1単独で適用できるか検討。ボードのビルド条件の変更に加え、コードの修正も必要。OTA以外の動作確認。

$ pio run -t menuconfig
(Top) → ESP Thread Border Router Example

    Border router board type (Standalone dev kits)  --->
    Board Configuration  --->
    Border router RCP target (ESP32-C6)  --->
[*] Enable the automatic start mode in Thread Border Router. (default value)
[*] Enable the web server in Thread Border Router. (default value)
[*] Enable SoftAP Wi-Fi configuration mode (default value)
(Top) → Component config → ESP-STDIO

    Channel for console output (Default: UART0)  --->
    Channel for console secondary output (USB_SERIAL_JTAG PORT)  --->
(Top) → Serial flasher config

[ ] Disable download stub (default value)
    Flash SPI mode (DIO)  --->
    Flash Sampling Mode (STR Mode)  --->
    Flash SPI speed (80 MHz)  --->
    Flash size (16 MB)  --->
[*] Detect flash size when flashing bootloader (default value)
    Before flashing (Reset to bootloader)  --->
    After flashing (Reset after flashing)  --->
(Top) → Config for OpenThread Examples

[*] Enable the automatic start mode of Thread network.
    External Console Commands  --->
(Top) → Component config → Wi-Fi

[ ] WiFi AMPDU TX
[ ] WiFi AMPDU RX

(20) Minimum active time (default value)
(Top) → Component config → OpenThread → OpenThread → Thread Core Features

[*] Enable timing optimization
(Top) → Component config → OpenThread → OpenThread → Thread Core Features → OpenThread Stack Parameters

(20) The size of max MLE children entries

(8) Maximum backoffs times before declaring a channel access failure.

注)ESP32-C6単体でWiFiとRCP機能を有効にしたため、下記GPIOピンの設定は不要。

sdkconfig.esp32-c6-devkitc-1

#
# Board Configuration
#
# default:
CONFIG_PIN_TO_RCP_RESET=7
# default:
CONFIG_PIN_TO_RCP_BOOT=8
# default:
CONFIG_PIN_TO_RCP_TX=4
# default:
CONFIG_PIN_TO_RCP_RX=5
# end of Board Configuration

#
# OpenThread RCP Update
#
# default:
# CONFIG_AUTO_UPDATE_RCP is not set
# CONFIG_CREATE_OTA_IMAGE_WITH_RCP_FW is not set
# default:
CONFIG_DEFAULT_PIN_TO_RCP_RESET=7
# default:
CONFIG_DEFAULT_PIN_TO_RCP_BOOT=8
# default:
CONFIG_DEFAULT_PIN_TO_RCP_TX=17
# default:
CONFIG_DEFAULT_PIN_TO_RCP_RX=18
# end of OpenThread RCP Update
(Top) → ESP Thread Border Router Example → Board Configuration
                                                                                                       Espressif IoT Development Framework Configuration
(7) Pin to RCP reset (default value)
(8) Pin to RCP boot (default value)
(4) Pin to RCP TX (default value)
(5) Pin to RCP RX (default value)
(Top) → Component config → OpenThread RCP Update
                                                                                                       Espressif IoT Development Framework Configuration
[ ] Update RCP automatically (default value)
[ ] Create the OTA image with rcp for border router
(7) Default Pin to RCP reset (default value)
(8) Default Pin to RCP boot (default value)
(17) Default Pin to RCP TX (default value)
(18) Default Pin to RCP RX (default value)

ソースのバックアップ(.pioとmanaged_componentsは除外)

$ tar -czf $(date +%Y%m%d)_esp32-otbr-example.tar.gz \
--exclude='examples/basic_thread_border_router/.pio' \
--exclude='examples/basic_thread_border_router/managed_components' \
--exclude='examples/basic_thread_border_router/dependencies.lock' \
examples

PCやモバイルでOTBRのアクセスポイントへ接続し、ブラウザで以下のURLへアクセス。

http://192.168.4.1

シリアルモニターで動作確認

$ pio devive monitor
esp32>ot wifi state
connected
Done

esp32c6> ot state
leader
Done

esp32c6> ot dataset active 
Active Timestamp: 1
Channel: 13
Wake-up Channel: 13
Channel Mask: 0xxxxxxxxxxxx
Ext PAN ID: xxxxxxxxxxxxxxxxxxxxx
Mesh Local Prefix: fdxx:xx:xx:xx::/64
Network Key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Network Name: OpenThread-xxxx
PAN ID: 0xadd9
PSKc: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Security Policy: 672 onrc 0
Done

STAモード後ルータのIPを確認してルータ管理ページへアクセス
http://192.168.xx.xx

Wifi設定やThreadネットワークの設定をするためのウェブUIが用意されていますが、別途以下コマンドで作成する必要があります。

SPIFFSイメージの生成

$ python ~/.platformio/packages/framework-espidf/components/spiffs/spiffsgen.py 0x200000 \
../../components/esp_ot_br_server/frontend \
.pio/build/esp32-c6-devkitc-1/web_storage.bin

spiffsgen.py はESP-IDFに付属するツールで、指定したディレクトリの中身をSPIFFS(SPI Flash File System)形式のイメージファイルにまとめます。

  • 0x200000: 生成するSPIFFSイメージのサイズ(バイト単位の16進数)。この場合は 2,097,152 バイト = 2MB を指定しています。パーティションテーブルpartitions.csvで定義したSPIFFS用パーティションのサイズと一致させる必要があります。
  • ../../components/esp_ot_br_server/frontend: イメージ化する元のディレクトリ。Web UI用のHTML/CSS/JSファイルなどが入っています。
  • .pio/build/esp32-c6-devkitc-1/web_storage.bin: 出力先のバイナリファイル名。ビルド後にこのファイルとしてSPIFFSイメージが生成されます。

フラッシュへの書き込み

$ python ~/.platformio/packages/tool-esptoolpy/esptool.py --chip esp32c6 -p /dev/ttyACM0 \
-b 460800 write_flash 0x820000 .pio/build/esp32-c6-devkitc-1/web_storage.bin

esptool.py はEspressif製チップにシリアル経由でファームウェアやデータを書き込むための標準ツールです。

  • --chip esp32c6: 対象チップがESP32-C6であることを指定。
  • -p /dev/ttyACM0: 書き込みに使うシリアルポート(Linuxのデバイスファイル)。
  • -b 460800: 通信ボーレート(460800bps)。高速書き込みのための設定です。
  • write_flash 0x820000 ...web_storage.bin: 0x820000というフラッシュ上のアドレスに、先ほど生成したweb_storage.binを書き込みます。このアドレスはパーティションテーブルpartitions.csvで定義されたSPIFFS(またはstorage)パーティションの開始オフセットと一致している必要があります。

partitions.csv

# Name,       Type, SubType, Offset,   Size,    Flags
nvs,          data, nvs,     0x9000,   0x10000,
otadata,      data, ota,     0x19000,  0x2000,
phy_init,     data, phy,     0x1b000,  0x1000,
ota_0,        app,  ota_0,   0x20000,  4M,
ota_1,        app,  ota_1,   0x420000, 4M,
web_storage,  data, spiffs,  0x820000, 2M,
rcp_fw,       data, spiffs,  0xa20000, 2M,

注)rcp_fw:OTBRをESP32-C3とESP32-C6の2台で構成(デフォルトの構成)しWi-FiとRCPをそれぞれ専用のマイコンで制御する場合

今回はPlatformio->Build Filesystem Imageは使用せず(使用する場合、dataディレクトリを作成し、ここにhtmlファイルを格納)、以下のPythonスクリプトによりFS用イメージを作成・アップロードを行います。

build_spiffs.py

# build_spiffs.py
Import("env")
import subprocess

def before_build(source, target, env):
    subprocess.run([
        "python",
        env.subst("$PROJECT_PACKAGES_DIR") + "/framework-espidf/components/spiffs/spiffsgen.py",
        "0x200000",
        "../../components/esp_ot_br_server/frontend",
        env.subst("$BUILD_DIR") + "/web_storage.bin"
    ], check=True)

def after_upload(source, target, env):
    port = env.subst("$UPLOAD_PORT")
    subprocess.run([
        "python",
        env.subst("$PROJECT_PACKAGES_DIR") + "/tool-esptoolpy/esptool.py",
        "--chip", "esp32c6",
        "-p", port,
        "-b", "460800",
        "write_flash", "0x820000",
        env.subst("$BUILD_DIR") + "/web_storage.bin"
    ], check=True)

env.AddPreAction("buildprog", before_build)
env.AddPostAction("upload", after_upload)

機能拡張コマンド

esp32c6> ot ip
---ip parameter---
print                    :     print all ip on each interface of lwip
add <ifname> <ifaddr>    :     add an address onto an interface of lwip
del <ifname> <ifaddr>    :     delete an address from an interface of lwip
Done
esp32c6> 
esp32c6> ot loglevel
---loglevel---
set <TAG> <level>                :       set log level of the <TAG> to <level>
---example---
loglevel set * 3                 :       set log level of all the tags to INFO
loglevel set OPENTHREAD 0        :       set log level of OpenThread to None
loglevel set wifi 4              :       set log level of Wi-Fi to DEBUG
----Note----
Support 6 levels         :       0(NONE), 1(ERROR), 2(WARN), 3(INFO), 4(DEBUG), 5(VERBOSE)
The log level of the tags cannot be bigger than the maximum log level
You can set the maximum log level via:
idf.py menuconfig >  Component config---> Log output---> Maximum log verbosity
Done

IKEA Matter デバイスの登録

事前に、Settings —> Device & Services—> Integrations で Matter, Thread, Open Thread Boarder Router の登録を行って下さい。

Open Thread Border Router

ベーシック認証にした場合、ユーザ:パスワードを追加。

http://user:password@<otbr_local_ip>

IKEA対象デバイス

  • KAJPLATSLED bulb E17 810 lumen, smart white spectrum/globe/opal white

    ファクトリリセットはON/OFF6回

  • BILRESA Remote control, white smart/dual button

    ファクトリリセットは表面ケースを開けて現れる真ん中のボタンを長押し。

BILRESA Remote control を単独のMatterデバイスとしてHome AssistantやApple Homeなどに登録すると、現時点ではMatter Binding(デバイス同士の直接関連付け)をサポートしていないため、KAJPLATS LED bulb E17 810 lumen と直接ペアリングして制御することはできません。

直接ペアリングを使うか、Home Assistant経由で制御するかの二択になります。

登録手続き

登録手続きができるのはモバイルアプリからのみとなっています。予めAndroid, iOS版Home Assistantアプリをインストールして下さい。

上記のモバイルアプリ経由で登録手続きを行います。Threadをクリックすると、デバイスの登録から選択したOTBRが表示されます(OpenThread-add9)。これをPreferred networkに追加して下さい。

Used for Android + iOS Credentials のチェックは必須です。

Matterデバイスを登録する前に、以下のコンパニオンアプリからトラブルシューティングを選択し、Thread認証情報の同期を行って下さい(Threadネットワークのリセットなど、ネットワーク名を変更した場合に必要な手続きです)。

Matterデバイスの登録

注)携帯のWiFiとESP32-C6によるOTBRは、同じ2.4GHz帯のSSIDである必要があります。

Matterをクリックし、+Add device → No. It's new

デバイスの外装や説明書に添付されているQRコードをスキャンし登録します。登録後デバイスはリストアップされます。

トポロジー:OTBRとMatterデバイスの接続マップ


オートメーションの作成

リモコンでLEDのON/OFF制御を行うには、オートメーションルールへの登録が必要です。

TriggerにはDeviceではなくEntityを選択。

optionalは選択しないで下さい(optionalを選択すると機能しません)。

注)ペアリングで使用する場合には、ボタンの長押しで調光操作ができるのですが、オートメーションルールでボタンの長押し操作はイベントとして認識でき調光操作も可能です。
ボタン操作に機能を追加するには以下のプループリントを利用する方法もあります。

注) BILRESAは、HAやMatter統合(Matter Server)が再起動した際、またはデバイス自身が再ネットワーク参加した際に、最後に押されたステータスを誤って再送する、あるいは無効な値を送り出す 挙動が報告されています。これにより、HA側がイベントを正しく処理できなくなるケースがあります。


ボタンの長押し+リリースを他のデバイス制御に利用


GRILLPLATSスマートプラグ追加

トラブルシュート

BILRESA Remote controlの反応が非常に遅い、または無反応。

原因はホストPC(HAコンテナのネットワークモードはhost)のファイヤーウォールによるmDNSのマルチキャストパケット拒否。

[ESP32-C6 (OTBR)] --Wi-Fi--> [HostPCのwpan0] == (host network) == [HAコンテナ]
                                        ↑
                                   ここのufwがブロック
$ sudo ufw allow in on wpan0 ### host LAN or Wifi interface
$ sudo ufw reload

Wi-Fi 再接続エラー

ルータ側の再起動時など接続断になった際 ot wifi state —> reconnecting となり再接続しない。

再接続プロセスが欠如しているため追加。

esp-thread-br/examples/basic_thread_border_router/main/idf_component.yml

@@ -1,8 +1,8 @@
 ## IDF Component Manager Manifest File
 dependencies:
   espressif/mdns: "^1.0.0"
-  espressif/esp_ot_cli_extension:
-    version: "~2.0.0"
+  esp_ot_cli_extension:
+    path: ../../../components/esp_ot_cli_extension
   espressif/esp_rcp_update:
     version: "~1.6.0"
   esp_br_http_ota:

esp-thread-br/components/esp_ot_cli_extension/src/esp_ot_wifi_cmd.c

@@ -89,6 +89,12 @@
 #define PASSWORDMAXLEN 64
 ESP_EVENT_DEFINE_BASE(WIFI_ADDRESS_EVENT);
 
+/* ---- Wi-Fi reconnect watchdog ---- */
+static TaskHandle_t s_wifi_watchdog_task_handle = NULL;
+
+/* wifi_watchdog_task より前方で使うための前方宣言 */
+static esp_err_t wifi_join(const char *ssid, const char *password);
+
 static void handle_wifi_addr_init(void)
 {
     ESP_ERROR_CHECK(esp_event_handler_register(WIFI_ADDRESS_EVENT, WIFI_ADDRESS_EVENT_ADD_IP6,
@@ -105,6 +111,36 @@
                                                esp_netif_get_handle_from_ifkey("WIFI_STA_DEF")));
 }
 
+/*
+ * ウォッチドッグタスク本体。
+ * wifi_join() はブロッキング呼び出しのため、WIFI_EVENT_STA_DISCONNECTED の
+ * イベントハンドラ(handler_on_wifi_disconnect)から直接呼ばず、
+ * ここ(独立タスク)に処理を委譲することでイベントループタスクを長時間占有しないようにする。
+ */
+static void wifi_watchdog_task(void *arg)
+{
+    for (;;) {
+        /* handler_on_wifi_disconnect からの通知を待つ */
+        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
+
+        char ssid[SSIDMAXLEN] = "";
+        char password[PASSWORDMAXLEN] = "";
+        esp_ot_wifi_config_get_ssid(ssid);
+        esp_ot_wifi_config_get_password(password);
+
+        if (strlen(ssid) == 0) {
+            /* 保存された設定がなければ何もしない */
+            continue;
+        }
+
+        ESP_LOGW(OT_EXT_CLI_TAG, "example_connect gave up, immediately retrying rejoin to \"%s\"", ssid);
+        wifi_join(ssid, password);
+        if (err != ESP_OK) {
+            ESP_LOGW(OT_EXT_CLI_TAG, "Watchdog rejoin attempt failed (0x%x), will retry on next disconnect cycle", err);
+        }
+    }
+}
+
 static void handler_on_wifi_disconnect(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
 {
     wifi_conn_retry_nums++;
@@ -112,6 +148,10 @@
     if (wifi_conn_retry_nums > CONFIG_EXAMPLE_WIFI_CONN_MAX_RETRY) {
         wifi_conn_retry_nums = 0;
         s_wifi_state = OT_WIFI_DISCONNECTED;
+        /* example_connect が諦めたと同時に、間を置かず自前で再接続を試みる */
+        if (s_wifi_watchdog_task_handle != NULL) {
+            xTaskNotifyGive(s_wifi_watchdog_task_handle);
+        }
     } else
 #endif
     {
@@ -193,6 +233,16 @@
         ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MAX_MODEM));
         handle_wifi_addr_init();
         s_wifi_initialized = true;
+
+        /* ウォッチドッグ用タイマーとタスクは初回接続時に一度だけ生成する */
+        if (s_wifi_watchdog_task_handle == NULL) {
+            BaseType_t ok = xTaskCreate(wifi_watchdog_task, "wifi_watchdog", 4096, NULL,
+                                        tskIDLE_PRIORITY + 3, &s_wifi_watchdog_task_handle);
+            if (ok != pdPASS) {
+                ESP_LOGE(OT_EXT_CLI_TAG, "Failed to create wifi watchdog task");
+                s_wifi_watchdog_task_handle = NULL;
+            }
+        }
     }
 
     if (!s_wifi_handler_registered) {

APIによるデータセット送信エラー

Changed channelでOKが出力されるが、実際はESP32のOTBR内でランダムなデータセットとして処理される。このためチャンネルの変更は反映されない。

ESP32側:handle_ot_resource_node_set_dataset_requestのmemset漏れ

E (32362) web_api: handle_ot_resource_node_set_dataset_request(365): Cannot set Pending DatasetTlvs

esp-thread-br/components/esp_ot_br_server/src/esp_br_web_api.c

@@ -303,6 +303,7 @@
     otOperationalDatasetTlvs datasetTlvs;
     otOperationalDatasetTlvs datasetUpdateTlvs;
     otError ret = OT_ERROR_NONE;
+    memset(&dataset, 0, sizeof(otOperationalDataset));
     const char *content_format =
         cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(request, ESP_OT_REST_CONTENT_TYPE_HEADER));
     const char *dataset_type =

ESP32側:NOT_FOUND_handlerが404ステータスを返していなかった → HAの検出誤判定の原因

python-otbr-api がキーフォーマットをCAMAL CASE と誤判定—> 実際はPASCAL CASE

httpd_resp_set_status(req, HTTPD_404)の呼び出しがなく、実際のHTTPステータスが常にデフォルトの200になっていたため。

HAのログ確認

homeassistant  | 2026-09-05 11:03:31.417 DEBUG (MainThread) [python_otbr_api] Detected OTBR JSON key format: KeyFormat.CAMEL_CASE

esp-thread-br/components/esp_ot_br_server/src/esp_br_web.c

 /*-----------------------------------------------------
  Note:Handling for Client request
 -----------------------------------------------------*/
 static esp_err_t NOT_FOUND_handler(httpd_req_t *req)
 {
     esp_err_t ret = ESP_OK;
+    httpd_resp_set_status(req, HTTPD_404);
     cJSON *response = resource_status("404", "404 Not Found");
     ESP_GOTO_ON_ERROR(httpd_send_packet(req, response), exit, WEB_TAG, "Failed to response %s", req->uri);

HA側:set_channel()が既存のPending Datasetをチェックせず上書きしようとしていた → 2026年の比較的最近の修正で解消済み(HA/ライブラリの更新が必要)

Firmware Update from WEB Browser

ブラウザからファームウェアを指定してESP32へアップロードする機能を追加。

以下新規・更新ファイル

新規作成
esp-thread-br/components/esp_ot_br_server/frontend/ota.html

<!--
SPDX-FileCopyrightText: 2026
SPDX-License-Identifier: Apache-2.0
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport"
      content="width=device-width, initial-scale=1">
<title>Thread Border Router - Firmware Update</title>
<link rel="stylesheet"
      href="/static/style.css">
<link rel="icon"
      href="/favicon.ico">
</head>

<body>
<nav class="nav" id="nav"></nav>
<div class="container">
    <h1 class="page-title">Firmware Update</h1>
    <p class="page-desc">
        Update the Thread Border Router firmware.
    </p>
    <div class="card section">
        <div class="card-header">
            Firmware Update
        </div>
        <p style="
            font-size:13px;
            color:var(--text-muted);
            margin-bottom:14px
        ">
            Select an ESP32-C6 firmware image and upload it to the
            inactive OTA partition.
        </p>
        <form id="otaForm"
              onsubmit="return false">
            <div class="form-group">
                <label class="form-label"
                       for="firmware">
                    Firmware Image
                </label>
                <input
                    class="form-input"
                    id="firmware"
                    name="firmware"
                    type="file"
                    accept=".bin,application/octet-stream">
                <div class="form-hint">
                    Select the firmware.bin generated by PlatformIO.
                </div>
            </div>
            <div style="
                display:flex;
                align-items:center;
                gap:12px;
                flex-wrap:wrap
            ">
                <button
                    type="button"
                    class="btn btn-primary"
                    id="uploadButton"
                    onclick="uploadFirmware()">
                    Upload Firmware
                </button>
                <span
                    id="otaStatus"
                    style="font-size:13px">
                </span>
            </div>
        </form>
    </div>
    <div class="card section">
        <div class="card-header">
            Update Information
        </div>
        <div class="info-row">
            <span class="info-label">
                Target
            </span>
            <span class="info-value">
                ESP32-C6
            </span>
        </div>
        <div class="info-row">
            <span class="info-label">
                OTA
            </span>
            <span class="info-value">
                Dual Partition
            </span>
        </div>
        <div class="info-row">
            <span class="info-label">
                Maximum Firmware Size
            </span>
            <span class="info-value">
                4 MB
            </span>
        </div>
    </div>
    <div class="footer">
        Copyright &copy; 2026 Espressif Systems. All rights reserved.
    </div>
</div>

<script src="/static/api.js"></script>

<script>
/*
 * Wait for the ESP32 to reboot and become reachable again.
 *
 * During reboot, /node/state will fail to connect.
 * Once the Web server is running again, the request succeeds.
 */
function waitForReboot()
{
    var status =
        document.getElementById('otaStatus');
    var attempts = 0;
    var maxAttempts = 30;
    status.textContent =
        'Device is rebooting...';
    status.style.color =
        'var(--success)';

    function checkDevice()
    {
        attempts++;
        fetch('/node/state', {
            method: 'GET',
            cache: 'no-store'
        })
        .then(function(response) {
            if (!response.ok) {
                throw new Error(
                    'HTTP ' + response.status
                );
            }
            return response.json();
        })

        .then(function() {
            /*
             * The ESP32 is responding again.
             */
            status.textContent =
                'Device restarted successfully.';
            status.style.color =
                'var(--success)';
            showToast(
                'Device restarted successfully.',
                'success'
            );

            /*
             * Give the user a short moment to see
             * the success message, then reload the page.
             */
            setTimeout(function() {
                showLoading(false);
                window.location.reload();
            }, 800);
        })
        .catch(function() {
            /*
             * The ESP32 is still rebooting.
             */
            if (attempts >= maxAttempts) {
                status.textContent =
                    'Device reboot detection timed out.';
                status.style.color =
                    'var(--danger)';
                showToast(
                    'Device reboot detection timed out.',
                    'error'
                );
                showLoading(false);
                return;
            }

            /*
             * Check again after one second.
             */
            setTimeout(
                checkDevice,
                1000
            );
        });
    }

    /*
     * Give the ESP32 some time to actually reboot
     * before starting the first request.
     */
    setTimeout(
        checkDevice,
        1500
    );
}

function uploadFirmware()
{
    var input =
        document.getElementById('firmware');
    var button =
        document.getElementById('uploadButton');
    var status =
        document.getElementById('otaStatus');

    if (!input.files.length) {
        showToast(
            'Please select a firmware image.',
            'error'
        );
        status.textContent =
            'Please select a firmware image.';
        status.style.color =
            'var(--danger)';
        return;
    }

    var file =
        input.files[0];

    if (file.size === 0) {
        showToast(
            'The selected file is empty.',
            'error'
        );
        return;
    }

    var sizeMB =
        (file.size / 1024 / 1024).toFixed(2);

    if (!confirm(
        'Upload this firmware image?\n\n' +
        file.name + '\n' +
        sizeMB + ' MB\n\n' +
        'The device will reboot automatically after the update.'
    )) {
        return;
    }

    button.disabled = true;
    input.disabled = true;

    status.textContent =
        'Uploading...';
    status.style.color =
        'var(--primary)';
    showLoading(true);
    fetch('/api/ota', {
    method: 'POST',
    headers: {
        'Content-Type':
            'application/octet-stream'
    },
    body: file
    })
    .then(function(response) {
        return response.text().then(function(text) {
            /*
            * Check HTTP status before parsing JSON.
            */
            if (!response.ok) {
                throw new Error(
                    'OTA update failed (HTTP ' +
                    response.status +
                    '): ' +
                    text
                );
            }
            var result;
            try {
                result = JSON.parse(text);
            } catch (e) {
                throw new Error(
                    'Invalid server response: ' + text
                );
            }
            if (result.error) {
                throw new Error(
                    result.message ||
                    'OTA update failed'
                );
            }
            return result;
        });
    })

    .then(function(result) {
        status.textContent =
            result.message ||
            'OTA successful. Rebooting...';
        status.style.color =
            'var(--success)';
        showToast(
            'Firmware update successful.',
            'success'
        );

        /*
        * Wait for the ESP32 to reboot and become
        * reachable again.
        */
        waitForReboot();

    })

    .catch(function(error) {
        status.textContent =
            error.message ||
            'Firmware update failed.';
        status.style.color =
            'var(--danger)';
        showToast(
            error.message ||
            'Firmware update failed.',
            'error'
        );

        button.disabled = false;
        input.disabled = false;

        /*
         * The OTA request itself failed, so the loading
         * overlay can be removed immediately.
         */
        showLoading(false);
    });
}
</script>
</body>
</html>

更新ファイル

esp-thread-br/components/esp_ot_br_server/frontend/static/api.js

@@ -137,7 +137,8 @@
       {href : '/network.html', label : 'Network'},
       {href : '/commission.html', label : 'Commissioner'},
       {href : '/addresses.html', label : 'Addresses'},
-      {href : '/tools.html', label : 'Tools'}
+      {href : '/tools.html', label : 'Tools'},
+      {href : '/ota.html', label : 'Firmware Update'}
     ]
   },
   {href : '/topology.html', label : 'Topology'},

esp-thread-br/components/esp_ot_br_server/src/esp_br_web.c

@@ -24,6 +24,7 @@
 #include "esp_heap_caps.h"
 #include "esp_http_server.h"
 #include "esp_log.h"
+#include "esp_ota_ops.h"
 #include "esp_openthread.h"
 #include "esp_openthread_border_router.h"
 #include "esp_spiffs.h"
@@ -223,6 +224,7 @@
 static esp_err_t esp_otbr_ipaddr_get_handler(httpd_req_t *req);
 static esp_err_t esp_otbr_add_ipaddr_post_handler(httpd_req_t *req);
 static esp_err_t esp_otbr_delete_ipaddr_post_handler(httpd_req_t *req);
+static esp_err_t esp_otbr_ota_post_handler(httpd_req_t *req);
 
 static httpd_uri_t s_web_gui_handlers[] = {
     {
@@ -303,6 +305,12 @@
         .handler = esp_otbr_delete_ipaddr_post_handler,
         .user_ctx = &s_server.data,
     },
+    {
+        .uri = "/api/ota",
+        .method = HTTP_POST,
+        .handler = esp_otbr_ota_post_handler,
+        .user_ctx = NULL,
+    },
 };
 
 /*-----------------------------------------------------
@@ -1066,6 +1074,255 @@
     return ret;
 }
 
+static esp_err_t esp_otbr_ota_post_handler(httpd_req_t *req)
+{
+    esp_err_t ret = ESP_OK;
+    esp_ota_handle_t ota_handle = 0;
+    const esp_partition_t *update_partition = NULL;
+    bool ota_started = false;
+
+    if (req == NULL) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    if (req->content_len <= 0) {
+        httpd_resp_send_err(
+            req,
+            HTTPD_400_BAD_REQUEST,
+            "Invalid firmware size"
+        );
+        return ESP_ERR_INVALID_SIZE;
+    }
+
+    ESP_LOGI(
+        WEB_TAG,
+        "OTA upload started: %d bytes",
+        req->content_len
+    );
+
+    /*
+     * Select the OTA partition that is not currently running.
+     */
+    update_partition = esp_ota_get_next_update_partition(NULL);
+
+    if (update_partition == NULL) {
+        ESP_LOGE(
+            WEB_TAG,
+            "No OTA partition available"
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_500_INTERNAL_SERVER_ERROR,
+            "No OTA partition available"
+        );
+
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    ESP_LOGI(
+        WEB_TAG,
+        "OTA target: %s @ 0x%" PRIx32 ", size=%" PRIu32,
+        update_partition->label,
+        update_partition->address,
+        update_partition->size
+    );
+
+    /*
+     * Make sure the uploaded firmware fits in the OTA partition.
+     *
+     * Your partition table gives ota_0 and ota_1 4 MiB each.
+     */
+    if ((uint32_t)req->content_len > update_partition->size) {
+        ESP_LOGE(
+            WEB_TAG,
+            "Firmware too large: %d > %" PRIu32,
+            req->content_len,
+            update_partition->size
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_400_BAD_REQUEST,
+            "Firmware image is too large"
+        );
+
+        return ESP_ERR_INVALID_SIZE;
+    }
+
+    /*
+     * Start OTA.
+     */
+    ret = esp_ota_begin(
+        update_partition,
+        OTA_WITH_SEQUENTIAL_WRITES,
+        &ota_handle
+    );
+
+    if (ret != ESP_OK) {
+        ESP_LOGE(
+            WEB_TAG,
+            "esp_ota_begin failed: %s",
+            esp_err_to_name(ret)
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_500_INTERNAL_SERVER_ERROR,
+            "Failed to start OTA"
+        );
+
+        return ret;
+    }
+
+    ota_started = true;
+
+    char buffer[FILE_CHUNK_SIZE];
+    int remaining = req->content_len;
+
+    while (remaining > 0) {
+
+        int recv_size = remaining;
+
+        if (recv_size > (int)sizeof(buffer)) {
+            recv_size = sizeof(buffer);
+        }
+
+        int recv_len = httpd_req_recv(
+            req,
+            buffer,
+            recv_size
+        );
+
+        if (recv_len == HTTPD_SOCK_ERR_TIMEOUT) {
+            continue;
+        }
+
+        if (recv_len <= 0) {
+            ESP_LOGE(
+                WEB_TAG,
+                "Failed to receive firmware data: %d",
+                recv_len
+            );
+
+            ret = ESP_FAIL;
+            goto ota_error;
+        }
+
+        ret = esp_ota_write(
+            ota_handle,
+            buffer,
+            recv_len
+        );
+
+        if (ret != ESP_OK) {
+            ESP_LOGE(
+                WEB_TAG,
+                "esp_ota_write failed: %s",
+                esp_err_to_name(ret)
+            );
+
+            goto ota_error;
+        }
+
+        remaining -= recv_len;
+
+        ESP_LOGD(
+            WEB_TAG,
+            "OTA progress: %d / %d",
+            req->content_len - remaining,
+            req->content_len
+        );
+    }
+
+    /*
+     * Finish and validate the OTA image.
+     */
+    ret = esp_ota_end(ota_handle);
+
+    ota_started = false;
+
+    if (ret != ESP_OK) {
+        ESP_LOGE(
+            WEB_TAG,
+            "esp_ota_end failed: %s",
+            esp_err_to_name(ret)
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_400_BAD_REQUEST,
+            "Invalid firmware image"
+        );
+
+        return ret;
+    }
+
+    /*
+     * Select the new firmware as the next boot partition.
+     */
+    ret = esp_ota_set_boot_partition(update_partition);
+
+    if (ret != ESP_OK) {
+        ESP_LOGE(
+            WEB_TAG,
+            "Failed to set boot partition: %s",
+            esp_err_to_name(ret)
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_500_INTERNAL_SERVER_ERROR,
+            "Failed to set boot partition"
+        );
+
+        return ret;
+    }
+
+    ESP_LOGI(
+        WEB_TAG,
+        "OTA successful. Boot partition: %s",
+        update_partition->label
+    );
+
+    /*
+     * Send the HTTP response before restarting.
+     */
+    httpd_resp_set_type(
+        req,
+        "application/json"
+    );
+
+    ret = httpd_resp_sendstr(
+        req,
+        "{\"error\":0,\"message\":\"OTA successful. Rebooting...\"}"
+    );
+
+    /*
+     * Give the HTTP response some time to reach the browser.
+     */
+    vTaskDelay(pdMS_TO_TICKS(500));
+
+    esp_restart();
+
+    return ret;
+
+
+ota_error:
+
+    if (ota_started) {
+        esp_ota_abort(ota_handle);
+    }
+
+    httpd_resp_send_err(
+        req,
+        HTTPD_500_INTERNAL_SERVER_ERROR,
+        "OTA failed"
+    );
+
+    return ret;
+}
+
 /*-----------------------------------------------------
  Note:Handling for Client request
 -----------------------------------------------------*/

esp-thread-br/examples/basic_thread_border_router/CMakeLists.txt

@@ -2,5 +2,7 @@
 # in this exact order for cmake to work correctly
 cmake_minimum_required(VERSION 3.5)
 
+set(PROJECT_VER "1.0.2")
+
 include($ENV{IDF_PATH}/tools/cmake/project.cmake)
 project(esp_ot_br)

esp-thread-br/components/esp_ot_br_server/CMakeLists.txt

@@ -1,4 +1,4 @@
-set(requires mdns fatfs spiffs esp_eth nvs_flash freertos openthread esp_http_server protocol_examples_common esp_wifi esp_app_format esp_timer)
+set(requires mdns fatfs spiffs esp_eth nvs_flash freertos openthread esp_http_server app_update protocol_examples_common esp_wifi esp_app_format esp_timer)
 
 # For IDF < 6.0, use IDF's built-in json component; for IDF >= 6.0 use managed cJSON component through idf_component.yml
 if("${IDF_VERSION_MAJOR}" VERSION_LESS "6")

esp-thread-br/examples/basic_thread_border_router/main/esp_ot_br.c

@@ -5,6 +5,7 @@
  */
 #include <stdio.h>
 #include <string.h>
+#include <inttypes.h>
 
 #include "sdkconfig.h"
 
@@ -13,6 +14,7 @@
 #include "esp_event.h"
 #include "esp_log.h"
 #include "esp_netif.h"
+#include "esp_ota_ops.h"
 #include "esp_openthread.h"
 #include "esp_openthread_border_router.h"
 #include "esp_openthread_netif_glue.h"
@@ -35,6 +37,120 @@
 extern const uint8_t server_cert_pem_start[] asm("_binary_ca_cert_pem_start");
 extern const uint8_t server_cert_pem_end[] asm("_binary_ca_cert_pem_end");
 
+/*
+ * Convert OTA image state to a readable string.
+ */
+static const char *ota_state_to_string(esp_ota_img_states_t state)
+{
+    switch (state) {
+    case ESP_OTA_IMG_NEW:
+        return "NEW";
+    case ESP_OTA_IMG_PENDING_VERIFY:
+        return "PENDING_VERIFY";
+    case ESP_OTA_IMG_VALID:
+        return "VALID";
+    case ESP_OTA_IMG_INVALID:
+        return "INVALID";
+    case ESP_OTA_IMG_ABORTED:
+        return "ABORTED";
+    case ESP_OTA_IMG_UNDEFINED:
+        return "UNDEFINED";
+    default:
+        return "UNKNOWN";
+    }
+}
+
+/*
+ * Check the currently running OTA partition and
+ * confirm the firmware image if rollback is enabled.
+ */
+static void check_ota_state(void)
+{
+    const esp_partition_t *running =
+        esp_ota_get_running_partition();
+    if (running == NULL) {
+        ESP_LOGE(
+            TAG,
+            "Failed to get running OTA partition"
+        );
+        return;
+    }
+
+    ESP_LOGI(
+        TAG,
+        "Running firmware partition: %s @ 0x%" PRIx32,
+        running->label,
+        running->address
+    );
+
+    /*
+     * Get the current OTA image state.
+     */
+    esp_ota_img_states_t ota_state;
+    esp_err_t ret =
+        esp_ota_get_state_partition(
+            running,
+            &ota_state
+        );
+    if (ret == ESP_OK) {
+        ESP_LOGI(
+            TAG,
+            "OTA image state before confirmation: %s",
+            ota_state_to_string(ota_state)
+        );
+    } else {
+        ESP_LOGW(
+            TAG,
+            "Failed to get OTA image state: %s",
+            esp_err_to_name(ret)
+        );
+    }
+
+    /*
+     * Confirm the currently running firmware.
+     *
+     * This changes ESP_OTA_IMG_PENDING_VERIFY to
+     * ESP_OTA_IMG_VALID and cancels rollback.
+     */
+    ret =
+        esp_ota_mark_app_valid_cancel_rollback();
+
+    if (ret == ESP_OK) {
+        ESP_LOGI(
+            TAG,
+            "OTA image confirmed as valid"
+        );
+    } else {
+        ESP_LOGW(
+            TAG,
+            "OTA image confirmation failed: %s",
+            esp_err_to_name(ret)
+        );
+    }
+
+    /*
+     * Check the state again after confirmation.
+     */
+    ret =
+        esp_ota_get_state_partition(
+            running,
+            &ota_state
+        );
+    if (ret == ESP_OK) {
+        ESP_LOGI(
+            TAG,
+            "OTA image state after confirmation: %s",
+            ota_state_to_string(ota_state)
+        );
+    } else {
+        ESP_LOGW(
+            TAG,
+            "Failed to get OTA image state after confirmation: %s",
+            esp_err_to_name(ret)
+        );
+    }
+}
+
 static esp_err_t init_spiffs(void)
 {
 #if CONFIG_AUTO_UPDATE_RCP
@@ -54,13 +170,23 @@
 
 void app_main(void)
 {
+    /*
+     * ---------------------------------------------------------
+     * OTA / rollback status
+     * ---------------------------------------------------------
+     *
+     * Check which OTA partition is currently running and
+     * confirm the firmware image.
+     */
+    check_ota_state();
+
     // Used eventfds:
     // * netif
     // * task queue
     // * border router
     size_t max_eventfd = 3;
 
-#if CONFIG_OPENTHREAD_RADIO_SPINEL_SPI
+#if CONFIG_OPENTHREAD_RADIO_NATIVE || CONFIG_OPENTHREAD_RADIO_SPINEL_SPI
     // * SpiSpinelInterface (The Spi Spinel Interface needs an eventfd.)
     max_eventfd++;
 #endif

ベーシック認証・ユーザ名・パスワード変更機能追加

ベーシック認証とユーザ名・パスワードを変更するページを追加

esp-thread-br/components/esp_ot_br_server/frontend/credentials.html

<!--
  SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
  SPDX-License-Identifier: Apache-2.0
-->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Thread Border Router - Change Credentials</title>
    <link rel="stylesheet" href="/static/style.css">
    <link rel="icon" href="/favicon.ico">
</head>
<body>
<nav class="nav" id="nav"></nav>
<div class="container">
    <h1 class="page-title">Change Credentials</h1>
    <p class="page-desc">Update the username and password used to access this web interface</p>

    <!-- Credentials Section -->
    <div class="card section">
        <div class="card-header">Login Credentials</div>
        <p style="font-size:13px;color:var(--text-muted);margin-bottom:14px">
            After saving, you will need to log in again with the new username and password the next time you access this device.
        </p>
        <form id="credentialsForm" onsubmit="return false">
            <div class="form-group">
                <label class="form-label">New Username *</label>
                <input class="form-input" name="username" id="credUsername" type="text"
                       autocomplete="username" maxlength="31" required>
                <div class="form-hint">Up to 31 characters</div>
            </div>
            <div class="form-row">
                <div class="form-group">
                    <label class="form-label">New Password *</label>
                    <input class="form-input" name="password" id="credPassword" type="password"
                           autocomplete="new-password" maxlength="63" minlength="4" required>
                    <div class="form-hint">4–63 characters</div>
                </div>
                <div class="form-group">
                    <label class="form-label">Confirm New Password *</label>
                    <input class="form-input" id="credPasswordConfirm" type="password"
                           autocomplete="new-password" maxlength="63" minlength="4" required>
                    <div class="form-hint">Re-enter the password above</div>
                </div>
            </div>
            <div class="flex-between">
                <button type="button" class="btn btn-primary" id="credBtn" onclick="saveCredentials()">Save Changes</button>
                <span id="credStatus" style="font-size:13px"></span>
            </div>
        </form>
    </div>

</div>
<div class="footer">Copyright &copy; 2026 Espressif Systems. All rights reserved.</div>

<script src="/static/api.js"></script>
<script>
function saveCredentials() {
    var username = document.getElementById('credUsername').value.trim();
    var password = document.getElementById('credPassword').value;
    var passwordConfirm = document.getElementById('credPasswordConfirm').value;

    if (!username) {
        showToast('Please enter a username', 'error');
        return;
    }
    if (password.length < 4) {
        showToast('Password must be at least 4 characters', 'error');
        return;
    }
    if (password !== passwordConfirm) {
        showToast('Passwords do not match', 'error');
        return;
    }

    var btn = document.getElementById('credBtn');
    var status = document.getElementById('credStatus');

    btn.disabled = true;
    btn.textContent = 'Saving...';
    status.textContent = 'Saving credentials...';
    status.style.color = 'var(--primary)';

    apiPut('/api/credentials', {username: username, password: password}).then(function(data) {
        btn.disabled = false;
        btn.textContent = 'Save Changes';

        if (data.error !== 0) {
            status.textContent = data.message || 'Failed to update credentials';
            status.style.color = 'var(--danger)';
            showToast(data.message || 'Failed to update credentials', 'error');
            return;
        }

        status.textContent = 'Saved';
        status.style.color = 'var(--success)';
        showToast('Credentials updated. Please log in again with your new credentials.', 'success');
        document.getElementById('credentialsForm').reset();

    }).catch(function() {
        btn.disabled = false;
        btn.textContent = 'Save Changes';
        status.textContent = 'Connection error';
        status.style.color = 'var(--danger)';
        showToast('Connection error', 'error');
    });
}
</script>
</body>
</html>

esp-thread-br/components/esp_ot_br_server/frontend/static/api.js

@@ -40,6 +40,15 @@
       .then(function(r) { return r.json(); });
 }
 
+function apiPut(url, data) {
+  return fetch(url, {
+           method : 'PUT',
+           headers : {'Content-Type' : 'application/json'},
+           body : JSON.stringify(data)
+         })
+      .then(function(r) { return r.json(); });
+}
+
 /**
  * Get cached response for a GET endpoint, or null if not available.
  * Use this to render immediately on page load before the live fetch completes.
@@ -137,7 +146,9 @@
       {href : '/network.html', label : 'Network'},
       {href : '/commission.html', label : 'Commissioner'},
       {href : '/addresses.html', label : 'Addresses'},
       {href : '/tools.html', label : 'Tools'},
+      {href : '/ota.html', label : 'Firmware Update'},
+      {href : '/credentials.html', label : 'Change Credentials'}
     ]
   },
   {href : '/topology.html', label : 'Topology'},      {href : '/tools.html', label : 'Tools'}

esp_projects/esp-thread-br/components/esp_ot_br_server/src/esp_br_web.c

@@ -24,6 +24,7 @@
 #include "esp_heap_caps.h"
 #include "esp_http_server.h"
 #include "esp_log.h"
+#include "esp_ota_ops.h"
 #include "esp_openthread.h"
 #include "esp_openthread_border_router.h"
 #include "esp_spiffs.h"
@@ -38,6 +39,8 @@
 #include "openthread/thread.h"
 #include "openthread/thread_ftd.h"
 
+#include "mbedtls/base64.h"
+
 #define MAX_FILE_SIZE (200 * 1024) // 200 KB
 #define MAX_FILE_SIZE_STR "200KB"
 #define SCRATCH_BUFSIZE 1024 /* Scratch buffer size */
@@ -46,6 +49,116 @@
 #define FILE_CHUNK_SIZE 4096
 #define WEB_TAG "obtr_web"
 
+#include "nvs.h"
+
+#define BR_WEB_AUTH_NVS_NAMESPACE "br_web_auth"
+#define BR_WEB_AUTH_USERNAME_KEY "username"
+#define BR_WEB_AUTH_PASSWORD_KEY "password"
+#define BR_WEB_AUTH_USERNAME_MAXLEN 32
+#define BR_WEB_AUTH_PASSWORD_MAXLEN 64
+
+/* 初回起動時、NVSに何も保存されていない場合のみ使われるデフォルト値 */
+#define BR_WEB_AUTH_DEFAULT_USERNAME "admin"
+#define BR_WEB_AUTH_DEFAULT_PASSWORD "changeme"
+
+static nvs_handle_t s_web_auth_nvs_handle = 0;
+
+esp_err_t esp_br_web_auth_init(void)
+{
+    static bool s_auth_init = false;
+    if (!s_auth_init) {
+        esp_err_t err = nvs_open(BR_WEB_AUTH_NVS_NAMESPACE, NVS_READWRITE, &s_web_auth_nvs_handle);
+        ESP_RETURN_ON_ERROR(err, WEB_TAG, "Failed to open br_web_auth NVS namespace (0x%x)", err);
+        s_auth_init = true;
+    }
+    return ESP_OK;
+}
+
+esp_err_t esp_br_web_auth_set_username(const char *username)
+{
+    ESP_RETURN_ON_FALSE((s_web_auth_nvs_handle != 0), ESP_FAIL, WEB_TAG, "auth NVS handle is invalid");
+    ESP_RETURN_ON_ERROR(nvs_set_str(s_web_auth_nvs_handle, BR_WEB_AUTH_USERNAME_KEY, username), WEB_TAG,
+                        "No buffers for username");
+    ESP_RETURN_ON_ERROR(nvs_commit(s_web_auth_nvs_handle), WEB_TAG, "Failed to commit username");
+    return ESP_OK;
+}
+
+esp_err_t esp_br_web_auth_get_username(char *username, size_t maxlen)
+{
+    ESP_RETURN_ON_FALSE((s_web_auth_nvs_handle != 0), ESP_FAIL, WEB_TAG, "auth NVS handle is invalid");
+    size_t length = maxlen;
+    if (nvs_get_str(s_web_auth_nvs_handle, BR_WEB_AUTH_USERNAME_KEY, username, &length) != ESP_OK) {
+        /* 未設定なら初回起動とみなし、デフォルト値をそのままNVSにも書き込んでおく */
+        strlcpy(username, BR_WEB_AUTH_DEFAULT_USERNAME, maxlen);
+        esp_br_web_auth_set_username(username);
+    }
+    return ESP_OK;
+}
+
+esp_err_t esp_br_web_auth_set_password(const char *password)
+{
+    ESP_RETURN_ON_FALSE((s_web_auth_nvs_handle != 0), ESP_FAIL, WEB_TAG, "auth NVS handle is invalid");
+    ESP_RETURN_ON_ERROR(nvs_set_str(s_web_auth_nvs_handle, BR_WEB_AUTH_PASSWORD_KEY, password), WEB_TAG,
+                        "No buffers for password");
+    ESP_RETURN_ON_ERROR(nvs_commit(s_web_auth_nvs_handle), WEB_TAG, "Failed to commit password");
+    return ESP_OK;
+}
+
+esp_err_t esp_br_web_auth_get_password(char *password, size_t maxlen)
+{
+    ESP_RETURN_ON_FALSE((s_web_auth_nvs_handle != 0), ESP_FAIL, WEB_TAG, "auth NVS handle is invalid");
+    size_t length = maxlen;
+    if (nvs_get_str(s_web_auth_nvs_handle, BR_WEB_AUTH_PASSWORD_KEY, password, &length) != ESP_OK) {
+        strlcpy(password, BR_WEB_AUTH_DEFAULT_PASSWORD, maxlen);
+        esp_br_web_auth_set_password(password);
+    }
+    return ESP_OK;
+}
+
+/*-----------------------------------------------------
+ Note:Http Server Basic Auth
+-----------------------------------------------------*/
+static bool check_basic_auth(httpd_req_t *req)
+{
+    size_t hdr_len = httpd_req_get_hdr_value_len(req, "Authorization");
+    if (hdr_len == 0) {
+        return false;
+    }
+
+    char *auth_hdr = malloc(hdr_len + 1);
+    if (!auth_hdr) {
+        return false;
+    }
+    if (httpd_req_get_hdr_value_str(req, "Authorization", auth_hdr, hdr_len + 1) != ESP_OK) {
+        free(auth_hdr);
+        return false;
+    }
+
+    bool ok = false;
+    const char *prefix = "Basic ";
+    if (strncmp(auth_hdr, prefix, strlen(prefix)) == 0) {
+        const char *b64 = auth_hdr + strlen(prefix);
+        unsigned char decoded[128] = {0};
+        size_t decoded_len = 0;
+        if (mbedtls_base64_decode(decoded, sizeof(decoded) - 1, &decoded_len, (const unsigned char *)b64,
+                                  strlen(b64)) == 0) {
+            decoded[decoded_len] = '\0';
+            char stored_username[BR_WEB_AUTH_USERNAME_MAXLEN] = {0};
+            char stored_password[BR_WEB_AUTH_PASSWORD_MAXLEN] = {0};
+            esp_br_web_auth_get_username(stored_username, sizeof(stored_username));
+            esp_br_web_auth_get_password(stored_password, sizeof(stored_password));
+
+            char expected[BR_WEB_AUTH_USERNAME_MAXLEN + BR_WEB_AUTH_PASSWORD_MAXLEN + 1];
+            snprintf(expected, sizeof(expected), "%s:%s", stored_username, stored_password);
+            ok = (strcmp((char *)decoded, expected) == 0);
+        }
+    }
+
+    free(auth_hdr);
+    return ok;
+}
+
+
 /*-----------------------------------------------------
  Note:Http Server
 -----------------------------------------------------*/
@@ -72,11 +185,11 @@
 /**
  * @brief The basic parameter definition for parsing url
  */
-#define PROTLOCOL_MAX_SIZE 12
+#define PROTOCOL_MAX_SIZE 12
 #define FILENAME_MAX_SIZE 64
 #define FILEPATH_MAX_SIZE (FILENAME_MAX_SIZE + ESP_VFS_PATH_MAX)
 typedef struct request_url {
-    char protocol[PROTLOCOL_MAX_SIZE];
+    char protocol[PROTOCOL_MAX_SIZE];
     uint16_t port;
     char file_name[FILENAME_MAX_SIZE];
     char file_path[FILEPATH_MAX_SIZE];
@@ -223,6 +336,8 @@
 static esp_err_t esp_otbr_ipaddr_get_handler(httpd_req_t *req);
 static esp_err_t esp_otbr_add_ipaddr_post_handler(httpd_req_t *req);
 static esp_err_t esp_otbr_delete_ipaddr_post_handler(httpd_req_t *req);
+static esp_err_t esp_otbr_ota_post_handler(httpd_req_t *req);
+static esp_err_t esp_otbr_auth_credentials_put_handler(httpd_req_t *req);
 
 static httpd_uri_t s_web_gui_handlers[] = {
     {
@@ -303,9 +418,57 @@
         .handler = esp_otbr_delete_ipaddr_post_handler,
         .user_ctx = &s_server.data,
     },
+    {
+        .uri = "/api/credentials",
+        .method = HTTP_PUT,
+        .handler = esp_otbr_auth_credentials_put_handler,
+        .user_ctx = &s_server.data,
+    },
+    {
+        .uri = "/api/ota",
+        .method = HTTP_POST,
+        .handler = esp_otbr_ota_post_handler,
+        .user_ctx = NULL,
+    },
 };
 
 /*-----------------------------------------------------
+ Note:Http Server Basic Auth (wrapper)
+-----------------------------------------------------*/
+static esp_err_t send_401_unauthorized(httpd_req_t *req)
+{
+    httpd_resp_set_status(req, "401 Unauthorized");
+    httpd_resp_set_hdr(req, "WWW-Authenticate", "Basic realm=\"ESP Thread BR\"");
+    httpd_resp_send(req, NULL, 0);
+    return ESP_FAIL;
+}
+
+typedef struct {
+    esp_err_t (*orig_handler)(httpd_req_t *req);
+    void *orig_user_ctx;
+    bool wrapped;
+} auth_wrapped_ctx_t;
+
+/* s_resource_handlers / s_web_gui_handlers はこの直前で既に定義済みなので sizeof が使える */
+static auth_wrapped_ctx_t s_resource_handler_ctx[sizeof(s_resource_handlers) / sizeof(httpd_uri_t)];
+static auth_wrapped_ctx_t s_web_gui_handler_ctx[sizeof(s_web_gui_handlers) / sizeof(httpd_uri_t)];
+static auth_wrapped_ctx_t s_default_uri_ctx;
+
+static esp_err_t auth_wrapper_handler(httpd_req_t *req)
+{
+    auth_wrapped_ctx_t *ctx = (auth_wrapped_ctx_t *)req->user_ctx;
+
+    if (!check_basic_auth(req)) {
+        return send_401_unauthorized(req);
+    }
+
+    req->user_ctx = ctx->orig_user_ctx;
+    esp_err_t ret = ctx->orig_handler(req);
+    req->user_ctx = ctx;
+    return ret;
+}
+
+/*-----------------------------------------------------
  Note:Http Tools
 -----------------------------------------------------*/
 static cJSON *pack_response(cJSON *error, cJSON *result, cJSON *message)
@@ -338,10 +501,17 @@
     return root;
 }
 
-static esp_err_t httpd_server_register_http_uri(const http_server_t *server, httpd_uri_t *uris, uint8_t size)
+static esp_err_t httpd_server_register_http_uri(const http_server_t *server, httpd_uri_t *uris, auth_wrapped_ctx_t *ctx_storage, uint8_t size)
 {
     ESP_RETURN_ON_FALSE((server->handle && uris), ESP_ERR_INVALID_ARG, WEB_TAG, "Invalid argument");
     for (int i = 0; i < size; i++) {
+        if (!ctx_storage[i].wrapped) {
+            ctx_storage[i].orig_handler = uris[i].handler;
+            ctx_storage[i].orig_user_ctx = uris[i].user_ctx;
+            ctx_storage[i].wrapped = true;
+        }
+        uris[i].handler = auth_wrapper_handler;
+        uris[i].user_ctx = &ctx_storage[i];
         ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server->handle, &uris[i]), WEB_TAG,
                             "Failed to register %s for %d", uris[i].uri, i);
     }
@@ -1066,6 +1236,285 @@
     return ret;
 }
 
+static esp_err_t esp_otbr_auth_credentials_put_handler(httpd_req_t *req)
+{
+    esp_err_t ret = ESP_OK;
+    cJSON *request = httpd_request_convert2_json(req, cJSON_Object);
+    ESP_RETURN_ON_FALSE(request, ESP_FAIL, WEB_TAG, "Failed to parse credentials request");
+
+    cJSON *username = cJSON_GetObjectItem(request, "username");
+    cJSON *password = cJSON_GetObjectItem(request, "password");
+
+    bool ok = true;
+    if (cJSON_IsString(username) && strlen(username->valuestring) > 0 &&
+        strlen(username->valuestring) < BR_WEB_AUTH_USERNAME_MAXLEN) {
+        ok = ok && (esp_br_web_auth_set_username(username->valuestring) == ESP_OK);
+    }
+    if (cJSON_IsString(password) && strlen(password->valuestring) > 0 &&
+        strlen(password->valuestring) < BR_WEB_AUTH_PASSWORD_MAXLEN) {
+        ok = ok && (esp_br_web_auth_set_password(password->valuestring) == ESP_OK);
+    }
+
+    cJSON *error = cJSON_CreateNumber(ok ? 0 : 1);
+    cJSON *result = cJSON_CreateString(ok ? "successful" : "failed");
+    cJSON *message = cJSON_CreateString(ok ? "Credentials updated" : "Failed to update credentials");
+    cJSON *response = pack_response(error, result, message);
+    ESP_GOTO_ON_ERROR(httpd_send_packet(req, response), exit, WEB_TAG, "Failed to respond %s", req->uri);
+exit:
+    cJSON_Delete(request);
+    cJSON_Delete(response);
+    return ret;
+}
+
+static esp_err_t esp_otbr_ota_post_handler(httpd_req_t *req)
+{
+    esp_err_t ret = ESP_OK;
+    esp_ota_handle_t ota_handle = 0;
+    const esp_partition_t *update_partition = NULL;
+    bool ota_started = false;
+
+    if (req == NULL) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    if (req->content_len <= 0) {
+        httpd_resp_send_err(
+            req,
+            HTTPD_400_BAD_REQUEST,
+            "Invalid firmware size"
+        );
+        return ESP_ERR_INVALID_SIZE;
+    }
+
+    ESP_LOGI(
+        WEB_TAG,
+        "OTA upload started: %d bytes",
+        req->content_len
+    );
+
+    /*
+     * Select the OTA partition that is not currently running.
+     */
+    update_partition = esp_ota_get_next_update_partition(NULL);
+
+    if (update_partition == NULL) {
+        ESP_LOGE(
+            WEB_TAG,
+            "No OTA partition available"
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_500_INTERNAL_SERVER_ERROR,
+            "No OTA partition available"
+        );
+
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    ESP_LOGI(
+        WEB_TAG,
+        "OTA target: %s @ 0x%" PRIx32 ", size=%" PRIu32,
+        update_partition->label,
+        update_partition->address,
+        update_partition->size
+    );
+
+    /*
+     * Make sure the uploaded firmware fits in the OTA partition.
+     *
+     * Your partition table gives ota_0 and ota_1 4 MiB each.
+     */
+    if ((uint32_t)req->content_len > update_partition->size) {
+        ESP_LOGE(
+            WEB_TAG,
+            "Firmware too large: %d > %" PRIu32,
+            req->content_len,
+            update_partition->size
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_400_BAD_REQUEST,
+            "Firmware image is too large"
+        );
+
+        return ESP_ERR_INVALID_SIZE;
+    }
+
+    /*
+     * Start OTA.
+     */
+    ret = esp_ota_begin(
+        update_partition,
+        OTA_WITH_SEQUENTIAL_WRITES,
+        &ota_handle
+    );
+
+    if (ret != ESP_OK) {
+        ESP_LOGE(
+            WEB_TAG,
+            "esp_ota_begin failed: %s",
+            esp_err_to_name(ret)
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_500_INTERNAL_SERVER_ERROR,
+            "Failed to start OTA"
+        );
+
+        return ret;
+    }
+
+    ota_started = true;
+
+    char buffer[FILE_CHUNK_SIZE];
+    int remaining = req->content_len;
+
+    while (remaining > 0) {
+
+        int recv_size = remaining;
+
+        if (recv_size > (int)sizeof(buffer)) {
+            recv_size = sizeof(buffer);
+        }
+
+        int recv_len = httpd_req_recv(
+            req,
+            buffer,
+            recv_size
+        );
+
+        if (recv_len == HTTPD_SOCK_ERR_TIMEOUT) {
+            continue;
+        }
+
+        if (recv_len <= 0) {
+            ESP_LOGE(
+                WEB_TAG,
+                "Failed to receive firmware data: %d",
+                recv_len
+            );
+
+            ret = ESP_FAIL;
+            goto ota_error;
+        }
+
+        ret = esp_ota_write(
+            ota_handle,
+            buffer,
+            recv_len
+        );
+
+        if (ret != ESP_OK) {
+            ESP_LOGE(
+                WEB_TAG,
+                "esp_ota_write failed: %s",
+                esp_err_to_name(ret)
+            );
+
+            goto ota_error;
+        }
+
+        remaining -= recv_len;
+
+        ESP_LOGD(
+            WEB_TAG,
+            "OTA progress: %d / %d",
+            req->content_len - remaining,
+            req->content_len
+        );
+    }
+
+    /*
+     * Finish and validate the OTA image.
+     */
+    ret = esp_ota_end(ota_handle);
+
+    ota_started = false;
+
+    if (ret != ESP_OK) {
+        ESP_LOGE(
+            WEB_TAG,
+            "esp_ota_end failed: %s",
+            esp_err_to_name(ret)
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_400_BAD_REQUEST,
+            "Invalid firmware image"
+        );
+
+        return ret;
+    }
+
+    /*
+     * Select the new firmware as the next boot partition.
+     */
+    ret = esp_ota_set_boot_partition(update_partition);
+
+    if (ret != ESP_OK) {
+        ESP_LOGE(
+            WEB_TAG,
+            "Failed to set boot partition: %s",
+            esp_err_to_name(ret)
+        );
+
+        httpd_resp_send_err(
+            req,
+            HTTPD_500_INTERNAL_SERVER_ERROR,
+            "Failed to set boot partition"
+        );
+
+        return ret;
+    }
+
+    ESP_LOGI(
+        WEB_TAG,
+        "OTA successful. Boot partition: %s",
+        update_partition->label
+    );
+
+    /*
+     * Send the HTTP response before restarting.
+     */
+    httpd_resp_set_type(
+        req,
+        "application/json"
+    );
+
+    ret = httpd_resp_sendstr(
+        req,
+        "{\"error\":0,\"message\":\"OTA successful. Rebooting...\"}"
+    );
+
+    /*
+     * Give the HTTP response some time to reach the browser.
+     */
+    vTaskDelay(pdMS_TO_TICKS(500));
+
+    esp_restart();
+
+    return ret;
+
+
+ota_error:
+
+    if (ota_started) {
+        esp_ota_abort(ota_handle);
+    }
+
+    httpd_resp_send_err(
+        req,
+        HTTPD_500_INTERNAL_SERVER_ERROR,
+        "OTA failed"
+    );
+
+    return ret;
+}
+
 /*-----------------------------------------------------
  Note:Handling for Client request
 -----------------------------------------------------*/
@@ -1190,7 +1639,7 @@
         .file_path = "",
     };
     ret.port = parse_url->port;
-    if ((parse_url->field_set & (1 << UF_SCHEMA)) != 0 && PROTLOCOL_MAX_SIZE > parse_url->field_data[UF_SCHEMA].len) {
+    if ((parse_url->field_set & (1 << UF_SCHEMA)) != 0 && PROTOCOL_MAX_SIZE > parse_url->field_data[UF_SCHEMA].len) {
         memcpy(ret.protocol, uri + parse_url->field_data[UF_SCHEMA].off, parse_url->field_data[UF_SCHEMA].len);
         ret.protocol[parse_url->field_data[UF_SCHEMA].len] = '\0';
     }
@@ -1332,11 +1781,18 @@
 
     httpd_uri_t default_uris_get = {.uri = "/*", // Match all URIs of type /path/to/file
                                     .method = HTTP_GET,
-                                    .handler = default_urls_get_handler,
-                                    .user_ctx = &s_server.data};
+                                    .handler = auth_wrapper_handler,
+                                    .user_ctx = &s_default_uri_ctx};
+    if (!s_default_uri_ctx.wrapped) {
+        s_default_uri_ctx.orig_handler = default_urls_get_handler;
+        s_default_uri_ctx.orig_user_ctx = &s_server.data;
+        s_default_uri_ctx.wrapped = true;
+    }
 
-    httpd_server_register_http_uri(&s_server, s_resource_handlers, sizeof(s_resource_handlers) / sizeof(httpd_uri_t));
-    httpd_server_register_http_uri(&s_server, s_web_gui_handlers, sizeof(s_web_gui_handlers) / sizeof(httpd_uri_t));
+    httpd_server_register_http_uri(&s_server, s_resource_handlers, s_resource_handler_ctx,
+                                   sizeof(s_resource_handlers) / sizeof(httpd_uri_t));
+    httpd_server_register_http_uri(&s_server, s_web_gui_handlers, s_web_gui_handler_ctx,
+                                   sizeof(s_web_gui_handlers) / sizeof(httpd_uri_t));
     httpd_register_uri_handler(s_server.handle, &default_uris_get);
 
     // Show the login address in the console
@@ -1404,6 +1860,7 @@
 
 void esp_br_web_start(char *base_path)
 {
+    esp_br_web_auth_init();  /* NVSからユーザー名/パスワードを読めるようにしておく */
     ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &handler_got_ip_event, base_path));
     ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, &handler_got_ip_event, base_path));
 }

API

esp-thread-br/components/esp_ot_br_server/private_include/esp_br_web_api.h

/*---------------------------------------------------------------------
            ESP Thread Border Router Wer Server REST API
----------------------------------------------------------------------*/
/* HTTP GET */
#define ESP_OT_REST_API_DIAGNOSTICS_PATH "/diagnostics"
#define ESP_OT_REST_API_NODE_PATH "/node"
#define ESP_OT_REST_API_NODE_RLOC_PATH "/node/rloc"
#define ESP_OT_REST_API_NODE_RLOC16_PATH "/node/rloc16"
#define ESP_OT_REST_API_NODE_STATE_PATH "/node/state"
#define ESP_OT_REST_API_NODE_EXTADDRESS_PATH "/node/ext-address"
#define ESP_OT_REST_API_NODE_NETWORKNAME_PATH "/node/network-name"
#define ESP_OT_REST_API_NODE_LEADERDATA_PATH "/node/leader-data"
#define ESP_OT_REST_API_NODE_NUMBEROFROUTER_PATH "/node/num-of-router"
#define ESP_OT_REST_API_NODE_EXTPANID_PATH "/node/ext-panid"
#define ESP_OT_REST_API_NODE_BORDERAGENTID_PATH "/node/ba-id"
#define ESP_OT_REST_API_NODE_DATASET_ACTIVE_PATH "/node/dataset/active"
#define ESP_OT_REST_API_NODE_DATASET_PENDING_PATH "/node/dataset/pending"
#define ESP_OT_REST_API_PROPERTIES_PATH "/get_properties"
#define ESP_OT_REST_API_AVAILABLE_NETWORK_PATH "/available_network"
#define ESP_OT_REST_API_NODE_INFORMATION_PATH "/node_information"
#define ESP_OT_REST_API_TOPOLOGY_PATH "/topology"
/* HTTP POST */
#define ESP_OT_REST_API_JOIN_NETWORK_PATH "/join_network"
#define ESP_OT_REST_API_FORM_NETWORK_PATH "/form_network"
#define ESP_OT_REST_API_ADD_NETWORK_PREFIX_PATH "/add_prefix"
#define ESP_OT_REST_API_DELETE_NETWORK_PREFIX_PATH "/delete_prefix"
#define ESP_OT_REST_API_PING_PATH "/ping"
#define ESP_OT_REST_API_IPADDR_PATH "/ipaddr"
#define ESP_OT_REST_API_ADD_IPADDR_PATH "/add_ipaddr"
#define ESP_OT_REST_API_DELETE_IPADDR_PATH "/delete_ipaddr"
/* To implement in the future */
#define ESP_OT_REST_API_COMMISSION_PATH "/commission"
#define ESP_OT_REST_API_NETWORK "/networks"
#define ESP_OT_REST_API_NETWORK_CURRENT "/networks/current"
#define ESP_OT_REST_API_NETWORK_CURRENT_COMMISSION "/networks/commission"
#define ESP_OT_REST_API_NETWORK_CURRENT_PREFIX "/networks/current/prefix"

APIへのアクセス

$ curl -u user_name:password http://<local_IP>/get_properties
$ curl -u user_name:password http://<local_IP>/node/dataset/pending
$ curl -u user_name:password http://<local_IP>/node/dataset/active

Thread(IEEE 802.15.4)チャンネル

Wi-Fi 2.4GHzチャンネル(1〜13)とThread(IEEE 802.15.4)チャンネルの対応表

日本国内ではチャンネル13まで利用可能なので、全て含めています。

対応表

Wi-Fiチャンネル 中心周波数 占有帯域(目安、幅22MHz) 重なるThreadチャンネル
1 2412 MHz 2401–2423 MHz 11, 12, 13, 14
2 2417 MHz 2406–2428 MHz 12, 13, 14, 15
3 2422 MHz 2411–2433 MHz 13, 14, 15, 16
4 2427 MHz 2416–2438 MHz 14, 15, 16, 17
5 2432 MHz 2421–2443 MHz 15, 16, 17, 18
6 2437 MHz 2426–2448 MHz 16, 17, 18, 19
7 2442 MHz 2431–2453 MHz 17, 18, 19, 20
8 2447 MHz 2436–2458 MHz 18, 19, 20, 21
9 2452 MHz 2441–2463 MHz 19, 20, 21, 22
10 2457 MHz 2446–2468 MHz 20, 21, 22, 23
11 2462 MHz 2451–2473 MHz 21, 22, 23, 24
12 2467 MHz 2456–2478 MHz 22, 23, 24, 25
13 2472 MHz 2461–2483 MHz 23, 24, 25, 26

読み方と注意点

  • 中心周波数の計算式:Wi-Fi = 2412 + 5×(チャンネル番号−1) MHz、Thread = 2405 + 5×(チャンネル番号−11) MHz
  • 占有帯域は目安です。実際の干渉の強さは変調方式・送信出力・距離によって変わるため、「重なる」と表にあっても実運用上は問題にならないケースもあります。逆に隣接帯域からの漏れ込みで、表にないチャンネルにも軽微な影響が出ることがあります。
  • Wi-Fiでよく推奨される「非重複チャンネル」は1, 6, 11(海外)または1, 6, 11, 13(日本など13ch対応国)です。この3〜4チャンネルはWi-Fi同士では重ならない設計になっています。

実用的な組み合わせの例

もしWi-Fiルーターがチャンネル1を使っているなら、Threadは表の右列(11〜14)を避けて、チャンネル20以降を選ぶと干渉を大きく減らせます。

逆にWi-Fiがチャンネル11を使っているなら、Threadはチャンネル11〜17あたり(低い方)を選ぶのが安全です。

WiFiリセット機能追加

bootボタン長押しによるWiFi設定のリセット機能追加。WiFiリセットによりAPモードへ自動的に移行。

esp-thread-br/examples/basic_thread_border_router/main/CMakeLists.txt

idf_component_register(SRCS "esp_ot_br.c" "wifi_reset.c"
                       INCLUDE_DIRS "."
                       EMBED_TXTFILES ${project_dir}/server_certs/ca_cert.pem
                       )

esp-thread-br/examples/basic_thread_border_router/main/esp_ot_br.c

extern const uint8_t server_cert_pem_start[] asm("_binary_ca_cert_pem_start");
extern const uint8_t server_cert_pem_end[] asm("_binary_ca_cert_pem_end");

+ extern void wifi_reset_button_start(void);
.....
.....
.....
.....
.....
#if CONFIG_OPENTHREAD_BR_START_WEB
    esp_br_web_start("/spiffs");
#endif

    launch_openthread_border_router(&openthread_config, &rcp_update_config);

+ wifi_reset_button_start();

}

esp-thread-br/examples/basic_thread_border_router/main/wifi_reset.c

/*
 * wifi_reset.c
 *
 * Pressing and holding the BOOT button (ESP32-C6-DevKitC-1: GPIO9, active-low)
 * for 3 seconds clears the Wi-Fi settings stored by esp_ot_wifi_cmd.c in its
 * custom "wifi_config" NVS namespace (keys: ssid / password), then reboots
 * the device.
 *
 * IMPORTANT: In this configuration, calling esp_wifi_restore() has no effect.
 * The SSID/PSK actually used for automatic Wi-Fi reconnection is stored in
 * the custom storage ("wifi_config" namespace), which is accessed through
 * esp_ot_wifi_config_get_ssid() / esp_ot_wifi_config_get_password().
 * Therefore, the settings must be cleared using the
 * esp_ot_wifi_config_clear() function provided by esp_ot_wifi_cmd.c.
 * This performs exactly the same operation as the
 * `wifi config clear` command in the serial console.
 *
 * PREREQUISITE: The BOOT button on GPIO9 is unrelated to the RCP pins
 * (GPIO7/8) and can be freely used as a GPIO input while the application
 * is running. GPIO8 is currently used by the LED driver, so it must not
 * be touched.
 */

#include "esp_log.h"
#include "esp_system.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_ot_wifi_cmd.h"   /* esp_ot_wifi_config_init/clear/get_ssid, esp_ot_wifi_disconnect, esp_ot_wifi_state_get */

static const char *TAG = "wifi_reset_btn";

#define WIFI_RESET_BUTTON_GPIO   GPIO_NUM_9   /* BOOT button */
#define WIFI_RESET_HOLD_MS       3000         /* 3 seconds hold to reset */
#define WIFI_RESET_POLL_MS       100

static void wifi_reset_button_task(void *arg)
{
    gpio_config_t io_conf = {
        .pin_bit_mask = 1ULL << WIFI_RESET_BUTTON_GPIO,
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
    };
    gpio_config(&io_conf);

    int held_ms = 0;

    while (1) {
        if (gpio_get_level(WIFI_RESET_BUTTON_GPIO) == 0) { /* push down (Low) */
            held_ms += WIFI_RESET_POLL_MS;
            if (held_ms >= WIFI_RESET_HOLD_MS) {
                ESP_LOGW(TAG, "BOOT button held %dms, clearing stored Wi-Fi config...", held_ms);

                /* Disconnect if currently connected to prevent reconnection */
                if (esp_ot_wifi_state_get() == OT_WIFI_CONNECTED) {
                    esp_ot_wifi_disconnect();
                    vTaskDelay(pdMS_TO_TICKS(200));
                }

                /* Ensure the "wifi_config" NVS namespace is open before clearing it */
                esp_err_t init_err = esp_ot_wifi_config_init();
                if (init_err != ESP_OK) {
                    ESP_LOGE(TAG, "esp_ot_wifi_config_init failed: %s", esp_err_to_name(init_err));
                } else {
                    esp_err_t clr_err = esp_ot_wifi_config_clear();
                    if (clr_err != ESP_OK) {
                        ESP_LOGE(TAG, "esp_ot_wifi_config_clear failed: %s", esp_err_to_name(clr_err));
                    } else {
                        ESP_LOGW(TAG, "Stored Wi-Fi ssid/password cleared. Rebooting...");
                    }
                }

                vTaskDelay(pdMS_TO_TICKS(500));
                esp_restart();
            }
        } else {
            held_ms = 0; /* Reset the counter when the button is released */
        }
        vTaskDelay(pdMS_TO_TICKS(WIFI_RESET_POLL_MS));
    }
}

void wifi_reset_button_start(void)
{
    xTaskCreate(wifi_reset_button_task, "wifi_reset_btn", 3072, NULL, 5, NULL);
}