瀏覽代碼

基线 v1.0-prod: 多路RTSP合成显示系统(RK3568)

源自 /root/hd-ws/rtsp568 (2026-08 RK3588转RK3568正式版)。
包含: 显示/解码/合成/PLC随动/网页配置/离线部署与打包。
最新生产二进制 md5: 63252ff9afca147bd89edd1c3907280d
详细历史见 docs/开发历程.md
layoutx-dev 1 周之前
當前提交
a7697d40a0
共有 56 個文件被更改,包括 12640 次插入 和 0 次删除
  1. 15 0
      .gitignore
  2. 99 0
      CMakeLists.txt
  3. 121 0
      README.md
  4. 107 0
      auto_reboot.sh
  5. 50 0
      build.sh
  6. 93 0
      capture_fb.c
  7. 196 0
      channels.conf
  8. 112 0
      channels_flow_example.conf
  9. 118 0
      channels_local.conf
  10. 717 0
      composer.c
  11. 78 0
      composer.h
  12. 453 0
      decoder.c
  13. 30 0
      decoder.h
  14. 148 0
      deploy/conf/channels.conf
  15. 134 0
      deploy/deploy.sh
  16. 30 0
      deploy/fix_service.sh
  17. 二進制
      deploy/fltk.png
  18. 158 0
      deploy/install.sh
  19. 101 0
      deploy/pack.sh
  20. 196 0
      deploy/scripts/plc_server.py
  21. 41 0
      deploy/scripts/plc_server.sh
  22. 517 0
      detector.c
  23. 29 0
      detector.h
  24. 626 0
      display.c
  25. 26 0
      display.h
  26. 39 0
      dma_util.c
  27. 21 0
      dma_util.h
  28. 115 0
      docs/开发历程.md
  29. 157 0
      flog.c
  30. 23 0
      flog.h
  31. 二進制
      fltk.png
  32. 218 0
      hikvision.c
  33. 59 0
      hikvision.h
  34. 72 0
      install.sh
  35. 159 0
      install_local_test.sh
  36. 17 0
      linux/dma-heap.h
  37. 1903 0
      main.c
  38. 485 0
      plc.c
  39. 45 0
      plc.h
  40. 196 0
      plc_server.py
  41. 47 0
      plc_server.sh
  42. 558 0
      plc_sim.c
  43. 804 0
      rknn_api.h
  44. 21 0
      start.sh
  45. 156 0
      test_detector_bench.c
  46. 110 0
      test_dma_heap.c
  47. 481 0
      test_drm_display.c
  48. 20 0
      test_mpp.c
  49. 313 0
      test_plc.c
  50. 131 0
      test_rga.c
  51. 190 0
      test_rga_color.c
  52. 744 0
      title.c
  53. 54 0
      title.h
  54. 21 0
      uninstall.sh
  55. 1267 0
      web_server.c
  56. 19 0
      web_server.h

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+build/
+build_asan/
+models/
+deploy/*.tar.gz
+deploy/models/
+deploy/bin/
+plc_sim
+capture_fb
+__pycache__/
+*.pyc
+*.o
+*.log
+flog*.txt
+diag_*.txt
+channels.conf.cp

+ 99 - 0
CMakeLists.txt

@@ -0,0 +1,99 @@
+cmake_minimum_required(VERSION 3.10)
+project(rtsp_multi_view C)
+
+set(CMAKE_C_STANDARD 11)
+set(CMAKE_C_STANDARD_REQUIRED OFF)
+
+find_package(PkgConfig REQUIRED)
+
+pkg_check_modules(GST REQUIRED gstreamer-1.0 gstreamer-app-1.0
+                           gstreamer-video-1.0 gstreamer-allocators-1.0)
+pkg_check_modules(RGA REQUIRED librga)
+pkg_check_modules(DRM REQUIRED libdrm)
+pkg_check_modules(PNG REQUIRED libpng)
+pkg_check_modules(JPEG REQUIRED libjpeg)
+pkg_check_modules(FT REQUIRED freetype2)
+pkg_check_modules(CURL REQUIRED libcurl)
+
+include_directories(${CMAKE_SOURCE_DIR}
+                    ${GST_INCLUDE_DIRS}
+                    ${RGA_INCLUDE_DIRS}
+                    ${DRM_INCLUDE_DIRS}
+                    ${PNG_INCLUDE_DIRS}
+                    ${JPEG_INCLUDE_DIRS}
+                    ${FT_INCLUDE_DIRS}
+                    ${CURL_INCLUDE_DIRS}
+                    /usr/include/rockchip)
+
+link_directories(${GST_LIBRARY_DIRS}
+                 ${RGA_LIBRARY_DIRS}
+                 ${DRM_LIBRARY_DIRS}
+                 ${PNG_LIBRARY_DIRS}
+                 ${JPEG_LIBRARY_DIRS}
+                 ${FT_LIBRARY_DIRS}
+                 ${CURL_LIBRARY_DIRS})
+
+function(common_cflags tgt)
+    target_compile_options(${tgt} PRIVATE -Wall -Wextra -O2)
+endfunction()
+
+add_executable(rtsp_multi_view
+    main.c
+    decoder.c
+    composer.c
+    display.c
+    plc.c
+    title.c
+    flog.c
+    hikvision.c
+    dma_util.c
+    web_server.c
+)
+
+target_link_libraries(rtsp_multi_view
+    ${GST_LIBRARIES}
+    ${RGA_LIBRARIES}
+    ${DRM_LIBRARIES}
+    ${PNG_LIBRARIES}
+    ${JPEG_LIBRARIES}
+    ${FT_LIBRARIES}
+    ${CURL_LIBRARIES}
+    rockchip_mpp
+    pthread
+    dl
+    m
+)
+
+common_cflags(rtsp_multi_view)
+
+# --- Unit tests / benchmarks ---
+add_executable(test_dma_heap test_dma_heap.c dma_util.c)
+target_link_libraries(test_dma_heap ${DRM_LIBRARIES})
+common_cflags(test_dma_heap)
+
+add_executable(test_drm_display test_drm_display.c)
+target_link_libraries(test_drm_display ${DRM_LIBRARIES})
+common_cflags(test_drm_display)
+
+add_executable(test_rga test_rga.c)
+target_link_libraries(test_rga ${RGA_LIBRARIES} ${DRM_LIBRARIES})
+common_cflags(test_rga)
+
+add_executable(test_rga_color test_rga_color.c)
+target_link_libraries(test_rga_color ${RGA_LIBRARIES} ${DRM_LIBRARIES})
+common_cflags(test_rga_color)
+
+# RKNN detection benchmark (not part of the main binary; needs rknnrt)
+add_executable(test_detector_bench test_detector_bench.c detector.c dma_util.c)
+target_link_libraries(test_detector_bench ${RGA_LIBRARIES} ${DRM_LIBRARIES}
+                      rknnrt rockchip_mpp pthread dl m)
+common_cflags(test_detector_bench)
+target_include_directories(test_detector_bench PRIVATE ${CMAKE_SOURCE_DIR})
+
+add_executable(test_plc test_plc.c plc.c flog.c)
+target_link_libraries(test_plc pthread)
+common_cflags(test_plc)
+
+add_executable(plc_sim plc_sim.c)
+target_link_libraries(plc_sim pthread)
+common_cflags(plc_sim)

+ 121 - 0
README.md

@@ -0,0 +1,121 @@
+# rtsp_multi_view
+
+多路 RTSP 视频流多画面合成显示系统,运行于 RK3568 (Rockchip SoC)。
+
+## 功能
+
+- 从多个 IP 摄像头通过 RTSP 拉取视频流
+- Rockchip MPP 硬件解码 H.264/H.265(优先零拷贝 DMABuf 直通,不支持时自动回退)
+- Rockchip RGA 硬件加速多画面拼接
+- DRM/KMS 直接输出到 HDMI(双缓冲 VBlank 同步,无撕裂)
+- PLC (S7comm) 位控制布局切换;单画面布局自动切换为对应摄像机的主码流
+- 自动定时重启设备(按日/周周期)
+
+## 编译
+
+```bash
+# 快捷编译(推荐)
+./build.sh
+
+# 或手动
+cmake -B build .
+cmake --build build -j$(nproc)
+```
+
+依赖:GStreamer 1.0(含 allocators)、librga、libdrm、rockchip_mpp、
+freetype2、libpng、libjpeg、libcurl、pthread。
+`rknnrt` 仅 `test_detector_bench` 基准测试需要。
+
+## 运行
+
+```bash
+# PLC 联调模式(同时启动 plc_server.py + plc_sim + auto_reboot)
+sudo ./plc_server.sh
+
+# 仅启动主程序
+./start.sh
+
+# 或手动指定
+./build/rtsp_multi_view -c channels.conf
+```
+
+`Ctrl+C` 退出。
+
+## 配置文件 (channels.conf)
+
+主要配置段:
+
+```ini
+[output]          # 输出分辨率
+[camera_list]     # 海康相机批量配置(自动生成 RTSP URL)
+[isapi_write]     # 可选:写回相机编码参数
+[layout]          # 默认布局网格
+[layoutN]         # 各布局的摄像机排列
+[title]           # 标题栏
+[plc]             # PLC 连接参数
+[plc_rules]       # PLC 位号 -> 布局映射规则
+[auto_reboot]     # 自动重启调度
+```
+
+### 自动重启配置
+
+```ini
+[auto_reboot]
+enable = 0        # 0=禁用(默认) 1=启用
+period = daily    # daily=每天  weekly=每周
+time = 03:00      # HH:MM 24小时制
+weekday = monday  # weekly 时有效: monday~sunday
+```
+
+## 部署
+
+### 一键部署到新主机
+
+需要 `sshpass`(脚本会自动安装)。
+
+```bash
+# 默认部署到 192.168.1.243(root/123456)
+./deploy/deploy.sh
+
+# 指定目标机
+./deploy/deploy.sh -i 192.168.1.100 -u root -p mypass
+
+# 编译后部署
+./deploy/deploy.sh -c
+
+# 只打包不部署
+./deploy/deploy.sh -n
+```
+
+脚本自动完成:编译 → 打包 → scp 传输 → 远程执行 install.sh,无需手动干预。
+
+### 手动部署
+
+```bash
+# 1. 本机打包(产物在 deploy/ 目录)
+./deploy/pack.sh
+
+# 2. 传到目标机
+scp deploy/rtsp_multi_view_aarch64_*.tar.gz root@192.168.1.243:/tmp/
+
+# 3. 远程安装
+ssh root@192.168.1.243
+cd /tmp && tar xzf rtsp_multi_view_aarch64_*.tar.gz
+cd rtsp_multi_view_deploy && ./install.sh
+```
+
+### 本地安装
+
+```bash
+sudo ./install.sh     # 安装(开机自启)
+sudo ./uninstall.sh   # 卸载
+```
+
+### 目标机服务管理
+
+```bash
+systemctl start rtsp_multi_view    # 启动主程序
+systemctl start auto-reboot        # 启动自动重启调度
+systemctl status rtsp_multi_view   # 查看状态
+journalctl -u rtsp_multi_view -f   # 查看日志
+```

+ 107 - 0
auto_reboot.sh

@@ -0,0 +1,107 @@
+#!/bin/bash
+# 自动重启调度器:读取 channels.conf [auto_reboot] 段,
+# 按 daily/weekly 周期在指定时间触发 reboot。
+# 用法: ./auto_reboot.sh [-c channels.conf]
+# 可作为 plc_server.sh 的后台子进程,或独立 systemd 服务运行。
+
+DIR="$(cd "$(dirname "$0")" && pwd)"
+CONF="${CONF:-${DIR}/channels.conf}"
+CHECK_INTERVAL=30
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        -c) CONF="$2"; shift ;;
+        *) shift ;;
+    esac
+done
+
+# ---------- 解析 [auto_reboot] 段 ----------
+parse_auto_reboot() {
+    local enabled=0 period="daily" time="03:00" weekday="monday"
+    [ ! -f "$CONF" ] && return
+
+    local section=0
+    while IFS= read -r line; do
+        local s="$line"
+        s="${s#"${s%%[![:space:]]*}"}"
+        case "$s" in
+            \#*|"") continue ;;
+            \[*)
+                if [ "$s" = "[auto_reboot]" ]; then section=1
+                else section=0; fi
+                continue
+                ;;
+        esac
+        [ "$section" -ne 1 ] && continue
+
+        local key="${s%%=*}"; key="${key%"${key##*[![:space:]]}"}"
+        local val="${s#*=}"; val="${val#"${val%%[![:space:]]*}"}"
+        val="${val%"${val##*[![:space:]]}"}"
+        val="${val%%;*}"; val="${val%%#*}"
+        val="${val%"${val##*[![:space:]]}"}"
+
+        case "$key" in
+            enable)   ABR_ENABLED="$val" ;;
+            period)   ABR_PERIOD="$val" ;;
+            time)     ABR_TIME="$val" ;;
+            weekday)  ABR_WEEKDAY="$val" ;;
+        esac
+    done < "$CONF"
+}
+
+# ---------- weekday 名字 -> tm 可用的数字(1=Mon..7=Sun) ----------
+weekday_to_num() {
+    case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in
+        mon*)  echo 1 ;; tue*)  echo 2 ;; wed*)  echo 3 ;;
+        thu*)  echo 4 ;; fri*)  echo 5 ;; sat*)  echo 6 ;;
+        sun*)  echo 7 ;; *)      echo 0 ;;
+    esac
+}
+
+# ---------- 判断当前时刻是否匹配重启条件 ----------
+should_reboot_now() {
+    local cur_h cur_m cur_wday cur_time_min
+    cur_h=$(date +%H);  cur_h=${cur_h#0}
+    cur_m=$(date +%M);  cur_m=${cur_m#0}
+    cur_wday=$(date +%u)
+    cur_time_min=$((cur_h * 60 + cur_m))
+
+    local want_h want_m
+    want_h="${ABR_TIME%%:*}"; want_h="${want_h#0}"
+    want_m="${ABR_TIME##*:}"; want_m="${want_m#0}"
+    local want_time_min=$((want_h * 60 + want_m))
+
+    # 时间窗口:target minute ± 1 check_interval (防止刚好错过)
+    local low=$((want_time_min - CHECK_INTERVAL))
+    local high=$((want_time_min + 1))
+    [ $low -lt 0 ] && low=0
+
+    if [ $cur_time_min -ge $low ] && [ $cur_time_min -le $high ]; then
+        if [ "$ABR_PERIOD" = "weekly" ]; then
+            local wday; wday=$(weekday_to_num "$ABR_WEEKDAY")
+            [ "$cur_wday" -eq "$wday" ]
+        else
+            return 0
+        fi
+    else
+        return 1
+    fi
+}
+
+# ---------- main ----------
+parse_auto_reboot
+
+if [ "$ABR_ENABLED" != "1" ]; then
+    echo "[auto_reboot] 未启用"
+    exit 0
+fi
+
+echo "[auto_reboot] 启用: period=$ABR_PERIOD time=$ABR_TIME weekday=$ABR_WEEKDAY"
+
+while true; do
+    if should_reboot_now; then
+        echo "[auto_reboot] $(date '+%F %T') 触发重启..."
+        /sbin/reboot
+    fi
+    sleep "$CHECK_INTERVAL"
+done

+ 50 - 0
build.sh

@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+set -e
+
+DIR="$(cd "$(dirname "$0")" && pwd)"
+BUILD_DIR="$DIR/build"
+JOBS=${JOBS:-$(nproc)}
+CLEAN=0
+REBUILD=0
+
+usage() {
+    cat << 'EOF'
+Usage: ./build.sh [options]
+
+Options:
+  -c, --clean    Clean build from scratch
+  -r, --rebuild  Reconfigure cmake (delete CMakeCache) then rebuild
+  -j N           Number of parallel jobs (default: nproc)
+  -h, --help     Show this help
+EOF
+}
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        -c|--clean) CLEAN=1 ;;
+        -r|--rebuild) REBUILD=1 ;;
+        -j) JOBS="$2"; shift ;;
+        -h|--help) usage; exit 0 ;;
+        *) echo "Unknown option: $1"; usage; exit 1 ;;
+    esac
+    shift
+done
+
+if [ "$CLEAN" -eq 1 ]; then
+    echo "Cleaning $BUILD_DIR ..."
+    rm -rf "$BUILD_DIR"
+fi
+
+if [ "$REBUILD" -eq 1 ]; then
+    rm -rf "$BUILD_DIR"
+fi
+
+if [ ! -f "$BUILD_DIR/Makefile" ]; then
+    echo "Configuring with cmake ..."
+    cmake -B "$BUILD_DIR" "$DIR"
+fi
+
+echo "Building with $JOBS jobs ..."
+cmake --build "$BUILD_DIR" -j "$JOBS"
+
+echo "Build complete: $BUILD_DIR/rtsp_multi_view"

+ 93 - 0
capture_fb.c

@@ -0,0 +1,93 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <fcntl.h>
+#include <unistd.h>
+#include <sys/mman.h>
+#include <sys/ioctl.h>
+#include <xf86drm.h>
+#include <xf86drmMode.h>
+#include <drm_fourcc.h>
+
+int main(void) {
+    int fd = open("/dev/dri/card0", O_RDWR);
+    if (fd < 0) { perror("open"); return 1; }
+
+    drmModeRes *res = drmModeGetResources(fd);
+    if (!res) { perror("getResources"); return 1; }
+
+    drmModeCrtc *crtc = NULL;
+    uint32_t crtc_id = 0;
+    for (int i = 0; i < res->count_crtcs; i++) {
+        crtc = drmModeGetCrtc(fd, res->crtcs[i]);
+        if (crtc && crtc->buffer_id) { crtc_id = crtc->crtc_id; break; }
+        if (crtc) { drmModeFreeCrtc(crtc); crtc = NULL; }
+    }
+    if (!crtc || !crtc->buffer_id) {
+        fprintf(stderr, "No active CRTC\n");
+        drmModeFreeResources(res);
+        return 1;
+    }
+
+    fprintf(stderr, "CRTC %u -> FB %u, %ux%u\n",
+            crtc_id, crtc->buffer_id, crtc->width, crtc->height);
+
+    drmModeFB2 *fb = drmModeGetFB2(fd, crtc->buffer_id);
+    if (!fb) {
+        fprintf(stderr, "drmModeGetFB2 failed, trying FB...\n");
+        drmModeFB *fb1 = drmModeGetFB(fd, crtc->buffer_id);
+        if (!fb1) { perror("getFB"); return 1; }
+        fprintf(stderr, "FB: %ux%u pitch=%u handle=%u\n",
+                fb1->width, fb1->height, fb1->pitch, fb1->handle);
+
+        uint32_t size = fb1->pitch * fb1->height;
+        void *map = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0);
+        if (map == MAP_FAILED) {
+            struct drm_mode_map_dumb mreq = { .handle = fb1->handle };
+            if (ioctl(fd, DRM_IOCTL_MODE_MAP_DUMB, &mreq)) { perror("map"); return 1; }
+            map = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, mreq.offset);
+            if (map == MAP_FAILED) { perror("mmap2"); return 1; }
+        }
+        FILE *fp = fopen("/tmp/frame.bin", "wb");
+        fwrite(map, 1, size, fp);
+        fclose(fp);
+        fprintf(stderr, "Dumped %u bytes to /tmp/frame.bin\n", size);
+        munmap(map, size);
+        drmModeFreeFB(fb1);
+        drmModeFreeCrtc(crtc);
+        drmModeFreeResources(res);
+        return 0;
+    }
+
+    fprintf(stderr, "FB2: %ux%u format=0x%x\n", fb->width, fb->height, fb->pixel_format);
+    for (int i = 0; i < 4; i++)
+        fprintf(stderr, "  plane[%d]: handle=%u pitch=%u offset=%u\n",
+                i, fb->handles[i], fb->pitches[i], fb->offsets[i]);
+
+    if (fb->handles[0] == 0) {
+        fprintf(stderr, "No handle on plane 0\n");
+        drmModeFreeFB2(fb); drmModeFreeCrtc(crtc); drmModeFreeResources(res);
+        return 1;
+    }
+
+    uint32_t w = fb->width, h = fb->height;
+    uint32_t pitch = fb->pitches[0];
+    uint32_t size = pitch * h;
+
+    struct drm_mode_map_dumb mreq = { .handle = fb->handles[0] };
+    if (ioctl(fd, DRM_IOCTL_MODE_MAP_DUMB, &mreq)) { perror("map_dumb"); return 1; }
+
+    void *map = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, mreq.offset);
+    if (map == MAP_FAILED) { perror("mmap"); return 1; }
+
+    FILE *fp = fopen("/tmp/frame.bin", "wb");
+    fwrite(map, 1, size, fp);
+    fclose(fp);
+    fprintf(stderr, "Dumped %ux%u (pitch=%u, %u bytes) to /tmp/frame.bin\n", w, h, pitch, size);
+
+    munmap(map, size);
+    drmModeFreeFB2(fb);
+    drmModeFreeCrtc(crtc);
+    drmModeFreeResources(res);
+    return 0;
+}

+ 196 - 0
channels.conf

@@ -0,0 +1,196 @@
+# ============================================================
+#  通过海康 ISAPI 批量配置主码流/子码流 示例
+#
+#  特点:
+#   1) 只需填写每台相机的 IP / 账号 / 密码 / 登录通道号,
+#      程序自动拼出 主码流(Channels/101) / 子码流(Channels/102)
+#      RTSP URL,无需手写一长串 URL。
+#   2) 启动时通过 ISAPI 读取每台相机编码参数(分辨率/码率/帧率),
+#      写入故障日志。
+#   3) 可选:通过 [isapi_write] 把指定通道的分辨率/帧率/码率写回相机。
+#
+#  注意:ISAPI 走 HTTP(默认80),RTSP 走 554;两者端口分开配。
+#  运行: ./build/rtsp_multi_view -c channels_isapi.conf
+# ============================================================
+
+[output]
+1920 1080
+letterbox = 1        ; 保持宽高比,黑边填充(0=拉伸铺满画面)
+grid_lines = 1       ; 画面之间画分隔线
+
+# ------------------------------------------------------------
+#  [camera_list]  批量相机清单(自动生成主/子码流 URL)
+#  格式: <id> <ip> <user> <pass> [login_chan] [rtsp_port] [http_port]
+#    id          : 布局/流 里用的编号(0-7)
+#    ip          : 相机 IP 或域名
+#    user/pass   : 相机登录账号密码
+#    login_chan  : 海康登录通道号,默认 1(可留空)
+#    rtsp_port   : RTSP 端口,默认 554
+#    http_port   : ISAPI HTTP 端口,默认 80
+#
+#  生成规则(海康固定协议):
+#    子码流 = rtsp://user:pass@ip:rtsp_port/Streaming/Channels/{chan*100+2}
+#    主码流 = rtsp://user:pass@ip:rtsp_port/Streaming/Channels/{chan*100+1}
+#
+#  一旦配置了 [camera_list],它会**取代** [streams] 和 [main_streams];
+#  未配置时仍按旧的 [streams]/[main_streams] 方式。
+# ------------------------------------------------------------
+[camera_list]
+0 192.168.1.125 admin hd135792468 1 554 80
+1 192.168.1.122 admin hd135792468 1 554 80
+2 192.168.1.123 admin hd135792468 1 554 80
+3 192.168.1.124 admin hd135792468 1 554 80
+4 192.168.1.121 admin hd135792468 1 554 80
+5 192.168.1.126 admin hd135792468 1 554 80
+6 192.168.1.129 admin hd135792468 1 554 80
+7 192.168.1.130 admin hd135792468 1 554 80
+
+# ------------------------------------------------------------
+#  [isapi_write]  可选:启动时通过 ISAPI 把指定通道改成给定规格。
+#  格式: <id> <main|sub> <width> <height> <fps> <bitrate_kbps>
+#   - 只改你填的字段;width/height 必填,fps/bitrate 填 0 表示不改
+#   - fps 用"帧率"(程序会自动 x100 换算为海康的 maxFrameRate)
+#   - 危险:会真实修改相机!默认不启用,需要时去掉下面的注释。
+#  示例(把相机0主码流改成 1280x720 @ 10fps 2048kbps):
+#  0 main 1280 720 10 2048
+# ------------------------------------------------------------
+[isapi_write]
+0 sub 640 360 25 2048
+1 sub 640 360 25 2048
+2 sub 640 360 25 2048
+3 sub 640 360 25 2048
+4 sub 640 360 25 2048
+5 sub 640 360 25 2048
+6 sub 640 360 25 2048
+7 sub 640 360 25 2048
+
+# ------------------------------------------------------------
+#  Split 布局:上 5 + 下 1~3,自适应宽高居中
+#  split=1    启用 split 模式
+#  top_ratio  上行占画面高度比例(0.3 = 30%)
+#  [layout_top]      上行摄像头 ID
+#  [layout_bottom_N] 下行显示 N 个画面时的摄像头 ID
+# ------------------------------------------------------------
+[layout]
+split = 1
+top_ratio = 0.3
+
+[layout_top]
+0 1 2 3 4
+
+[layout_bottom]
+5 6 7
+
+[layout_bottom_1]
+5
+
+[layout_bottom_2]
+6
+
+[layout_bottom_3]
+7
+
+[layout_bottom_4]
+5 6
+
+[layout_bottom_5]
+5 6 7
+
+[layout0]
+0 0 0 0 3
+0 0 0 0 4
+0 0 0 0 5
+0 0 0 0 6
+0 0 0 0 7
+
+[layout1]
+1 1 1 1 3
+1 1 1 1 4
+1 1 1 1 5
+1 1 1 1 6
+1 1 1 1 7
+
+[layout2]
+2 2 2 2 3
+2 2 2 2 4
+2 2 2 2 5
+2 2 2 2 6
+2 2 2 2 7
+
+[layout3]
+1 1 2 2 3
+1 1 2 2 4
+1 1 2 2 5
+x x x x 6
+x x x x 7
+
+[layout4]
+0 0 1 1 3
+0 0 1 1 4
+2 2 x x 5
+2 2 x x 6
+x x x x 7
+
+# ---------- 标题栏 ----------
+[title]
+enable = 1
+type = overlay
+height = 50
+text = 法兰泰克 · 多路视频随动监控
+logo = fltk.png
+logo_pad = 8
+opacity = 200
+font = /usr/share/fonts/truetype/wqy/wqy-microhei.ttc
+font_size = 24
+color = 0xFFFFFF
+
+# ---------- 故障日志 ----------
+[log]
+file = flog_isapi.txt
+rotate_ms = 3600000
+max_bytes = 1048576
+
+[plc]
+enable = 1
+ip = 192.168.1.240
+port = 102
+area = db            ; m | i | q | db
+db = 1000
+read_start = 0      ; 轮询块的起始字节
+read_len = 10
+poll_ms = 100
+reconnect_ms = 3000
+debounce_ms = 300    ; 布局切换去抖:新布局需连续稳定这么多毫秒才切换(抑制 PLC 点位瞬时脉冲导致的画面闪烁,0=关闭)
+tsap_local = 0x0100
+tsap_remote = 0x0101
+
+# ---------- PLC 位 -> 布局切换规则 ----------
+#  Rule 0  -> layout 0 (bottom_1)
+#  Rule 1  -> layout 1 (bottom_2)
+#  Rule 2  -> layout 2 (bottom_3)
+#  Rule 3  -> layout 3 (bottom_4)
+#  Rule 4  -> layout 4 (bottom_5)
+#  一个也不满足  -> bottom
+[plc_rules]
+0 : 6.6,6.7
+1 : 8.4,8.5
+2 : 8.6,8.7
+3 : 6.6,6.7;8.4,8.5
+4 : 6.6,8.4,8.5;8.6,8.7
+
+# ---------- 自动重启(已实现,程序内生效) ----------
+#  enable   : 0=禁用 1=启用;到点后自动 reboot 设备
+#  period   : daily=每天  weekly=每周
+#  time     : HH:MM 格式,24小时制;想凌晨重启一般设 03:00~05:00
+#  weekday  : 仅 period=weekly 时有效,monday|tuesday|...|sunday
+[auto_reboot]
+enable = 1
+period = daily
+time = 00:15
+weekday = monday
+
+# ---------- Web 网卡配置 ----------
+#  ifaces  : 允许在网页上查看/配置的网卡白名单(空格或逗号分隔)
+#            留空或删除本段 = 显示全部非回环网卡(eth0/eth1/wlan0/bond0 等)
+[web]
+ifaces = eth0

+ 112 - 0
channels_flow_example.conf

@@ -0,0 +1,112 @@
+# ============================================================
+#  多路视频自适应行布局示例
+#  -------------------------------------------------------
+#  本文件演示"每行动态格数 + 自动高度"的布局写法。
+#  运行: ./build/rtsp_multi_view -c channels_flow_example.conf
+#
+#  使用方法可参考 channels.conf;本示例重点展示 [layout]
+#  与 [layoutN] 的自适应行高语法。
+# ============================================================
+
+[output]
+1920 1080
+
+[streams]
+# 子码流(多画面/默认用)
+0 rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/102
+1 rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/102
+2 rtsp://admin:hd135792468@192.168.100.102:554/Streaming/Channels/102
+3 rtsp://admin:hd135792468@192.168.100.103:554/Streaming/Channels/102
+4 rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/102
+5 rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/102
+6 rtsp://admin:hd135792468@192.168.100.102:554/Streaming/Channels/102
+7 rtsp://admin:hd135792468@192.168.100.103:554/Streaming/Channels/102
+8 rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/102
+9 rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/102
+
+# 单通道布局时对应编号摄像机切换到的主码流(编号与 [streams] 对应)
+[main_streams]
+0 rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/101
+1 rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/101
+2 rtsp://admin:hd135792468@192.168.100.102:554/Streaming/Channels/101
+3 rtsp://admin:hd135792468@192.168.100.103:554/Streaming/Channels/101
+4 rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/101
+5 rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/101
+6 rtsp://admin:hd135792468@192.168.100.102:554/Streaming/Channels/101
+7 rtsp://admin:hd135792468@192.168.100.103:554/Streaming/Channels/101
+8 rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/101
+9 rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/101
+
+# ------------------------------------------------------------------
+#  [layout]  默认布局(自适应行高)
+#
+#  关键特性:现在保留每行的格子数,行高度按"该行格子数"占总数
+#  的权重分配;行内格子等宽铺满。
+#
+#  示例(共 6 格):
+#     第一行 4 格 -> 高度约占 4/6
+#     第二行 2 格 -> 高度约占 2/6
+#
+#  格子编号 = [streams] 里的编号;x = 空位(黑块不出画面)。
+#  行数不限,每行格数可各不相同。
+# ------------------------------------------------------------------
+[layout]
+0 1 2 3
+4 5 x x
+
+# ------------------------------------------------------------------
+#  命名布局 [layoutN]  —— 同样支持每行不同格数、行高自适应
+#
+#  layout0:单画面(1 格)-> 该格对应摄像机自动切到主码流
+#  layout1:第一行 3 格、第二行 2 格(5 路,行高 3/5、2/5)
+#  layout2:第一行 5 格、第二行 3 格、第三行 2 格(10 路)
+#  layout3:第一行 2 格、第二行 4 格(6 路,第二行更高)
+# ------------------------------------------------------------------
+[layout0]
+0
+
+[layout1]
+0 1 2
+3 4
+
+[layout2]
+0 1 2 3 4
+5 6 7
+8 9
+
+[layout3]
+0 1
+2 3 4 5
+
+# ---------- PLC ----------
+[plc]
+enable = 0                 # 示例默认关闭 PLC,专注看布局;需要时改 1
+ip = 192.168.1.90
+port = 102
+area = i
+db = 1
+read_start = 0
+read_len = 16
+poll_ms = 100
+reconnect_ms = 3000
+tsap_local = 0x0100
+tsap_remote = 0x0102
+
+# ---------- 标题栏(可关) ----------
+[title]
+enable = 0
+type = overlay
+height = 80
+text = 布局示例
+logo = fltk.png
+logo_pad = 8
+opacity = 200
+font = /usr/share/fonts/truetype/wqy/wqy-microhei.ttc
+font_size = 48
+color = 0xFFFFFF
+
+# ---------- 故障日志 ----------
+[log]
+file = flog.txt
+rotate_ms = 3600000
+max_bytes = 1048576

+ 118 - 0
channels_local.conf

@@ -0,0 +1,118 @@
+[output]
+1920 1080
+letterbox = 0        ; 少黑边:4:3 画面轻微拉伸铺满各自格子(改为 1 恢复等比留黑边)
+grid_lines = 1       ; 画面之间画白色网格分隔线(改为 0 关闭)
+
+[streams]
+0 rtsp://admin:hd135792468@192.168.1.121:554/Streaming/Channels/102
+1 rtsp://admin:hd135792468@192.168.1.122:554/Streaming/Channels/102
+2 rtsp://admin:hd135792468@192.168.1.123:554/Streaming/Channels/102
+3 rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/102
+4 rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/102
+5 rtsp://admin:hd135792468@192.168.1.126:554/Streaming/Channels/102
+6 rtsp://admin:hd135792468@192.168.1.129:554/Streaming/Channels/102
+7 rtsp://admin:hd135792468@192.168.1.130:554/Streaming/Channels/102
+
+# 单通道布局时切换为主码流
+[main_streams]
+0 rtsp://admin:hd135792468@192.168.1.121:554/Streaming/Channels/101
+1 rtsp://admin:hd135792468@192.168.1.122:554/Streaming/Channels/101
+2 rtsp://admin:hd135792468@192.168.1.123:554/Streaming/Channels/101
+3 rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/101
+4 rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/102
+5 rtsp://admin:hd135792468@192.168.1.126:554/Streaming/Channels/102
+6 rtsp://admin:hd135792468@192.168.1.129:554/Streaming/Channels/102
+7 rtsp://admin:hd135792468@192.168.1.130:554/Streaming/Channels/101
+
+[layout]
+0 1 2 3
+4 5 6 7
+
+
+# 命名布局 [layoutN]:统一 4 列网格。同一标识(如 "0")被多个格子引用时,
+# 这些格子自动合并成一个大画面区域;x 表示空格(黑块,不出画面)。
+# 大画面一律在左上角,小画面在右下平铺。行高按"格子数越少越高"分配,
+# 每行均 4 列时各行等高,布局整齐。
+#   默认/全显: 8 画面等大 (2x4)
+#   layout0-2: 1 个大画面(左上,占 75%宽) + 7 小
+#   layout3  : 2 个大画面(左上,各占 50%宽) + 6 小
+#   layout4  : 3 个大画面(cam0/1 上排大头 + cam2 横条) + 5 小
+[layout0]
+0 0 0 0 3
+0 0 0 0 4
+0 0 0 0 5
+0 0 0 0 6
+0 0 0 0 7
+
+[layout1]
+1 1 1 1 3
+1 1 1 1 4
+1 1 1 1 5
+1 1 1 1 6
+1 1 1 1 7
+
+[layout2]
+2 2 2 2 3
+2 2 2 2 4
+2 2 2 2 5
+2 2 2 2 6
+2 2 2 2 7
+
+[layout3]
+1 1 2 2 3
+1 1 2 2 4
+1 1 2 2 5
+x x x x 6
+x x x x 7
+
+
+[layout4]
+0 0 1 1 3
+0 0 1 1 4
+2 2 x x 5
+2 2 x x 6
+x x x x 7
+
+# ---------- PLC 读取(本地测试:指向本机 plc_server.py) ----------
+[plc]
+enable = 1
+ip = 127.0.0.1
+port = 102
+area = i           ; m | i | q | db
+db = 1              ; 仅 area=db 时有效
+read_start = 0      ; 轮询块的起始字节
+read_len = 16       ; 每次轮询读取的字节数
+poll_ms = 100
+reconnect_ms = 3000
+tsap_local = 0x0100
+tsap_remote = 0x0102
+
+# ---------- 标题栏 ----------
+# type: bar = 实心条,顶部占 height 像素(视频区域下移);
+#  bar = 实心      overlay = 半透明条,盖在视频上、不占画面空间(每帧重画)。
+# logo 与文字排成一组居中显示;opacity 0-255 控制 logo/文字不透明度。
+[title]
+enable = 1
+type = overlay
+height = 80
+text = 法兰泰克 · 多路视频随动监控
+logo = fltk.png
+logo_pad = 8
+opacity = 200
+font = /usr/share/fonts/truetype/wqy/wqy-microhei.ttc
+font_size = 48
+color = 0xFFFFFF
+
+# ---------- PLC 位 -> 布局切换规则 ----------
+[plc_rules]
+0 : 0.0,0.1 
+1 : 0.2,0.3
+2 : 0.4,0.5
+3 : 0.0,0.1;0.2,0.3
+4 : 0.0,0.1;0.2,0.3;0.4,0.5
+
+# ---------- 故障日志 ----------
+[log]
+file = flog.txt
+rotate_ms = 3600000
+max_bytes = 1048576

+ 717 - 0
composer.c

@@ -0,0 +1,717 @@
+#include "composer.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/mman.h>
+#include <sys/ioctl.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <unistd.h>
+#include <time.h>
+#include <sys/time.h>
+#include <linux/dma-buf.h>
+#include <rga/im2d.h>
+#include <rga/rga.h>
+
+int dma_heap_alloc(const char *heap_name, size_t size, int *fd);
+
+#define MAX_TILES 25
+
+typedef struct {
+    int x, y, w, h;
+} TileRect;
+
+/* Cache of RGA-imported dmabuf handles. importbuffer_fd/releasebuffer_handle
+ * are driver round-trips; doing both per tile per frame costs measurable CPU.
+ * Each entry keeps its own dup'd fd so the fd number can never be recycled
+ * and alias a different buffer while cached.
+ *
+ * DRIVER LIMIT: the RGA driver only keeps a bounded number of imported
+ * handles; once that table is full every new importbuffer_fd returns EINVAL
+ * (and never makes it into the cache, so the table stays full forever =
+ * permanent black tiles). The cache size must stay well below the driver
+ * table limit so a fresh import always has a free slot. 128 was observed to
+ * wedge the table on 8 RV-streams; 64 still failed on the live site; 32 keeps
+ * every buffer that is *currently being blitted* cached (the LRU re-imports
+ * the rest) while leaving the driver table mostly empty. */
+typedef struct {
+    int fd;                      /* our own dup (never the caller's fd) */
+    ino_t ino;                   /* dmabuf anon-inode identity */
+    int w, h, stride, vstride;
+    rga_buffer_handle_t handle;
+    uint64_t last_use;
+} RgaCacheEnt;
+
+#define RGA_CACHE_SIZE 32
+
+struct Composer {
+    int output_w;
+    int output_h;
+    int num_streams;
+    int letterbox;
+    TileRect tiles[MAX_TILES];
+    RgaCacheEnt rga_cache[RGA_CACHE_SIZE];
+    uint64_t rga_clock;
+    /* Per-tile health counters for diagnosing flicker: a tile that fails to
+     * import its source buffer keeps the dark clear = a black flashing tile. */
+    uint64_t src_ok[MAX_TILES];
+    uint64_t src_import_fail[MAX_TILES];
+    uint64_t src_improcess_fail[MAX_TILES];
+    uint64_t dst_fail;
+    /* Emergency low-address mirror per tile (invalid <= 0). See comment
+     * on mirror_refresh(). */
+    int mirror_fd[MAX_TILES];
+    uint64_t mirror_hits;
+};
+
+int composer_grid_rects(int out_w, int out_h, int n,
+                        int *xs, int *ys, int *ws, int *hs, int y_off) {
+    if (!xs || !ys || !ws || !hs) return -1;
+    if (n < 0) n = 0;
+    if (n > MAX_TILES) n = MAX_TILES;
+    if (y_off < 0) y_off = 0;
+    if (y_off > out_h) y_off = out_h;
+    int usable_h = out_h - y_off;
+
+    int cols = 1, rows = 1;
+    if (n <= 1) {
+        cols = 1; rows = 1;
+    } else if (n <= 2) {
+        cols = 2; rows = 1;
+    } else if (n <= 4) {
+        cols = 2; rows = 2;
+    } else if (n <= 6) {
+        cols = 3; rows = 2;
+    } else if (n <= 9) {
+        cols = 3; rows = 3;
+    } else if (n <= 12) {
+        cols = 4; rows = 3;
+    } else if (n <= 16) {
+        cols = 4; rows = 4;
+    } else {
+        cols = 5; rows = 5;
+    }
+
+    int grid_w = out_w / cols;
+    int grid_h = usable_h / rows;
+    if (grid_h < 1) grid_h = 1;
+
+    for (int i = 0; i < n; i++) {
+        int col = i % cols;
+        int row = i / cols;
+        int w = grid_w;
+        int h = grid_h;
+        /* Edge column/row extends to the output edge. */
+        if (col == cols - 1) w = out_w - col * grid_w;
+        if (row == rows - 1) h = out_h - y_off - row * grid_h;
+        xs[i] = col * grid_w;
+        ys[i] = y_off + row * grid_h;
+        ws[i] = w;
+        hs[i] = h;
+    }
+
+    return n;
+}
+
+int composer_flow_rects(int out_w, int out_h, const int *row_cols, int nrows,
+                        int *xs, int *ys, int *ws, int *hs, int y_off) {
+    if (!row_cols || !xs || !ys || !ws || !hs) return -1;
+    if (nrows <= 0) return 0;
+    if (nrows > MAX_TILES) nrows = MAX_TILES;
+    if (y_off < 0) y_off = 0;
+    if (y_off > out_h) y_off = out_h;
+    int usable_h = out_h - y_off;
+    if (usable_h <= 0) return 0;
+
+    int total = 0;
+    for (int r = 0; r < nrows; r++) {
+        if (row_cols[r] <= 0) continue;
+        total += row_cols[r];
+    }
+    if (total <= 0) return 0;
+    if (total > MAX_TILES) return -1;
+
+    /* Row heights: inversely proportional to the number of cells in the row,
+     * so a row with fewer cells ends up taller (a 1-cell "main" row + a
+     * multi-cell "thumbnail" row is the common case). The last row always
+     * extends to the bottom edge so the canvas is fully filled. Each row
+     * height is forced even (NV12 needs even row strides). */
+    int *rh = NULL;
+    double *wcol = NULL;
+    if (nrows > 1) {
+        rh = calloc((size_t)nrows, sizeof(int));
+        wcol = calloc((size_t)nrows, sizeof(double));
+        if (!rh || !wcol) { free(rh); free(wcol); return -1; }
+        double wsum = 0.0;
+        for (int r = 0; r < nrows; r++)
+            if (row_cols[r] > 0) { wcol[r] = 1.0 / (double)row_cols[r]; wsum += wcol[r]; }
+        if (wsum <= 0.0) { free(rh); free(wcol); return 0; }
+
+        /* All rows except the last: proportional to 1/cols, rounded even,
+         * leaving room for the last row to take whatever is left. */
+        int sum = 0;
+        for (int r = 0; r < nrows - 1; r++) {
+            if (row_cols[r] <= 0) { rh[r] = 0; continue; }
+            int h = (int)(usable_h * wcol[r] / wsum);
+            if (h & 1) h--;
+            if (h < 2) h = 2;
+            rh[r] = h;
+            sum += h;
+        }
+        /* Last row fills exactly the remaining height (full canvas). */
+        int last_h = usable_h - sum;
+        if (last_h & 1) {
+            last_h++;
+            for (int r = nrows - 2; r >= 0 && (last_h & 1); r--)
+                if (rh[r] > 2) { rh[r] -= 2; last_h += 2; }
+        }
+        if (last_h < 2 && row_cols[nrows - 1] > 0) last_h = 2;
+        rh[nrows - 1] = last_h;
+    }
+
+    int idx = 0;
+    int y = y_off;
+    for (int r = 0; r < nrows; r++) {
+        int cols = row_cols[r];
+        int rheight = rh ? rh[r] : (usable_h - (y - y_off)); /* single row: fill all */
+        if (cols <= 0 || rheight < 2) continue;
+        if (r == nrows - 1) rheight = usable_h - (y - y_off); /* last row to bottom edge */
+        for (int c = 0; c < cols; c++) {
+            int w = out_w / cols;
+            int x = c * w;
+            if (c == cols - 1) w = out_w - x;   /* last cell to right edge */
+            if (w < 2) w = 2;
+            if (x < 0) x = 0;
+            xs[idx] = x;
+            ys[idx] = y;
+            ws[idx] = w;
+            hs[idx] = rheight;
+            idx++;
+        }
+        y += rheight;
+    }
+    free(wcol);
+    free(rh);
+    return idx;
+}
+
+int composer_split_rects(int out_w, int out_h, int y_off,
+                         const int *top_ids, int top_n, double top_ratio,
+                         const int *bottom_ids, int bottom_n,
+                         int actual_bottom,
+                         int *xs, int *ys, int *ws, int *hs, int *sid) {
+    if (!top_ids || !bottom_ids || !xs || !ys || !ws || !hs || !sid) return -1;
+    if (top_n <= 0 || top_n > MAX_TILES) return -1;
+    if (y_off < 0) y_off = 0;
+    if (y_off > out_h) y_off = out_h;
+    if (top_ratio < 0.05) top_ratio = 0.05;
+    if (top_ratio > 0.95) top_ratio = 0.95;
+    if (actual_bottom < 0) actual_bottom = 0;
+    if (actual_bottom > bottom_n) actual_bottom = bottom_n;
+    if (actual_bottom > MAX_TILES - top_n) actual_bottom = MAX_TILES - top_n;
+
+    int usable_h = out_h - y_off;
+    if (usable_h <= 0) return 0;
+
+    /* Top row: height proportional to top_ratio, even dimensions */
+    int top_h = (int)(usable_h * top_ratio);
+    top_h &= ~1;  /* even for NV12 */
+    if (top_h < 2) top_h = 2;
+    if (top_h > usable_h - 2) top_h = usable_h - 2;
+    int bot_h = usable_h - top_h;
+    if (bot_h < 2) bot_h = 2;
+
+    int n = 0;
+
+    /* Top row: top_n cameras, equal width, full row width */
+    int cell_w = out_w / top_n;
+    for (int i = 0; i < top_n && n < MAX_TILES; i++) {
+        xs[n] = i * cell_w;
+        ys[n] = y_off;
+        ws[n] = (i == top_n - 1) ? out_w - i * cell_w : cell_w;
+        hs[n] = top_h;
+        sid[n] = top_ids[i];
+        n++;
+    }
+
+    /* Bottom row: actual_bottom cameras, equal width, centered */
+    if (actual_bottom > 0) {
+        int bot_w = out_w / actual_bottom;
+        int total_bot_w = bot_w * actual_bottom;
+        int bot_x_start = (out_w - total_bot_w) / 2;
+        bot_x_start &= ~1;  /* even offset for NV12 */
+        for (int i = 0; i < actual_bottom && n < MAX_TILES; i++) {
+            xs[n] = bot_x_start + i * bot_w;
+            ys[n] = y_off + top_h;
+            ws[n] = (i == actual_bottom - 1)
+                    ? out_w - bot_x_start - i * bot_w : bot_w;
+            hs[n] = bot_h;
+            sid[n] = bottom_ids[i];
+            n++;
+        }
+    }
+
+    return n;
+}
+
+int composer_region_rects(int out_w, int out_h, const int *row_cols, int nrows,
+                          const int *cells, int *region_x, int *region_y,
+                          int *region_w, int *region_h, int *region_sid,
+                          int y_off) {
+    if (!row_cols || !cells || !region_x || !region_y ||
+        !region_w || !region_h || !region_sid) return -1;
+    if (nrows <= 0 || nrows > MAX_TILES) return 0;
+    if (y_off < 0) y_off = 0;
+    if (y_off > out_h) y_off = out_h;
+    int usable_h = out_h - y_off;
+    if (usable_h <= 0) return 0;
+
+    /* ---- per-row geometry (inverse cell-count height, fill to bottom) ---- */
+    int rh[MAX_TILES];
+    int ysum = 0;
+    if (nrows > 1) {
+        double wcol[MAX_TILES], wsum = 0.0;
+        for (int r = 0; r < nrows; r++)
+            if (row_cols[r] > 0) { wcol[r] = 1.0 / (double)row_cols[r]; wsum += wcol[r]; }
+        if (wsum <= 0.0) return 0;
+        for (int r = 0; r < nrows - 1; r++) {
+            int h = (int)(usable_h * wcol[r] / wsum);
+            if (h & 1) h--;
+            if (h < 2) h = 2;
+            rh[r] = h;
+            ysum += h;
+        }
+        int last_h = usable_h - ysum;
+        if (last_h & 1) {
+            last_h++;
+            for (int r = nrows - 2; r >= 0 && (last_h & 1); r--)
+                if (rh[r] > 2) { rh[r] -= 2; last_h += 2; }
+        }
+        if (last_h < 2) last_h = 2;
+        rh[nrows - 1] = last_h;
+    } else {
+        rh[0] = usable_h;
+    }
+
+    /* ---- assign every cell a label bucket ---- */
+    /* We'll accumulate pixel spans per label directly. */
+    int lb_minx[MAX_TILES], lb_miny[MAX_TILES];
+    int lb_maxx[MAX_TILES], lb_maxy[MAX_TILES];
+    int lb_init[MAX_TILES];
+    int nlabels = 0;
+    int lbl[MAX_TILES];
+    for (int i = 0; i < MAX_TILES; i++) lb_init[i] = 0;
+
+    int idx = 0;
+    int row_top = y_off;
+    for (int r = 0; r < nrows; r++) {
+        int cols = row_cols[r];
+        int rheight = rh[r];
+        if (cols <= 0 || rheight < 2) continue;
+        for (int c = 0; c < cols; c++) {
+            int lab = cells[idx];            /* flat, row-major */
+            int cellw = out_w / cols;
+            int cellx = c * cellw;
+            if (c == cols - 1) cellw = out_w - cellx;
+            if (cellw < 2) cellw = 2;
+            int cell_bot = row_top + rheight;
+            if (lab >= 0) {
+                int li = -1;
+                for (int k = 0; k < nlabels; k++) if (lbl[k] == lab) { li = k; break; }
+                if (li < 0) {
+                    if (nlabels >= MAX_TILES) { idx++; continue; }
+                    li = nlabels;
+                    lbl[li] = lab;
+                    lb_minx[li] = cellx; lb_miny[li] = row_top;
+                    lb_maxx[li] = cellx + cellw; lb_maxy[li] = cell_bot;
+                    lb_init[li] = 1;
+                    nlabels++;
+                } else {
+                    if (cellx < lb_minx[li]) lb_minx[li] = cellx;
+                    if (row_top < lb_miny[li]) lb_miny[li] = row_top;
+                    if (cellx + cellw > lb_maxx[li]) lb_maxx[li] = cellx + cellw;
+                    if (cell_bot > lb_maxy[li]) lb_maxy[li] = cell_bot;
+                }
+            }
+            idx++;
+        }
+        row_top += rheight;
+    }
+
+    /* dedupe/merge: same label already merged during accumulation */
+    int n = 0;
+    for (int k = 0; k < nlabels; k++) {
+        if (!lb_init[k]) continue;
+        region_x[n] = lb_minx[k];
+        region_y[n] = lb_miny[k];
+        region_w[n] = lb_maxx[k] - lb_minx[k];
+        region_h[n] = lb_maxy[k] - lb_miny[k];
+        if (region_w[n] < 2) region_w[n] = 2;
+        if (region_h[n] < 2) region_h[n] = 2;
+        region_sid[n] = lbl[k];
+        n++;
+    }
+    return n;
+}
+
+Composer *composer_create(int output_width, int output_height,
+                          int num_streams, int y_off) {
+    Composer *comp = calloc(1, sizeof(Composer));
+    if (!comp) return NULL;
+
+    comp->output_w = output_width;
+    comp->output_h = output_height;
+    comp->num_streams = num_streams > MAX_TILES ? MAX_TILES : num_streams;
+
+    /* Pin RGA to the RGA3 core (64-bit addressing). The RGA2 cores are
+     * 32-bit only and refuse any dmabuf whose physical pages sit above 4G
+     * ("rga_mm: RGA_MMU unsupported memory larger than 4G!"), which is what
+     * made importbuffer_fd return EINVAL and black out tiles on the live
+     * site. RGA3 has no such limitation and is faster. If the driver/kernel
+     * rejects it we keep the default scheduler. */
+    IM_STATUS sc = imconfig(IM_CONFIG_SCHEDULER_CORE, IM_SCHEDULER_RGA3_CORE0);
+    if (sc == IM_STATUS_SUCCESS)
+        fprintf(stderr, "composer: RGA scheduler pinned to RGA3 (64-bit addressing)\n");
+    else
+        fprintf(stderr, "composer: imconfig(RGA3_CORE0) failed st=%d, using default scheduler\n", sc);
+
+    int xs[MAX_TILES], ys[MAX_TILES], ws[MAX_TILES], hs[MAX_TILES];
+    int n = composer_grid_rects(output_width, output_height, comp->num_streams,
+                                xs, ys, ws, hs, y_off);
+    for (int i = 0; i < n; i++) {
+        comp->tiles[i].x = xs[i];
+        comp->tiles[i].y = ys[i];
+        comp->tiles[i].w = ws[i];
+        comp->tiles[i].h = hs[i];
+    }
+
+    return comp;
+}
+
+void composer_invalidate_all(Composer *comp) {
+    if (!comp) return;
+    for (int i = 0; i < RGA_CACHE_SIZE; i++) {
+        RgaCacheEnt *e = &comp->rga_cache[i];
+        if (e->handle) releasebuffer_handle(e->handle);
+        if (e->fd > 0) close(e->fd);
+        memset(e, 0, sizeof(*e));
+    }
+}
+
+void composer_destroy(Composer *comp) {
+    composer_invalidate_all(comp);
+    for (int i = 0; i < MAX_TILES; i++)
+        if (comp->mirror_fd[i] > 0) close(comp->mirror_fd[i]);
+    free(comp);
+}
+
+void composer_get_tile_layout(Composer *comp, int stream_idx,
+                              int *x, int *y, int *w, int *h) {
+    if (stream_idx < 0 || stream_idx >= MAX_TILES) {
+        *x = *y = *w = *h = 0;
+        return;
+    }
+    *x = comp->tiles[stream_idx].x;
+    *y = comp->tiles[stream_idx].y;
+    *w = comp->tiles[stream_idx].w;
+    *h = comp->tiles[stream_idx].h;
+}
+
+int composer_set_tiles(Composer *comp, const int *x, const int *y,
+                       const int *w, const int *h, int n) {
+    if (!comp || !x || !y || !w || !h) return -1;
+    if (n < 0) n = 0;
+    if (n > MAX_TILES) n = MAX_TILES;
+    for (int i = 0; i < n; i++) {
+        comp->tiles[i].x = x[i];
+        comp->tiles[i].y = y[i];
+        comp->tiles[i].w = w[i];
+        comp->tiles[i].h = h[i];
+    }
+    return 0;
+}
+
+void composer_set_letterbox(Composer *comp, int enable) {
+    if (comp) comp->letterbox = enable ? 1 : 0;
+}
+
+static void rga_cache_evict_one(Composer *comp) {
+    RgaCacheEnt *oldest = NULL;
+    for (int i = 0; i < RGA_CACHE_SIZE; i++) {
+        RgaCacheEnt *e = &comp->rga_cache[i];
+        if (!e->fd) continue;
+        if (!oldest || e->last_use < oldest->last_use) oldest = e;
+    }
+    if (!oldest) return;
+    if (oldest->handle) releasebuffer_handle(oldest->handle);
+    close(oldest->fd);
+    memset(oldest, 0, sizeof(*oldest));
+}
+
+/* Wrap fd for RGA use, importing into a cached handle when the fast
+ * wrapbuffer_fd path is unavailable. On return *out_handle is nonzero only
+ * when the caller must releasebuffer_handle it (legacy one-shot import);
+ * cached handles are owned by the composer and must not be released. */
+static rga_buffer_t import_nv12(Composer *comp, int fd, int w, int h,
+                                int stride, int vstride,
+                                rga_buffer_handle_t *out_handle) {
+    *out_handle = 0;
+
+    rga_buffer_t buf = wrapbuffer_fd_t(fd, w, h, stride, vstride, RK_FORMAT_YCbCr_420_SP);
+    if (buf.handle != 0)
+        return buf;
+
+    im_handle_param_t param;
+    memset(&param, 0, sizeof(param));
+    param.width = w;
+    param.height = h;
+    param.format = RK_FORMAT_YCbCr_420_SP;
+
+    /* Identity of the underlying dmabuf: its anon-inode number is unique
+     * per buffer instance, immune to fd-number recycling. */
+    struct stat st;
+    ino_t ino = (fstat(fd, &st) == 0) ? st.st_ino : 0;
+
+    if (ino != 0) {
+        for (int i = 0; i < RGA_CACHE_SIZE; i++) {
+            RgaCacheEnt *e = &comp->rga_cache[i];
+            if (e->fd > 0 && e->handle && e->ino == ino &&
+                e->w == w && e->h == h &&
+                e->stride == stride && e->vstride == vstride) {
+                e->last_use = ++comp->rga_clock;
+                return wrapbuffer_handle_t(e->handle, w, h, stride, vstride,
+                                           RK_FORMAT_YCbCr_420_SP);
+            }
+        }
+    }
+
+    /* Prepare a free cache slot BEFORE importing. If the table is full when
+     * importbuffer_fd runs the driver refuses the new handle with EINVAL and
+     * it never gets cached (stale entries then keep the table full forever).
+     * Evicting one old handle up front guarantees the import has room. */
+    int slot = -1;
+    if (ino != 0) {
+        for (int i = 0; i < RGA_CACHE_SIZE; i++)
+            if (!comp->rga_cache[i].fd) { slot = i; break; }
+        if (slot < 0) {
+            rga_cache_evict_one(comp);
+            for (int i = 0; i < RGA_CACHE_SIZE; i++)
+                if (!comp->rga_cache[i].fd) { slot = i; break; }
+        }
+    }
+
+    rga_buffer_handle_t handle = importbuffer_fd(fd, &param);
+    if (handle == 0) {
+        /* Import failed even with room for it (or a wedged table): report
+         * and let the caller's self-heal invalidate fire instead of CACHING
+         * the failure. Rate-limit the line: boards that map part of DDR above
+         * 4G keep the same two high-bank decode buffers failing every frame
+         * (the composer's low-4G mirror then rescues them), so a per-frame
+         * print floods; the counts still live in composer_dump_stats(). */
+        static struct timeval last_err = {0, 0}, now;
+        gettimeofday(&now, NULL);
+        if (last_err.tv_sec == 0 || now.tv_sec - last_err.tv_sec >= 2) {
+            last_err = now;
+            fprintf(stderr, "import_nv12: importbuffer_fd failed for fd=%d ino=%llu %dx%d stride=%d errno=%d(%s)\n",
+                    fd, (unsigned long long)ino, w, h, stride, errno, strerror(errno));
+        }
+        memset(&buf, 0, sizeof(buf));
+        return buf;
+    }
+
+    /* Pin in the cache with our own dup: while our dup stays open the kernel
+     * cannot hand that fd number to anyone else, so stale hits are impossible. */
+    if (ino != 0 && slot >= 0) {
+        int mydup = fcntl(fd, F_DUPFD_CLOEXEC, 3);
+        if (mydup >= 0) {
+            RgaCacheEnt *e = &comp->rga_cache[slot];
+            e->fd = mydup;
+            e->ino = ino;
+            e->w = w; e->h = h;
+            e->stride = stride; e->vstride = vstride;
+            e->handle = handle;
+            e->last_use = ++comp->rga_clock;
+            return wrapbuffer_handle_t(handle, w, h, stride, vstride,
+                                       RK_FORMAT_YCbCr_420_SP);
+        }
+        /* dup failed: hand the handle back one-shot (slot stays free). */
+    }
+
+    /* could not cache: hand out one-shot ownership to the caller */
+    *out_handle = handle;
+    return wrapbuffer_handle_t(handle, w, h, stride, vstride,
+                               RK_FORMAT_YCbCr_420_SP);
+}
+
+/* Emergency low-address mirror: the board maps part of DDR above 4G and the
+ * RGA driver refuses any dmabuf whose physical pages sit above 4G
+ * ("rga_mm: RGA_MMU unsupported memory larger than 4G!") — importbuffer_fd
+ * then returns EINVAL and the tile goes black. When a tile's import fails we
+ * memcpy its latest frame into a per-tile system-uncached-dma32 buffer
+ * (guaranteed <4G) and hand RGA that instead. Only failing tiles pay the
+ * copy cost; healthy ones keep the zero-copy path. */
+static int mirror_refresh(Composer *comp, int idx, int src_fd,
+                          int stride, int vstride) {
+    size_t size = (size_t)stride * (size_t)vstride * 3 / 2;
+    if (comp->mirror_fd[idx] <= 0) {
+        if (dma_heap_alloc("system-uncached-dma32", size, &comp->mirror_fd[idx]) < 0 &&
+            dma_heap_alloc("cma", size, &comp->mirror_fd[idx]) < 0) {
+            fprintf(stderr, "mirror: dma32+cma heap alloc of %zuB failed\n", size);
+            return -1;
+        }
+    }
+    void *src = mmap(NULL, size, PROT_READ, MAP_SHARED, src_fd, 0);
+    if (src == MAP_FAILED) {
+        fprintf(stderr, "mirror: mmap src fd=%d failed (%s)\n", src_fd, strerror(errno));
+        return -1;
+    }
+    void *dst = mmap(NULL, size, PROT_WRITE, MAP_SHARED, comp->mirror_fd[idx], 0);
+    if (dst == MAP_FAILED) {
+        fprintf(stderr, "mirror: mmap mirror fd=%d failed (%s)\n", comp->mirror_fd[idx], strerror(errno));
+        munmap(src, size);
+        return -1;
+    }
+    memcpy(dst, src, size);
+    munmap(dst, size);
+    munmap(src, size);
+    struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_RW };
+    ioctl(comp->mirror_fd[idx], DMA_BUF_IOCTL_SYNC, &sync);
+    return 0;
+}
+
+int composer_compose(Composer *comp,
+                     int *src_fds, int *src_widths, int *src_heights,
+                     int *src_strides, int *src_vstrides, int num_src,
+                     int dst_fd, int dst_stride) {
+    int n = num_src;
+    if (n > MAX_TILES) n = MAX_TILES;
+    if (n <= 0) return 0;
+
+    rga_buffer_handle_t dst_handle = 0;
+    rga_buffer_t dst_buf = import_nv12(comp, dst_fd, comp->output_w, comp->output_h,
+                                        dst_stride, comp->output_h, &dst_handle);
+    if (dst_buf.handle == 0) {
+        /* Output import failed: the RGA handle table is likely stale (this
+         * driver returns EINVAL for every import once it wedges). Drop every
+         * cached import and retry once before giving up on this frame. */
+        composer_invalidate_all(comp);
+        usleep(1000);
+        dst_buf = import_nv12(comp, dst_fd, comp->output_w, comp->output_h,
+                              dst_stride, comp->output_h, &dst_handle);
+        if (dst_buf.handle == 0) { comp->dst_fail++; return -1; }
+    }
+
+    rga_buffer_t pat;
+    memset(&pat, 0, sizeof(pat));
+    im_rect prect;
+    memset(&prect, 0, sizeof(prect));
+
+    int src_fail_frame = 0;
+    for (int i = 0; i < n; i++) {
+        if (src_fds[i] < 0) continue;
+
+        int vstride = (src_vstrides && src_vstrides[i] > 0) ? src_vstrides[i] : src_heights[i];
+        rga_buffer_handle_t src_handle = 0;
+        rga_buffer_t src_buf = import_nv12(comp, src_fds[i], src_widths[i], src_heights[i],
+                                           src_strides[i], vstride, &src_handle);
+        if (src_buf.handle == 0) {
+            /* Likely landed on a >4G physical page (RGA refuses it). Copy the
+             * frame into the per-tile low-address mirror and import that. */
+            if (mirror_refresh(comp, i, src_fds[i], src_strides[i], vstride) == 0) {
+                src_handle = 0;
+                src_buf = import_nv12(comp, comp->mirror_fd[i],
+                                      src_widths[i], src_heights[i],
+                                      src_strides[i], vstride, &src_handle);
+                if (src_buf.handle == 0)
+                    fprintf(stderr, "mirror: import mirror fd=%d still failed for tile %d\n",
+                            comp->mirror_fd[i], i);
+            }
+            if (src_buf.handle == 0) { comp->src_import_fail[i]++; src_fail_frame++; continue; }
+            comp->mirror_hits++;
+        }
+        comp->src_ok[i]++;
+
+        im_rect src_rect = {0, 0, src_widths[i], src_heights[i]};
+        im_rect dst_rect;
+        int tw = comp->tiles[i].w, th = comp->tiles[i].h;
+        if (comp->letterbox) {
+            /* Preserve aspect ratio: fit inside the tile, centered, with
+             * black bars. NV12 needs even dims/offsets. */
+            int sw = src_widths[i], sh = src_heights[i];
+            double sc = ((double)tw / sw < (double)th / sh) ?
+                        (double)tw / sw : (double)th / sh;
+            int dw = (int)(sw * sc);
+            int dh = (int)(sh * sc);
+            if (dw > tw) dw = tw;
+            if (dh > th) dh = th;
+            dw &= ~1;
+            dh &= ~1;
+            if (dw < 2) dw = 2;
+            if (dh < 2) dh = 2;
+            dst_rect.x = comp->tiles[i].x + (tw - dw) / 2;
+            dst_rect.y = comp->tiles[i].y + (th - dh) / 2;
+            if (dst_rect.x & 1) dst_rect.x--;
+            if (dst_rect.y & 1) dst_rect.y--;
+            if (dst_rect.x < 0) dst_rect.x = 0;
+            if (dst_rect.y < 0) dst_rect.y = 0;
+            dst_rect.width = dw;
+            dst_rect.height = dh;
+        } else {
+            dst_rect.x = comp->tiles[i].x;
+            dst_rect.y = comp->tiles[i].y;
+            dst_rect.width = tw;
+            dst_rect.height = th;
+        }
+
+        int ret = improcess(src_buf, dst_buf, pat,
+                            src_rect, dst_rect, prect, IM_SYNC);
+        if (ret != IM_STATUS_SUCCESS) {
+            comp->src_improcess_fail[i]++;
+            fprintf(stderr, "RGA improcess failed for tile %d: ret=%d (fail=%llu)\n",
+                    i, ret, (unsigned long long)comp->src_improcess_fail[i]);
+        }
+        if (src_handle) releasebuffer_handle(src_handle);
+    }
+
+    if (dst_handle) releasebuffer_handle(dst_handle);
+
+    if (src_fail_frame > 0) {
+        static struct timeval last_iva = {0, 0};
+        struct timeval now;
+        gettimeofday(&now, NULL);
+        if (last_iva.tv_sec == 0 || now.tv_sec - last_iva.tv_sec >= 30) {
+            last_iva = now;
+            composer_invalidate_all(comp);
+            fprintf(stderr, "compose: %d src import(s) failed this frame; "
+                            "invalidated RGA cache for self-heal\n", src_fail_frame);
+        }
+    }
+
+    return 0;
+}
+void composer_dump_stats(const Composer *comp) {
+    if (!comp) return;
+    int any = 0;
+    for (int i = 0; i < MAX_TILES; i++)
+        if (comp->src_import_fail[i] || comp->src_improcess_fail[i]) { any = 1; break; }
+    if (!any && !comp->dst_fail) {
+        fprintf(stderr, "compose stats: all tiles clean (%llu dst-fail)\n",
+                (unsigned long long)comp->dst_fail);
+        return;
+    }
+    for (int i = 0; i < MAX_TILES; i++) {
+        if (comp->src_ok[i] || comp->src_import_fail[i] || comp->src_improcess_fail[i])
+            fprintf(stderr, "compose stats tile %d: ok=%llu import_fail=%llu improcess_fail=%llu\n",
+                    i,
+                    (unsigned long long)comp->src_ok[i],
+                    (unsigned long long)comp->src_import_fail[i],
+                    (unsigned long long)comp->src_improcess_fail[i]);
+    }
+    if (comp->dst_fail)
+        fprintf(stderr, "compose stats dst: import_fail=%llu\n",
+                (unsigned long long)comp->dst_fail);
+    if (comp->mirror_hits)
+        fprintf(stderr, "compose stats mirror: rescued=%llu frames via low-4G copy\n",
+                (unsigned long long)comp->mirror_hits);
+}

+ 78 - 0
composer.h

@@ -0,0 +1,78 @@
+#ifndef __COMPOSER_H__
+#define __COMPOSER_H__
+
+#include <stdint.h>
+
+typedef struct Composer Composer;
+
+Composer *composer_create(int output_width, int output_height,
+                          int num_streams, int y_off);
+void composer_destroy(Composer *comp);
+
+/* Drop every cached RGA buffer handle (imported dmabufs). Call after
+ * decoder teardowns if fd churn is expected; the cache also self-evicts
+ * via LRU when full. */
+void composer_invalidate_all(Composer *comp);
+
+int composer_compose(Composer *comp,
+                      int *src_fds, int *src_widths, int *src_heights,
+                      int *src_strides, int *src_vstrides, int num_src,
+                      int dst_fd, int dst_stride);
+
+/* Override tile rectangles (pixel coordinates). n must be <= the tile
+ * capacity (MAX_TILES). Used for PLC-driven dynamic layouts. */
+int composer_set_tiles(Composer *comp, const int *x, const int *y,
+                       const int *w, const int *h, int n);
+
+/* Enable/disable aspect-ratio-preserving scaling (letterbox). When enabled
+ * each source is scaled to fit inside its tile with black bars instead of
+ * being stretched. Default is disabled. */
+void composer_set_letterbox(Composer *comp, int enable);
+
+/* Compute the standard grid tile rectangles for n tiles on an
+ * out_w x out_h canvas (same division as composer_create). The top
+ * y_off pixels are reserved (e.g. title bar): tiles are laid out in
+ * the area below and the last row extends to the bottom edge. */
+int composer_grid_rects(int out_w, int out_h, int n,
+                        int *xs, int *ys, int *ws, int *hs, int y_off);
+
+/* Row-aware layout: each row may hold a different number of equal-width cells.
+ * Row height is proportional to the number of cells in that row (so a 5-cell
+ * row is taller than a 3-cell row). Cells within a row are equal width. The
+ * total cell count = sum(row_cols). Returns total cells or -1 on bad input. */
+int composer_flow_rects(int out_w, int out_h, const int *row_cols, int nrows,
+                        int *xs, int *ys, int *ws, int *hs, int y_off);
+
+/* Region (merged-cell) layout. The grid has nrows rows, row_cols[r] cells per
+ * row, laid out in row-major order in `cells`. Cells holding the same label
+ * (>=0) are merged into their bounding rectangle; label -1 is an empty cell.
+ * Row heights follow inverse-of-cell-count weighting and always fill the
+ * canvas. Returns the number of regions, filling region_x/y/w/h and
+ * region_sid with the region's label (which equals the camera id). */
+int composer_region_rects(int out_w, int out_h, const int *row_cols, int nrows,
+                          const int *cells, int *region_x, int *region_y,
+                          int *region_w, int *region_h, int *region_sid,
+                          int y_off);
+
+/* Split layout: fixed top row + adaptive centered bottom row.
+ * top_ids[]: camera IDs for the top row (always shown).
+ * top_n: number of top-row cameras (e.g. 5).
+ * top_ratio: top row height as fraction of usable area (0.0-1.0, e.g. 0.3).
+ * bottom_ids[]: camera IDs for the bottom row (1-3 cameras, -1=empty).
+ * bottom_n: number of valid entries in bottom_ids (max count, may include -1).
+ * actual_bottom: how many bottom cameras to actually show (1 to bottom_n).
+ * Writes rects into xs/ys/ws/hs/sid arrays, returns total regions. */
+int composer_split_rects(int out_w, int out_h, int y_off,
+                         const int *top_ids, int top_n, double top_ratio,
+                         const int *bottom_ids, int bottom_n,
+                         int actual_bottom,
+                         int *xs, int *ys, int *ws, int *hs, int *sid);
+
+void composer_get_tile_layout(Composer *comp, int stream_idx,
+                              int *x, int *y, int *w, int *h);
+
+/* Dump per-tile compose health counters to stderr (diagnoses flicker: a tile
+ * with import/improcess failures periodically shows a black frame). */
+void composer_dump_stats(const Composer *comp);
+
+#endif

+ 453 - 0
decoder.c

@@ -0,0 +1,453 @@
+#include "decoder.h"
+#include "dma_util.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <pthread.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <linux/dma-heap.h>
+#include <linux/dma-buf.h>
+
+#include <gst/gst.h>
+#include <gst/app/gstappsink.h>
+#include <gst/video/video.h>
+#include <gst/allocators/gstdmabuf.h>
+
+#define DEC_NUM_BUF 3
+
+struct Decoder {
+    GstElement *pipeline;
+    GstElement *appsink;
+
+    /* Zero-copy mode: appsink negotiates video/x-raw(memory:DMABuf) and we
+     * hand out dups of the decoder's own dmabuf fds. Pulled samples are kept
+     * alive per slot until decoder_release_frame() unrefs them, so GStreamer
+     * can never overwrite a buffer the composer is still using. */
+    int use_dmabuf;
+    GstSample *slot_sample[DEC_NUM_BUF];
+
+    /* Fallback mode: CPU copies each frame into our own dma-heap buffers. */
+    int buf_fd[DEC_NUM_BUF];
+    void *buf_map[DEC_NUM_BUF];
+    size_t buf_size;
+    int buf_width;
+    int buf_height;
+    int buf_stride;
+    int buf_stride_v;
+    int buf_index;
+
+    int buf_inuse[DEC_NUM_BUF];
+    pthread_mutex_t buf_mutex;
+    pthread_cond_t buf_cond;
+
+    int codec_type;
+    int width;
+    int height;
+    int stride;
+};
+
+static GstElement *build_pipeline(const char *url, int want_dmabuf) {
+    char pipeline_str[2048];
+    if (want_dmabuf) {
+        snprintf(pipeline_str, sizeof(pipeline_str),
+                 "rtspsrc name=src location=%s protocols=tcp latency=0 ! "
+                 "application/x-rtp,media=video ! parsebin name=pb ! mppvideodec ! "
+                 "video/x-raw(memory:DMABuf),format=NV12 ! appsink name=sink "
+                 "max-buffers=%d drop=true sync=false ",
+                 url, DEC_NUM_BUF);
+    } else {
+        snprintf(pipeline_str, sizeof(pipeline_str),
+                 "rtspsrc name=src location=%s protocols=tcp latency=0 ! "
+                 "application/x-rtp,media=video ! parsebin name=pb ! mppvideodec ! "
+                 "video/x-raw,format=NV12 ! appsink name=sink "
+                 "max-buffers=1 drop=true sync=false ",
+                 url);
+    }
+
+    GError *err = NULL;
+    GstElement *pipeline = gst_parse_launch(pipeline_str, &err);
+    if (!pipeline || err) {
+        fprintf(stderr, "GStreamer pipeline creation failed: %s\n",
+                err ? err->message : "unknown");
+        if (err) g_error_free(err);
+        if (pipeline) { gst_object_unref(pipeline); pipeline = NULL; }
+        return NULL;
+    }
+
+    GstElement *appsink = gst_bin_get_by_name(GST_BIN(pipeline), "sink");
+    gst_app_sink_set_drop(GST_APP_SINK(appsink), TRUE);
+    gst_app_sink_set_max_buffers(GST_APP_SINK(appsink),
+                                 want_dmabuf ? DEC_NUM_BUF : 1);
+    gst_app_sink_set_emit_signals(GST_APP_SINK(appsink), FALSE);
+
+    /* Set rtspsrc low-latency options */
+    GstElement *src = gst_bin_get_by_name(GST_BIN(pipeline), "src");
+    if (src) {
+        g_object_set(src, "latency", 0, NULL);
+        g_object_set(src, "buffer-mode", 0, NULL);
+        g_object_set(src, "do-rtcp", FALSE, NULL);
+        gst_object_unref(src);
+    }
+    gst_object_unref(appsink);
+    return pipeline;
+}
+
+static void destroy_pipeline(Decoder *dec) {
+    if (dec->pipeline) {
+        gst_element_set_state(dec->pipeline, GST_STATE_NULL);
+        gst_object_unref(dec->pipeline);
+        dec->pipeline = NULL;
+    }
+    if (dec->appsink) { gst_object_unref(dec->appsink); dec->appsink = NULL; }
+}
+
+/* Fill dec dims from the caps of `sample`; returns ver_stride (UV plane row
+ * offset in rows) or -1 on bad caps. */
+static int probe_dims(Decoder *dec, GstSample *sample) {
+    GstCaps *caps = gst_sample_get_caps(sample);
+    GstVideoInfo vinfo;
+    if (!caps || !gst_video_info_from_caps(&vinfo, caps)) return -1;
+    dec->width = GST_VIDEO_INFO_WIDTH(&vinfo);
+    dec->height = GST_VIDEO_INFO_HEIGHT(&vinfo);
+    dec->stride = GST_VIDEO_INFO_PLANE_STRIDE(&vinfo, 0);
+    int stride_v = GST_VIDEO_INFO_PLANE_STRIDE(&vinfo, 1);
+    gsize uv_off = GST_VIDEO_INFO_COMP_OFFSET(&vinfo, 1);
+    /* UV plane starts after ver_stride aligned rows of Y */
+    if (dec->stride > 0 && uv_off % (gsize)dec->stride == 0)
+        return (int)(uv_off / (gsize)dec->stride);
+    (void)stride_v;
+    return dec->height;
+}
+
+Decoder *decoder_create(const char *url, int timeout_sec) {
+    static int gst_inited = 0;
+    if (!gst_inited) { gst_init(NULL, NULL); gst_inited = 1; }
+
+    Decoder *dec = calloc(1, sizeof(Decoder));
+    if (!dec) return NULL;
+    for (int i = 0; i < DEC_NUM_BUF; i++) dec->buf_fd[i] = -1;
+    pthread_mutex_init(&dec->buf_mutex, NULL);
+    pthread_cond_init(&dec->buf_cond, NULL);
+    for (int i = 0; i < DEC_NUM_BUF; i++) dec->buf_inuse[i] = 0;
+
+    /* parsebin auto-detects whether the RTP stream is H.264, H.265, etc. from
+     * the SDP encoding-name, so cameras need not advertise their codec via the
+     * URL (a `.264`-less URL used to be forced to H.265, breaking H.264 cams
+     * such as 192.168.1.120). mppvideodec does the hardware decode. */
+    dec->codec_type = 1;
+
+    /* --- Preferred path: copy into our own dma-heap buffers. The zero-copy
+     * mppvideodec DMABuf path is skipped: on this board RGA cannot import the
+     * MPP decoder's own dmabufs (importbuffer_fd fails -> tiles never appear).
+     * Our dma-heap (system-uncached) buffers import cleanly into RGA. --- */
+copy_path:
+
+    /* --- Fallback: system-memory output + CPU copy into our own heap --- */
+    dec->pipeline = build_pipeline(url, 0);
+    if (!dec->pipeline) {
+        pthread_mutex_destroy(&dec->buf_mutex);
+        pthread_cond_destroy(&dec->buf_cond);
+        free(dec);
+        return NULL;
+    }
+    dec->appsink = gst_bin_get_by_name(GST_BIN(dec->pipeline), "sink");
+
+    gst_element_set_state(dec->pipeline, GST_STATE_PLAYING);
+
+    /* Wait for first frame with timeout */
+    GstSample *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(dec->appsink),
+                                                      timeout_sec * GST_SECOND);
+    if (!sample) {
+        fprintf(stderr, "Timeout waiting for first frame from %s\n", url);
+        destroy_pipeline(dec);
+        pthread_mutex_destroy(&dec->buf_mutex);
+        pthread_cond_destroy(&dec->buf_cond);
+        free(dec);
+        return NULL;
+    }
+
+    /* Get video info */
+    if (probe_dims(dec, sample) < 0) {
+        gst_sample_unref(sample);
+        fprintf(stderr, "Failed to get video info\n");
+        destroy_pipeline(dec);
+        pthread_mutex_destroy(&dec->buf_mutex);
+        pthread_cond_destroy(&dec->buf_cond);
+        free(dec);
+        return NULL;
+    }
+
+    /* Decode first frame to allocate buffer */
+    GstBuffer *out_buf = gst_sample_get_buffer(sample);
+    if (out_buf) {
+        GstCaps *caps = gst_sample_get_caps(sample);
+        GstVideoInfo vinfo;
+        int stride_v = dec->height;
+        if (caps && gst_video_info_from_caps(&vinfo, caps))
+            stride_v = GST_VIDEO_INFO_PLANE_STRIDE(&vinfo, 1);
+        size_t needed = dec->stride * dec->height + stride_v * dec->height / 2;
+
+        for (int i = 0; i < DEC_NUM_BUF; i++) {
+            if (dma_heap_alloc("cma", needed, &dec->buf_fd[i]) < 0) {
+                if (dma_heap_alloc("system-uncached", needed, &dec->buf_fd[i]) < 0) {
+                    dec->buf_fd[i] = -1;
+                }
+            }
+
+            if (dec->buf_fd[i] >= 0) {
+                dec->buf_map[i] = mmap(NULL, needed, PROT_READ | PROT_WRITE,
+                                    MAP_SHARED, dec->buf_fd[i], 0);
+                if (dec->buf_map[i] != MAP_FAILED) {
+                    if (i == 0) {
+                        dec->buf_size = needed;
+                        dec->buf_width = dec->width;
+                        dec->buf_height = dec->height;
+                        dec->buf_stride = dec->stride;
+                        dec->buf_stride_v = stride_v;
+                        fprintf(stderr, "DBG: decoder buffer %dx%d stride=%d/%d size=%zu\n",
+                                dec->width, dec->height, dec->stride, stride_v, needed);
+                    }
+                } else {
+                    close(dec->buf_fd[i]);
+                    dec->buf_fd[i] = -1;
+                }
+            }
+        }
+    }
+
+    gst_sample_unref(sample);
+
+    if (dec->buf_fd[0] < 0) {
+        fprintf(stderr, "Failed to allocate decoder buffer\n");
+        destroy_pipeline(dec);
+        pthread_mutex_destroy(&dec->buf_mutex);
+        pthread_cond_destroy(&dec->buf_cond);
+        free(dec);
+        return NULL;
+    }
+
+    return dec;
+}
+
+void decoder_destroy(Decoder *dec) {
+    if (!dec) return;
+    destroy_pipeline(dec);
+    for (int i = 0; i < DEC_NUM_BUF; i++) {
+        if (dec->slot_sample[i]) {
+            gst_sample_unref(dec->slot_sample[i]);
+            dec->slot_sample[i] = NULL;
+        }
+    }
+    for (int i = 0; i < DEC_NUM_BUF; i++) {
+        if (dec->buf_fd[i] >= 0) {
+            if (dec->buf_map[i]) munmap(dec->buf_map[i], dec->buf_size);
+            close(dec->buf_fd[i]);
+        }
+    }
+    pthread_mutex_destroy(&dec->buf_mutex);
+    pthread_cond_destroy(&dec->buf_cond);
+    free(dec);
+}
+
+int decoder_get_codec(Decoder *dec) { return dec->codec_type; }
+int decoder_get_width(Decoder *dec) { return dec->width; }
+int decoder_get_height(Decoder *dec) { return dec->height; }
+int decoder_get_stride(Decoder *dec) { return dec->stride; }
+
+int decoder_get_frame(Decoder *dec, DecodedFrame *frame) {
+    if (!dec || !dec->pipeline) return -1;
+
+    /* --- zero-copy dmabuf path --- */
+    if (dec->use_dmabuf) {
+        GstSample *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(dec->appsink), 0);
+        if (!sample) return -3;
+        GstBuffer *buf = gst_sample_get_buffer(sample);
+        GstMemory *mem = buf ? gst_buffer_peek_memory(buf, 0) : NULL;
+        if (!mem || !gst_is_dmabuf_memory(mem)) {
+            gst_sample_unref(sample);
+            return -1;
+        }
+
+        pthread_mutex_lock(&dec->buf_mutex);
+        int idx = -1;
+        for (int i = 0; i < DEC_NUM_BUF; i++) {
+            if (!dec->slot_sample[i]) { idx = i; break; }
+        }
+        if (idx < 0) {
+            /* all slots pinned (ring + held + in-flight): drop the new
+             * frame, main catches up next iteration */
+            pthread_mutex_unlock(&dec->buf_mutex);
+            gst_sample_unref(sample);
+            return -3;
+        }
+        dec->slot_sample[idx] = sample;   /* keep alive until release */
+        pthread_mutex_unlock(&dec->buf_mutex);
+
+        int fd = gst_dmabuf_memory_get_fd(mem);
+        if (fd < 0) {
+            pthread_mutex_lock(&dec->buf_mutex);
+            dec->slot_sample[idx] = NULL;
+            pthread_mutex_unlock(&dec->buf_mutex);
+            gst_sample_unref(sample);
+            return -1;
+        }
+
+        int ver = probe_dims(dec, sample);
+        if (ver <= 0) ver = dec->height;
+
+        frame->fd = fcntl(fd, F_DUPFD_CLOEXEC, 3);
+        frame->width = dec->width;
+        frame->height = dec->height;
+        frame->hor_stride = dec->stride;
+        frame->ver_stride = ver;
+        frame->idx = idx;
+        frame->pts = 0;
+        if (frame->fd < 0) {
+            pthread_mutex_lock(&dec->buf_mutex);
+            dec->slot_sample[idx] = NULL;
+            pthread_mutex_unlock(&dec->buf_mutex);
+            gst_sample_unref(sample);
+            return -1;
+        }
+        return 0;
+    }
+
+    /* --- fallback: system memory + CPU copy into our own heap --- */
+    GstSample *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(dec->appsink), 0);
+    if (!sample) return -3;
+
+    GstBuffer *out_buf = gst_sample_get_buffer(sample);
+    GstCaps *caps = gst_sample_get_caps(sample);
+    if (!caps || !out_buf) { gst_sample_unref(sample); return -1; }
+
+    GstVideoInfo vinfo;
+    if (!gst_video_info_from_caps(&vinfo, caps)) { gst_sample_unref(sample); return -1; }
+
+    int w = GST_VIDEO_INFO_WIDTH(&vinfo);
+    int h = GST_VIDEO_INFO_HEIGHT(&vinfo);
+    int stride = GST_VIDEO_INFO_PLANE_STRIDE(&vinfo, 0);
+    int stride_v = GST_VIDEO_INFO_PLANE_STRIDE(&vinfo, 1);
+    size_t needed = stride * h + stride_v * h / 2;
+
+    /* Reallocate if dimensions changed */
+    if (w != dec->buf_width || h != dec->buf_height ||
+        stride != dec->buf_stride || needed > dec->buf_size) {
+        for (int i = 0; i < DEC_NUM_BUF; i++) {
+            if (dec->buf_map[i]) munmap(dec->buf_map[i], dec->buf_size);
+            if (dec->buf_fd[i] >= 0) close(dec->buf_fd[i]);
+            dec->buf_fd[i] = -1;
+            dec->buf_map[i] = NULL;
+
+            if (dma_heap_alloc("cma", needed, &dec->buf_fd[i]) < 0) {
+                if (dma_heap_alloc("system-uncached", needed, &dec->buf_fd[i]) < 0) {
+                    dec->buf_fd[i] = -1;
+                }
+            }
+
+            if (dec->buf_fd[i] < 0) { gst_sample_unref(sample); return -1; }
+
+            dec->buf_map[i] = mmap(NULL, needed, PROT_READ | PROT_WRITE,
+                                MAP_SHARED, dec->buf_fd[i], 0);
+            if (dec->buf_map[i] == MAP_FAILED) {
+                close(dec->buf_fd[i]);
+                dec->buf_fd[i] = -1;
+                gst_sample_unref(sample);
+                return -1;
+            }
+        }
+        dec->buf_size = needed;
+        dec->buf_width = w;
+        dec->buf_height = h;
+        dec->buf_stride = stride;
+        dec->buf_stride_v = stride_v;
+    }
+
+    pthread_mutex_lock(&dec->buf_mutex);
+    while (dec->buf_inuse[dec->buf_index]) {
+        struct timespec ts;
+        clock_gettime(CLOCK_REALTIME, &ts);
+        ts.tv_nsec += 200000000;
+        if (ts.tv_nsec >= 1000000000) { ts.tv_sec++; ts.tv_nsec -= 1000000000; }
+        pthread_cond_timedwait(&dec->buf_cond, &dec->buf_mutex, &ts);
+    }
+    int idx = dec->buf_index;
+    dec->buf_inuse[idx] = 1;
+    dec->buf_index = (idx + 1) % DEC_NUM_BUF;
+    pthread_mutex_unlock(&dec->buf_mutex);
+
+    GstVideoFrame vframe;
+    if (!gst_video_frame_map(&vframe, &vinfo, out_buf, GST_MAP_READ)) {
+        gst_sample_unref(sample);
+        return -1;
+    }
+
+    uint8_t *src_y = GST_VIDEO_FRAME_PLANE_DATA(&vframe, 0);
+    uint8_t *src_uv = GST_VIDEO_FRAME_PLANE_DATA(&vframe, 1);
+    int src_stride = GST_VIDEO_FRAME_PLANE_STRIDE(&vframe, 0);
+    int src_stride_uv = GST_VIDEO_FRAME_PLANE_STRIDE(&vframe, 1);
+    uint8_t *dst = (uint8_t *)dec->buf_map[idx];
+
+    /* CPU write start: invalidate/clean so cached dma-heap memory stays coherent */
+    {
+        struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_WRITE };
+        ioctl(dec->buf_fd[idx], DMA_BUF_IOCTL_SYNC, &sync);
+    }
+
+    if (src_stride == stride) {
+        memcpy(dst, src_y, stride * h);
+    } else {
+        for (int y = 0; y < h; y++)
+            memcpy(dst + y * stride, src_y + y * src_stride, stride < src_stride ? stride : src_stride);
+    }
+
+    dst += stride * h;
+    int uv_h = h / 2;
+    if (src_stride_uv == stride_v) {
+        memcpy(dst, src_uv, stride_v * uv_h);
+    } else {
+        for (int y = 0; y < uv_h; y++)
+            memcpy(dst + y * stride_v, src_uv + y * src_stride_uv, stride_v < src_stride_uv ? stride_v : src_stride_uv);
+    }
+
+    gst_video_frame_unmap(&vframe);
+    gst_sample_unref(sample);
+
+    /* CPU write done: flush cache so RGA DMA reads the latest frame data.
+       Without this, fast motion shows a mix of old/new rows (tearing). */
+    {
+        struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_WRITE };
+        ioctl(dec->buf_fd[idx], DMA_BUF_IOCTL_SYNC, &sync);
+    }
+
+    frame->fd = fcntl(dec->buf_fd[idx], F_DUPFD_CLOEXEC, 3);
+    frame->width = w;
+    frame->height = h;
+    frame->hor_stride = stride;
+    frame->ver_stride = h;
+    frame->idx = idx;
+    frame->pts = 0;
+    return 0;
+}
+
+int decoder_flush(Decoder *dec) {
+    (void)dec;
+    return 0;
+}
+
+int decoder_release_frame(Decoder *dec, int idx) {
+    if (!dec || idx < 0 || idx >= DEC_NUM_BUF) return -1;
+    pthread_mutex_lock(&dec->buf_mutex);
+    if (dec->slot_sample[idx]) {
+        gst_sample_unref(dec->slot_sample[idx]);
+        dec->slot_sample[idx] = NULL;
+        pthread_cond_signal(&dec->buf_cond);
+    } else if (dec->buf_inuse[idx]) {
+        dec->buf_inuse[idx] = 0;
+        pthread_cond_signal(&dec->buf_cond);
+    }
+    pthread_mutex_unlock(&dec->buf_mutex);
+    return 0;
+}

+ 30 - 0
decoder.h

@@ -0,0 +1,30 @@
+#ifndef __DECODER_H__
+#define __DECODER_H__
+
+#include <stdint.h>
+
+typedef struct Decoder Decoder;
+
+typedef struct {
+    int fd;
+    int width;
+    int height;
+    int hor_stride;
+    int ver_stride;
+    int idx;
+    int64_t pts;
+} DecodedFrame;
+
+Decoder *decoder_create(const char *url, int timeout_sec);
+void decoder_destroy(Decoder *dec);
+
+int decoder_get_codec(Decoder *dec);
+int decoder_get_width(Decoder *dec);
+int decoder_get_height(Decoder *dec);
+int decoder_get_stride(Decoder *dec);
+
+int decoder_get_frame(Decoder *dec, DecodedFrame *frame);
+int decoder_release_frame(Decoder *dec, int idx);
+int decoder_flush(Decoder *dec);
+
+#endif

+ 148 - 0
deploy/conf/channels.conf

@@ -0,0 +1,148 @@
+# ============================================================
+#  通过海康 ISAPI 批量配置主码流/子码流 示例
+#
+#  特点:
+#   1) 只需填写每台相机的 IP / 账号 / 密码 / 登录通道号,
+#      程序自动拼出 主码流(Channels/101) / 子码流(Channels/102)
+#      RTSP URL,无需手写一长串 URL。
+#   2) 启动时通过 ISAPI 读取每台相机编码参数(分辨率/码率/帧率),
+#      写入故障日志。
+#   3) 可选:通过 [isapi_write] 把指定通道的分辨率/帧率/码率写回相机。
+#
+#  注意:ISAPI 走 HTTP(默认80),RTSP 走 554;两者端口分开配。
+#  运行: ./build/rtsp_multi_view -c channels_isapi.conf
+# ============================================================
+
+[output]
+1920 1080
+letterbox = 0        ; 少黑边:4:3 画面轻微拉伸铺满各自格子
+grid_lines = 1       ; 画面之间画分隔线
+
+# ------------------------------------------------------------
+#  [camera_list]  批量相机清单(自动生成主/子码流 URL)
+#  格式: <id> <ip> <user> <pass> [login_chan] [rtsp_port] [http_port]
+#    id          : 布局/流 里用的编号(0-7)
+#    ip          : 相机 IP 或域名
+#    user/pass   : 相机登录账号密码
+#    login_chan  : 海康登录通道号,默认 1(可留空)
+#    rtsp_port   : RTSP 端口,默认 554
+#    http_port   : ISAPI HTTP 端口,默认 80
+#
+#  生成规则(海康固定协议):
+#    子码流 = rtsp://user:pass@ip:rtsp_port/Streaming/Channels/{chan*100+2}
+#    主码流 = rtsp://user:pass@ip:rtsp_port/Streaming/Channels/{chan*100+1}
+#
+#  一旦配置了 [camera_list],它会**取代** [streams] 和 [main_streams];
+#  未配置时仍按旧的 [streams]/[main_streams] 方式。
+# ------------------------------------------------------------
+[camera_list]
+0 192.168.1.121 admin hd135792468 1 554 80
+1 192.168.1.122 admin hd135792468 1 554 80
+2 192.168.1.123 admin hd135792468 1 554 80
+3 192.168.1.124 admin hd135792468 1 554 80
+4 192.168.1.125 admin hd135792468 1 554 80
+5 192.168.1.126 admin hd135792468 1 554 80
+6 192.168.1.129 admin hd135792468 1 554 80
+7 192.168.1.130 admin hd135792468 1 554 80
+
+# ------------------------------------------------------------
+#  [isapi_write]  可选:启动时通过 ISAPI 把指定通道改成给定规格。
+#  格式: <id> <main|sub> <width> <height> <fps> <bitrate_kbps>
+#   - 只改你填的字段;width/height 必填,fps/bitrate 填 0 表示不改
+#   - fps 用"帧率"(程序会自动 x100 换算为海康的 maxFrameRate)
+#   - 危险:会真实修改相机!默认不启用,需要时去掉下面的注释。
+#  示例(把相机0主码流改成 1280x720 @ 10fps 2048kbps):
+#  0 main 1280 720 10 2048
+# ------------------------------------------------------------
+[isapi_write]
+0 sub 640 360 25 2048
+1 sub 640 360 25 2048
+2 sub 640 360 25 2048
+3 sub 640 360 25 2048
+4 sub 640 360 25 2048
+5 sub 640 360 25 2048
+6 sub 640 360 25 2048
+7 sub 640 360 25 2048
+
+[layout]
+0 1 2
+3 4 5
+6 7 x
+
+[layout0]
+0 0 0 0 3
+0 0 0 0 4
+0 0 0 0 5
+0 0 0 0 6
+0 0 0 0 7
+
+[layout1]
+1 1 1 1 3
+1 1 1 1 4
+1 1 1 1 5
+1 1 1 1 6
+1 1 1 1 7
+
+[layout2]
+2 2 2 2 3
+2 2 2 2 4
+2 2 2 2 5
+2 2 2 2 6
+2 2 2 2 7
+
+[layout3]
+1 1 2 2 3
+1 1 2 2 4
+1 1 2 2 5
+x x x x 6
+x x x x 7
+
+
+[layout4]
+0 0 1 1 3
+0 0 1 1 4
+2 2 x x 5
+2 2 x x 6
+x x x x 7
+
+
+# ---------- 标题栏 ----------
+[title]
+enable = 1
+type = overlay
+height = 80
+text = 法兰泰克 · 多路视频随动监控
+logo = fltk.png
+logo_pad = 8
+opacity = 200
+font = /usr/share/fonts/truetype/wqy/wqy-microhei.ttc
+font_size = 48
+color = 0xFFFFFF
+
+# ---------- 故障日志 ----------
+[log]
+file = flog_isapi.txt
+rotate_ms = 3600000
+max_bytes = 1048576
+
+[plc]
+enable = 1
+ip = 192.168.1.240
+port = 102
+area = db            ; m | i | q | db
+db = 1000              ; 仅 area=db 时有效
+read_start = 0      ; 轮询块的起始字节
+read_len = 16       ; 每次轮询读取的字节数
+poll_ms = 100
+reconnect_ms = 3000
+tsap_local = 0x0000
+tsap_remote = 0x0000
+
+# ---------- PLC 位 -> 布局切换规则 ----------
+[plc_rules]
+0 : 1000.6.6,1000.6.7 
+1 : 1000.8.4,1000.8.5
+2 : 1000.8.6,1000.8.7
+3 : 1000.6.6,1000.6.7;1000.8.4,1000.8.5
+4 : 1000.6.6,1000.8.4,1000.8.5;1000.8.6,1000.8.7
+

+ 134 - 0
deploy/deploy.sh

@@ -0,0 +1,134 @@
+#!/bin/bash
+# 一键部署到新主机
+# 用法: ./deploy/deploy.sh [-i IP] [-u USER] [-p PASS] [-c] [-j N]
+#   -i  目标机 IP (默认 192.168.1.243)
+#   -u  SSH 用户 (默认 root)
+#   -p  SSH 密码 (默认 123456)
+#   -c  部署前先编译
+#   -j  编译并行数 (默认 nproc)
+#   -n  仅打包不部署 (dry-run)
+
+set -e
+DIR="$(cd "$(dirname "$0")/.." && pwd)"
+DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+TARGET_IP="192.168.1.243"
+TARGET_USER="root"
+TARGET_PASS="123456"
+DO_BUILD=0
+DRY_RUN=0
+JOBS=${JOBS:-$(nproc)}
+
+usage() {
+    cat << 'EOF'
+Usage: ./deploy/deploy.sh [options]
+
+Options:
+  -i IP       目标机 IP (默认 192.168.1.243)
+  -u USER     SSH 用户 (默认 root)
+  -p PASS     SSH 密码 (默认 123456)
+  -c          部署前先编译
+  -j N        编译并行数 (默认 nproc)
+  -n          仅打包不部署 (dry-run)
+  -h          显示帮助
+EOF
+}
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        -i) TARGET_IP="$2"; shift ;;
+        -u) TARGET_USER="$2"; shift ;;
+        -p) TARGET_PASS="$2"; shift ;;
+        -c) DO_BUILD=1 ;;
+        -j) JOBS="$2"; shift ;;
+        -n) DRY_RUN=1 ;;
+        -h|--help) usage; exit 0 ;;
+        *) echo "Unknown option: $1"; usage; exit 1 ;;
+    esac
+    shift
+done
+
+echo "=========================================="
+echo " 目标: $TARGET_USER@$TARGET_IP"
+echo "=========================================="
+echo ""
+
+# ---------- 检查 sshpass ----------
+if ! command -v sshpass >/dev/null 2>&1; then
+    echo "[deploy] 安装 sshpass ..."
+    if command -v apt-get >/dev/null 2>&1; then
+        apt-get install -y -qq sshpass
+    elif command -v yum >/dev/null 2>&1; then
+        yum install -y sshpass
+    elif command -v pacman >/dev/null 2>&1; then
+        pacman -S --noconfirm sshpass
+    else
+        echo "ERROR: 无法自动安装 sshpass,请手动安装" >&2
+        exit 1
+    fi
+fi
+
+# ---------- 测试连通性 ----------
+echo "[deploy] 测试连通性: $TARGET_IP ..."
+if ! sshpass -p "$TARGET_PASS" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
+    "$TARGET_USER@$TARGET_IP" "echo ok" >/dev/null 2>&1; then
+    echo "ERROR: 无法连接 $TARGET_USER@$TARGET_IP" >&2
+    echo "  检查: IP 是否正确、网络是否通、SSH 是否开启、密码是否正确" >&2
+    exit 1
+fi
+echo "[deploy] 连接成功"
+echo ""
+
+# ---------- 编译 ----------
+if [ "$DO_BUILD" -eq 1 ]; then
+    echo "[deploy] 编译中 (jobs=$JOBS) ..."
+    "$DIR/build.sh" -j "$JOBS"
+    echo ""
+fi
+
+# ---------- 打包 ----------
+echo "[deploy] 打包部署文件 ..."
+"$DEPLOY_DIR/pack.sh"
+TARBALL=$(ls -t "$DEPLOY_DIR"/*.tar.gz 2>/dev/null | head -1)
+if [ -z "$TARBALL" ]; then
+    echo "ERROR: 打包失败,未找到 tarball" >&2
+    exit 1
+fi
+TARBALL_NAME=$(basename "$TARBALL")
+echo "[deploy] 打包完成: $TARBALL_NAME"
+echo ""
+
+if [ "$DRY_RUN" -eq 1 ]; then
+    echo "[deploy] dry-run 模式,跳过部署"
+    echo "  手动部署: scp $TARBALL $TARGET_USER@$TARGET_IP:/tmp/"
+    exit 0
+fi
+
+# ---------- 传输 ----------
+echo "[deploy] 传输到 $TARGET_IP:/tmp/ ..."
+sshpass -p "$TARGET_PASS" scp -o StrictHostKeyChecking=no \
+    "$TARBALL" "$TARGET_USER@$TARGET_IP:/tmp/$TARBALL_NAME"
+echo "[deploy] 传输完成"
+echo ""
+
+# ---------- 远程安装 ----------
+echo "[deploy] 远程安装 ..."
+sshpass -p "$TARGET_PASS" ssh -o StrictHostKeyChecking=no \
+    "$TARGET_USER@$TARGET_IP" << REMOTE
+set -e
+cd /tmp
+tar xzf $TARBALL_NAME
+cd rtsp_multi_view_deploy
+chmod +x install.sh
+./install.sh
+REMOTE
+
+echo ""
+echo "=========================================="
+echo " 部署完成!"
+echo "=========================================="
+echo ""
+echo " 验证:"
+echo "   ssh $TARGET_USER@$TARGET_IP 'systemctl status rtsp_multi_view'"
+echo "   ssh $TARGET_USER@$TARGET_IP 'systemctl status auto-reboot'"
+echo ""

+ 30 - 0
deploy/fix_service.sh

@@ -0,0 +1,30 @@
+#!/bin/bash
+# 修复 systemd 服务无法导入 RGA buffer 的问题
+set -e
+
+cat > /etc/systemd/system/rtsp_multi_view.service << 'UNIT'
+[Unit]
+Description=RTSP Multi-View Display
+After=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/rtsp_multi_view
+ExecStart=/usr/local/bin/rtsp_multi_view -c /etc/rtsp_multi_view/channels.conf
+Restart=always
+RestartSec=3
+Nice=-10
+SupplementaryGroups=video
+Environment=LD_LIBRARY_PATH=/usr/lib/aarch64-linux-gnu:/usr/local/lib
+DeviceAllow=/dev/dri rw
+DeviceAllow=/dev/mpp_service rw
+DeviceAllow=/dev/dma_heap rw
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+systemctl daemon-reload
+systemctl restart rtsp_multi_view
+sleep 2
+systemctl status rtsp_multi_view --no-pager | head -5

二進制
deploy/fltk.png


+ 158 - 0
deploy/install.sh

@@ -0,0 +1,158 @@
+#!/bin/bash
+# RTSP Multi-View 目标机安装脚本 (RK3568 / aarch64)
+set -e
+
+if [ "$EUID" -ne 0 ]; then
+    echo "请以 root 运行: sudo ./install.sh"
+    exit 1
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+INSTALL_BIN="/usr/local/bin"
+INSTALL_CONF="/etc/rtsp_multi_view"
+INSTALL_DATA="/opt/rtsp_multi_view"
+SERVICE="/etc/systemd/system/rtsp_multi_view.service"
+
+echo "== [1/6] 检查运行依赖 =="
+# 生产镜像 (RK3568 Debian bullseye) 已预装全部运行依赖:
+#   gstreamer 全家桶 + gstreamer1.0-rockchip1(mppvideodec) + librga2 + librockchip-mpp1
+#   + libdrm/libpng/libjpeg/libfreetype/libcurl/glib 等。
+# 因此正常情况无需联网。这里只做检查: 齐全则完全离线安装;
+# 万一某台机器缺东西, 依次尝试随包离线 deps/ -> 联网 apt 补救。
+missing=""
+# 用 ldd 直接检查随包二进制能否解析 (比 ldconfig 可靠, 不受 PATH 影响)
+if command -v ldd >/dev/null 2>&1; then
+    miss_libs=$(ldd "$SCRIPT_DIR/bin/rtsp_multi_view" 2>/dev/null | awk '/not found/ {print $1}')
+    [ -n "$miss_libs" ] && missing="$missing $miss_libs"
+fi
+# GStreamer 元件 (dlopen 加载, ldd 查不到)
+if command -v gst-inspect-1.0 >/dev/null 2>&1; then
+    for el in rtspsrc parsebin appsink mppvideodec h265parse; do
+        gst-inspect-1.0 "$el" >/dev/null 2>&1 || missing="$missing gst:$el"
+    done
+fi
+
+if [ -z "$missing" ]; then
+    echo "  OK: 运行依赖齐全, 无需联网。"
+elif ls "$SCRIPT_DIR/deps/"*.deb >/dev/null 2>&1; then
+    echo "  缺少:$missing"
+    echo "  使用随包离线依赖 deps/ 安装..."
+    dpkg -i "$SCRIPT_DIR/deps/"*.deb 2>/dev/null || true
+elif command -v apt-get >/dev/null 2>&1 && timeout 3 bash -c 'echo > /dev/tcp/deb.debian.org/80' 2>/dev/null; then
+    echo "  缺少:$missing"
+    echo "  尝试联网安装补救..."
+    apt-get update -qq 2>/dev/null
+    apt-get install -y --no-install-recommends \
+        libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 \
+        gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \
+        librga2 librockchip-mpp1 \
+        libdrm2 libpng16-16 libjpeg62-turbo libfreetype6 libcurl4 \
+        2>/dev/null || echo "  WARN: 联网安装部分失败"
+else
+    echo "  缺少:$missing"
+    echo "  WARN: 缺少依赖且无离线包、无网络。请确认镜像是否完整, 程序可能无法启动。"
+fi
+
+echo "== [2/6] 安装二进制 =="
+cp "$SCRIPT_DIR/bin/rtsp_multi_view" "$INSTALL_BIN/"
+cp "$SCRIPT_DIR/bin/plc_sim"         "$INSTALL_BIN/"
+chmod +x "$INSTALL_BIN/rtsp_multi_view" "$INSTALL_BIN/plc_sim"
+
+echo "== [3/6] 安装脚本 =="
+cp "$SCRIPT_DIR/scripts/auto_reboot.sh" "$INSTALL_BIN/"
+chmod +x "$INSTALL_BIN/auto_reboot.sh"
+
+echo "== [4/6] 安装配置 =="
+mkdir -p "$INSTALL_CONF"
+if [ -f "$INSTALL_CONF/channels.conf" ]; then
+    cp "$INSTALL_CONF/channels.conf" "$INSTALL_CONF/channels.conf.bak.$(date +%Y%m%d_%H%M%S)"
+    echo "  旧配置已备份: $(ls -t "$INSTALL_CONF"/channels.conf.bak.* | head -1)"
+fi
+cp "$SCRIPT_DIR/conf/channels.conf" "$INSTALL_CONF/"
+echo "  已安装最新配置: $INSTALL_CONF/channels.conf"
+
+echo "== [5/6] 安装数据文件 =="
+mkdir -p "$INSTALL_DATA"
+[ -d "$SCRIPT_DIR/models" ] && cp -r "$SCRIPT_DIR/models" "$INSTALL_DATA/"
+[ -f "$SCRIPT_DIR/fltk.png" ] && cp "$SCRIPT_DIR/fltk.png" "$INSTALL_DATA/"
+cp "$SCRIPT_DIR/scripts/plc_server.py" "$INSTALL_DATA/"
+cp "$SCRIPT_DIR/scripts/plc_server.sh" "$INSTALL_DATA/"
+cp "$SCRIPT_DIR/scripts/auto_reboot.sh" "$INSTALL_DATA/"
+chmod +x "$INSTALL_DATA/plc_server.sh" "$INSTALL_DATA/auto_reboot.sh"
+
+echo "== [5.5/6] 安装中文字体 =="
+if [ -d "$SCRIPT_DIR/fonts" ] && ls "$SCRIPT_DIR/fonts/"* >/dev/null 2>&1; then
+    mkdir -p /usr/share/fonts/truetype/wqy
+    cp "$SCRIPT_DIR/fonts/"* /usr/share/fonts/truetype/wqy/
+    chmod 644 /usr/share/fonts/truetype/wqy/*
+    command -v fc-cache >/dev/null 2>&1 && fc-cache -f >/dev/null 2>&1 || true
+    echo "  字体已安装到 /usr/share/fonts/truetype/wqy/"
+else
+    echo "  WARN: 未随包提供字体,标题栏文字可能不显示(程序已支持自动回退)"
+fi
+
+echo "== [6/6] 创建 systemd 服务 =="
+cat > "$SERVICE" << 'UNIT'
+[Unit]
+Description=RTSP Multi-View Display
+After=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/rtsp_multi_view
+ExecStart=/usr/local/bin/rtsp_multi_view -c /etc/rtsp_multi_view/channels.conf
+Restart=always
+RestartSec=3
+Nice=-10
+SupplementaryGroups=video
+Environment=LD_LIBRARY_PATH=/usr/lib/aarch64-linux-gnu:/usr/local/lib
+# 注意:不要加 DeviceAllow。systemd 一旦有 DeviceAllow= 就会启用设备白名单,
+# 且 /dev/dri rw 这种"目录路径"匹配不到 /dev/dri/card0,会导致 open 失败
+# 直接表现为 "Failed to create display"、服务反复重启(手动运行却正常)。
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+systemctl daemon-reload
+systemctl enable rtsp_multi_view
+echo "  主服务已启用 (自动重启由程序内置 [auto_reboot] 调度,无需独立服务)"
+
+echo "== [6.5/6] 配置静态 IP =="
+# 现场通常没有路由器/DHCP。给 eth0 追加一个固定地址 192.168.100.245/24,
+# 并保留 DHCP:有路由器时照常拿自动地址,没路由器时也有这个固定地址。
+# 这些板子的 eth0 由 NetworkManager 管理,必须用 nmcli 写连接;
+# /etc/network/interfaces 在 NM 托管时不生效(ifupdown 未接管 eth0)。
+STATIC_IFACE="eth0"
+STATIC_CIDR="192.168.100.245/24"
+if command -v nmcli >/dev/null 2>&1 && nmcli -t -g RUNNING general 2>/dev/null | grep -qx running; then
+    CON="$(nmcli -t -f GENERAL.CONNECTION dev show "$STATIC_IFACE" 2>/dev/null | head -1 | cut -d: -f2-)"
+    if [ -n "$CON" ] && [ "$CON" != "--" ]; then
+        nmcli con mod "$CON" +ipv4.addresses "$STATIC_CIDR" >/dev/null 2>&1 || true
+        nmcli dev reapply "$STATIC_IFACE" >/dev/null 2>&1 || nmcli con up "$CON" >/dev/null 2>&1 || true
+        echo "  已在 $STATIC_IFACE 追加 $STATIC_CIDR (保留 DHCP, 连接 \"$CON\")"
+    else
+        nmcli con add type ethernet ifname "$STATIC_IFACE" con-name rtsp-static \
+            ipv4.method auto >/dev/null 2>&1 || true
+        nmcli con mod rtsp-static +ipv4.addresses "$STATIC_CIDR" >/dev/null 2>&1 || true
+        nmcli con up rtsp-static >/dev/null 2>&1 || true
+        echo "  已创建连接 rtsp-static: $STATIC_IFACE += $STATIC_CIDR (保留 DHCP)"
+    fi
+else
+    echo "  WARN: 未检测到运行中的 NetworkManager, 跳过静态 IP 设置"
+fi
+
+echo ""
+echo "=========================================="
+echo " 安装完成!"
+echo "=========================================="
+echo ""
+echo " 启动主服务: systemctl start rtsp_multi_view"
+echo " 查看状态:   systemctl status rtsp_multi_view"
+echo " 查看日志:   journalctl -u rtsp_multi_view -f"
+echo " PLC 调试:   cd $INSTALL_DATA && ./plc_server.sh"
+echo " 自动重启:   编辑 $INSTALL_CONF/channels.conf 的 [auto_reboot] 段"
+echo ""
+echo " 配置文件:   $INSTALL_CONF/channels.conf"
+echo " 数据目录:   $INSTALL_DATA/"
+echo ""

+ 101 - 0
deploy/pack.sh

@@ -0,0 +1,101 @@
+#!/bin/bash
+# 打包部署文件,用于快速部署到其它 RK3568
+# 打包产物与 pack.sh 同目录 (deploy/)
+# 用法: ./deploy/pack.sh [-c] [-j N]
+#   -c  打包前先编译
+#   -j N  编译并行数 (默认 nproc)
+
+set -e
+DIR="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD_DIR="$DIR/build"
+DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUT_DIR="/tmp/rtsp_multi_view_deploy"
+TARBALL="$DEPLOY_DIR/rtsp_multi_view_aarch64_$(date +%Y%m%d_%H%M%S).tar.gz"
+DO_BUILD=0
+JOBS=${JOBS:-$(nproc)}
+
+while [ $# -gt 0 ]; do
+    case "$1" in
+        -c) DO_BUILD=1 ;;
+        -j) JOBS="$2"; shift ;;
+        *) echo "Unknown option: $1"; exit 1 ;;
+    esac
+    shift
+done
+
+if [ "$DO_BUILD" -eq 1 ]; then
+    echo "== 编译 =="
+    "$DIR/build.sh" -j "$JOBS"
+fi
+
+# 检查必要文件
+for f in "$BUILD_DIR/rtsp_multi_view" "$BUILD_DIR/plc_sim" "$DIR/plc_server.py" "$DIR/channels.conf" "$DEPLOY_DIR/install.sh"; do
+    if [ ! -f "$f" ]; then
+        echo "ERROR: $f 不存在,请先编译或检查文件" >&2
+        exit 1
+    fi
+done
+
+echo "== 准备打包目录 =="
+rm -rf "$OUT_DIR"
+mkdir -p "$OUT_DIR"/{bin,conf,models,scripts,fonts}
+
+echo "== 复制文件 =="
+# 二进制
+cp "$BUILD_DIR/rtsp_multi_view" "$OUT_DIR/bin/"
+cp "$BUILD_DIR/plc_sim"         "$OUT_DIR/bin/"
+chmod +x "$OUT_DIR/bin/"*
+
+# 配置
+cp "$DIR/channels.conf" "$OUT_DIR/conf/"
+
+# 模型
+if [ -d "$DIR/models" ] && ls "$DIR/models/"* >/dev/null 2>&1; then
+    cp "$DIR/models/"* "$OUT_DIR/models/"
+fi
+
+# 脚本
+cp "$DIR/plc_server.py"  "$OUT_DIR/scripts/"
+cp "$DIR/plc_server.sh"  "$OUT_DIR/scripts/"
+cp "$DIR/auto_reboot.sh" "$OUT_DIR/scripts/"
+chmod +x "$OUT_DIR/scripts/"*.sh
+
+# Logo
+if [ -f "$DIR/fltk.png" ]; then
+    cp "$DIR/fltk.png" "$OUT_DIR/"
+fi
+
+# 中文字体(标题栏渲染用,避免目标机缺 wqy-microhei 时标题文字不显示)
+if [ -f "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc" ]; then
+    cp "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc" "$OUT_DIR/fonts/"
+fi
+
+# 目标机安装脚本:复用统一的 deploy/install.sh(唯一维护源)
+cp "$DEPLOY_DIR/install.sh" "$OUT_DIR/install.sh"
+chmod +x "$OUT_DIR/install.sh"
+
+# 可选:离线依赖 deb(若 deploy/deps/ 存在则随包携带,install.sh 会自动使用)
+if [ -d "$DEPLOY_DIR/deps" ] && ls "$DEPLOY_DIR/deps/"*.deb >/dev/null 2>&1; then
+    mkdir -p "$OUT_DIR/deps"
+    cp "$DEPLOY_DIR/deps/"*.deb "$OUT_DIR/deps/"
+    echo "  已包含离线依赖: $(ls "$DEPLOY_DIR/deps/"*.deb | wc -l) 个 deb"
+fi
+
+echo "== 生成打包 =="
+cd /tmp
+tar czf "$TARBALL" -C /tmp rtsp_multi_view_deploy
+rm -rf "$OUT_DIR"
+
+SIZE=$(du -h "$TARBALL" | cut -f1)
+echo ""
+echo "=========================================="
+echo " 打包完成!"
+echo "=========================================="
+echo " 文件: $TARBALL"
+echo " 大小: $SIZE"
+echo ""
+echo " 部署到目标机:"
+echo "   scp $TARBALL root@<目标IP>:/tmp/"
+echo "   ssh root@<目标IP>"
+echo "   cd /tmp && tar xzf $(basename "$TARBALL") && cd rtsp_multi_view_deploy && ./install.sh"
+echo ""

+ 196 - 0
deploy/scripts/plc_server.py

@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+"""Local S7comm PLC simulator (server).
+
+Stands in for a physical Siemens S7 PLC so that rtsp_multi_view can READ and
+plc_sim can WRITE a small "process image" (byte array) over S7comm / ISO-on-TCP.
+
+Handles: COTP Connect Request/Confirm, S7 Setup Communication, S7 Read Var
+(function 0x04) and S7 Write Var (function 0x05). Multiple clients are served
+on separate threads.
+
+Usage:
+    plc_server.py [-p PORT] [-l LEN] [--cycle] [-t CYCLE_MS]
+
+    -p PORT    listen port (default 102; needs root on Linux)
+    -l LEN     process image size in bytes (default 16)
+    --cycle    also rotate the 6 layout-switch states every cycle
+    -t MS      state cycle interval in ms (default 10000)
+
+With --cycle the server drives byte0 through 0x00,0x03,0x0C,0x30,0x0F,0x3F
+all by itself, so plc_sim is not required.
+"""
+
+import argparse
+import socket
+import sys
+import threading
+import time
+
+TS_BYTE = 0x02
+CYCLE_STATES = [0x00, 0x03, 0x0C, 0x30, 0x0F, 0x3F]
+STATE_NAMES = [
+    "state 0: all bits 0 (default layout)",
+    "state 1: M0.0|M0.1 (layout 0)",
+    "state 2: M0.2|M0.3 (layout 1)",
+    "state 3: M0.4|M0.5 (layout 2)",
+    "state 4: M0.0,0.1;M0.2,0.3 (layout 3)",
+    "state 5: M0.0,0.1;M0.2,0.3;M0.4,0.5 (layout 4)",
+]
+
+CC = bytes([0x03, 0x00, 0x00, 0x15]) + bytes([
+    0x0B, 0xD0, 0x00, 0x00, 0x01, 0x00,
+    0xC0, 0x01, 0x0A,
+    0xC1, 0x02, 0x01, 0x00,
+    0xC2, 0x02, 0x01, 0x02,
+])
+
+
+def put16(v):
+    return bytes([(v >> 8) & 0xFF, v & 0xFF])
+
+
+def tpkt(s7):
+    total = 4 + 3 + len(s7)
+    return bytes([0x03, 0x00, (total >> 8) & 0xFF, total & 0xFF,
+                  0x02, 0xF0, 0x80]) + s7
+
+
+def recv_exact(c, n):
+    buf = b''
+    while len(buf) < n:
+        r = c.recv(n - len(buf))
+        if not r:
+            return None
+        buf += r
+    return buf
+
+
+def read_tpkt(c):
+    h = recv_exact(c, 4)
+    if not h:
+        return None
+    total = (h[2] << 8) | h[3]
+    if total < 7 or total > 4096:
+        return None
+    body = recv_exact(c, total - 4)
+    if body is None:
+        return None
+    return h + body
+
+
+class PlcServer:
+    def __init__(self, area_len, cycle, cycle_ms):
+        self.lock = threading.Lock()
+        self.area = bytearray(area_len)
+        self.cycle = cycle
+        self.cycle_ms = cycle_ms
+        self.state_idx = 0
+
+    def setup_resp(self, seq):
+        return bytes([0x32, 0x03, 0x00, 0x00]) + seq + \
+            bytes([0x00, 0x08, 0x00, 0x00, 0x00, 0x00,
+                   0xF0, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0xE0])
+
+    def read_var(self, seq, s7):
+        ts = s7[15]
+        n = (s7[16] << 8) | s7[17]
+        if n > len(self.area):
+            n = len(self.area)
+        with self.lock:
+            data = bytes(self.area[:n])
+        blen = n if ts == TS_BYTE else n * 8
+        resp = bytes([0x32, 0x03, 0x00, 0x00]) + seq + \
+            put16(2) + put16(4 + n) + bytes([0x00, 0x00])
+        resp += bytes([0x04, 0x01])
+        resp += bytes([0xFF, ts]) + put16(blen) + data
+        return resp
+
+    def write_var(self, seq, s7):
+        ts = s7[15]
+        n = (s7[16] << 8) | s7[17]
+        item = s7[24:]
+        blen = (item[2] << 8) | item[3]
+        nbytes = blen if ts == TS_BYTE else blen // 8
+        if nbytes > len(self.area):
+            nbytes = len(self.area)
+        data = item[4:4 + nbytes]
+        with self.lock:
+            self.area[:nbytes] = data
+        print("[plc] write %d bytes @%d -> byte0=0x%02X" %
+              (nbytes, 0, data[0] if data else 0), flush=True)
+        return bytes([0x32, 0x03, 0x00, 0x00]) + seq + \
+            bytes([0x00, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x05, 0x01, 0xFF])
+
+    def handle(self, conn, addr):
+        try:
+            conn.settimeout(120)
+            while True:
+                frame = read_tpkt(conn)
+                if frame is None:
+                    break
+                payload = frame[4:]
+                if payload[1] == 0xE0:            # COTP Connect Request
+                    conn.sendall(CC)
+                elif payload[1] == 0xF0:          # COTP Data
+                    s7 = payload[3:]
+                    if len(s7) < 12:
+                        continue
+                    func = s7[10]
+                    seq = s7[4:6]
+                    if func == 0xF0:              # S7 Setup Communication
+                        conn.sendall(tpkt(self.setup_resp(seq)))
+                    elif func == 0x04:            # S7 Read Var
+                        conn.sendall(tpkt(self.read_var(seq, s7)))
+                    elif func == 0x05:            # S7 Write Var
+                        conn.sendall(tpkt(self.write_var(seq, s7)))
+                    # else: ignore unknown requests
+        except Exception:
+            pass
+        finally:
+            conn.close()
+        print("[plc] client %s:%d disconnected" % addr, flush=True)
+
+    def cycle_loop(self):
+        while True:
+            time.sleep(self.cycle_ms / 1000.0)
+            v = CYCLE_STATES[self.state_idx]
+            with self.lock:
+                self.area[0] = v
+            print("[plc] %s (byte0=0x%02X)" %
+                  (STATE_NAMES[self.state_idx], v), flush=True)
+            self.state_idx = (self.state_idx + 1) % len(CYCLE_STATES)
+
+
+def main():
+    ap = argparse.ArgumentParser(description="Local S7comm PLC simulator")
+    ap.add_argument("-p", "--port", type=int, default=102)
+    ap.add_argument("-l", "--len", type=int, default=16)
+    ap.add_argument("--cycle", action="store_true",
+                    help="self-rotate the 6 layout states every cycle")
+    ap.add_argument("-t", "--cycle-ms", type=int, default=10000)
+    args = ap.parse_args()
+
+    server = PlcServer(args.len, args.cycle, args.cycle_ms)
+
+    if args.cycle:
+        threading.Thread(target=server.cycle_loop, daemon=True).start()
+
+    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+    srv.bind(("0.0.0.0", args.port))
+    srv.listen(8)
+    print("[plc] local PLC listening on 0.0.0.0:%d area_len=%d%s" %
+          (args.port, args.len, " (cycling)" if args.cycle else ""), flush=True)
+
+    while True:
+        conn, addr = srv.accept()
+        print("[plc] client %s:%d connected" % addr, flush=True)
+        threading.Thread(target=server.handle, args=(conn, addr),
+                         daemon=True).start()
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except KeyboardInterrupt:
+        sys.exit(0)

+ 41 - 0
deploy/scripts/plc_server.sh

@@ -0,0 +1,41 @@
+#!/bin/bash
+# 本地 PLC 布局切换联调脚本:
+#   1. 启动本地 PLC(S7comm 服务端,plc_server.py)
+#   2. 启动状态切换写入器(plc_sim,每 5 秒写一个状态)
+# Ctrl+C 可完全退出两个服务。
+# 注意:TCP 102 为特权端口,需要 root 运行。
+
+DIR="$(cd "$(dirname "$0")" && pwd)"
+CONF="${CONF:-${DIR}/channels.conf}"
+PLCPID=""; SIMPID=""
+
+cleanup() {
+    echo ""
+    echo "== 正在停止服务 =="
+    [ -n "$SIMPID" ]  && kill "$SIMPID"  2>/dev/null && wait "$SIMPID"  2>/dev/null
+    [ -n "$PLCPID" ]  && kill "$PLCPID"  2>/dev/null && wait "$PLCPID"  2>/dev/null
+    echo "== 已停止 =="
+}
+trap cleanup INT TERM
+
+if ! command -v python3 >/dev/null 2>&1; then
+    echo "ERROR: python3 not found" >&2
+    exit 1
+fi
+for bin in plc_sim rtsp_multi_view; do
+    if [ ! -x "${DIR}/build/${bin}" ]; then
+        echo "ERROR: ${DIR}/build/${bin} not built" >&2
+        exit 1
+    fi
+done
+
+echo "== [1/2] 启动本地 PLC (plc_server.py) =="
+python3 "${DIR}/plc_server.py" -p 102 &
+PLCPID=$!
+
+echo "== [2/2] 启动状态切换器 (plc_sim, 每 5 秒切换) =="
+"${DIR}/build/plc_sim" -c "$CONF" -t 5000 &
+SIMPID=$!
+
+wait
+

+ 517 - 0
detector.c

@@ -0,0 +1,517 @@
+#include "detector.h"
+#include "dma_util.h"
+#include "rknn_api.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <linux/dma-heap.h>
+#include <linux/dma-buf.h>
+#include <math.h>
+#include <rga/im2d.h>
+#include <rga/rga.h>
+
+#define DBG(...) fprintf(stderr, __VA_ARGS__)
+
+struct Detector {
+    rknn_context ctx;
+    int model_w;
+    int model_h;
+    int model_type;
+    rknn_input_output_num io_num;
+    rknn_tensor_attr *input_attrs;
+    rknn_tensor_attr *output_attrs;
+    float conf_thresh;
+    float nms_thresh;
+
+    int rgb_fd;
+    void *rgb_map;
+    size_t rgb_size;
+    int rgb_initialized;
+};
+
+enum {
+    MODEL_YOLOV5 = 0,
+    MODEL_YOLOV8 = 1,
+};
+
+Detector *detector_create(const char *model_path, float conf_thresh, float nms_thresh) {
+    Detector *det = calloc(1, sizeof(Detector));
+    if (!det) return NULL;
+
+    det->conf_thresh = conf_thresh;
+    det->nms_thresh = nms_thresh;
+    det->ctx = 0;
+
+    FILE *fp = fopen(model_path, "rb");
+    if (!fp) { DBG("det: cannot open model %s\n", model_path); goto fail; }
+    fseek(fp, 0, SEEK_END);
+    long model_size = ftell(fp);
+    rewind(fp);
+    void *model_data = malloc(model_size);
+    if (!model_data) { fclose(fp); goto fail; }
+    if (fread(model_data, 1, model_size, fp) != (size_t)model_size) {
+        fclose(fp); free(model_data); goto fail;
+    }
+    fclose(fp);
+
+    int ret = rknn_init(&det->ctx, model_data, model_size, 0, NULL);
+    free(model_data);
+    if (ret < 0) { DBG("det: rknn_init failed %d\n", ret); goto fail; }
+
+    ret = rknn_query(det->ctx, RKNN_QUERY_IN_OUT_NUM, &det->io_num, sizeof(det->io_num));
+    if (ret < 0) { DBG("det: query io num failed %d\n", ret); goto fail; }
+    DBG("det: %d inputs, %d outputs\n", det->io_num.n_input, det->io_num.n_output);
+
+    det->input_attrs = calloc(det->io_num.n_input, sizeof(rknn_tensor_attr));
+    det->output_attrs = calloc(det->io_num.n_output, sizeof(rknn_tensor_attr));
+    if (!det->input_attrs || !det->output_attrs) goto fail;
+
+    for (uint32_t i = 0; i < det->io_num.n_input; i++) {
+        det->input_attrs[i].index = i;
+        rknn_query(det->ctx, RKNN_QUERY_INPUT_ATTR, &det->input_attrs[i], sizeof(rknn_tensor_attr));
+        DBG("det: input[%d] %s n_dims=%d dims=[%d,%d,%d,%d] fmt=%d type=%d\n",
+            i, det->input_attrs[i].name, det->input_attrs[i].n_dims,
+            det->input_attrs[i].dims[0], det->input_attrs[i].dims[1],
+            det->input_attrs[i].dims[2], det->input_attrs[i].dims[3],
+            det->input_attrs[i].fmt, det->input_attrs[i].type);
+    }
+    for (uint32_t i = 0; i < det->io_num.n_output; i++) {
+        det->output_attrs[i].index = i;
+        rknn_query(det->ctx, RKNN_QUERY_OUTPUT_ATTR, &det->output_attrs[i], sizeof(rknn_tensor_attr));
+        DBG("det: output[%d] %s n_dims=%d dims=[%d,%d,%d,%d] fmt=%d type=%d\n",
+            i, det->output_attrs[i].name, det->output_attrs[i].n_dims,
+            det->output_attrs[i].dims[0], det->output_attrs[i].dims[1],
+            det->output_attrs[i].dims[2], det->output_attrs[i].dims[3],
+            det->output_attrs[i].fmt, det->output_attrs[i].type);
+    }
+
+    det->model_w = det->input_attrs[0].dims[3];
+    det->model_h = det->input_attrs[0].dims[2];
+
+    det->model_type = MODEL_YOLOV8;
+    if (det->io_num.n_output == 3) {
+        int all_anchor = 1;
+        for (uint32_t i = 0; i < det->io_num.n_output; i++) {
+            if (det->output_attrs[i].n_dims != 3 ||
+                !(det->output_attrs[i].dims[2] == 85 ||
+                  det->output_attrs[i].dims[2] == 56)) {
+                all_anchor = 0;
+                break;
+            }
+        }
+        if (all_anchor) det->model_type = MODEL_YOLOV5;
+    }
+    DBG("det: model input %dx%d type=%s\n", det->model_w, det->model_h,
+        det->model_type == MODEL_YOLOV5 ? "yolov5" : "yolov8");
+
+    det->rgb_size = det->model_w * det->model_h * 3;
+    if (dma_heap_alloc("reserved", det->rgb_size, &det->rgb_fd) < 0) {
+        DBG("det: failed to alloc rgb buffer\n");
+        goto fail;
+    }
+    det->rgb_map = mmap(NULL, det->rgb_size, PROT_READ | PROT_WRITE, MAP_SHARED, det->rgb_fd, 0);
+    if (det->rgb_map == MAP_FAILED) {
+        DBG("det: mmap rgb buffer failed\n");
+        close(det->rgb_fd); det->rgb_fd = -1;
+        goto fail;
+    }
+    det->rgb_initialized = 1;
+
+    DBG("det: created successfully, model %dx%d\n", det->model_w, det->model_h);
+    return det;
+
+fail:
+    detector_destroy(det);
+    return NULL;
+}
+
+void detector_destroy(Detector *det) {
+    if (!det) return;
+    if (det->rgb_initialized && det->rgb_map && det->rgb_map != MAP_FAILED)
+        munmap(det->rgb_map, det->rgb_size);
+    if (det->rgb_fd >= 0) close(det->rgb_fd);
+    if (det->ctx) rknn_destroy(det->ctx);
+    free(det->input_attrs);
+    free(det->output_attrs);
+    free(det);
+}
+
+int detector_get_input_width(Detector *det) { return det->model_w; }
+int detector_get_input_height(Detector *det) { return det->model_h; }
+
+static int rga_scale_nv12_to_rgb(int src_fd, int src_w, int src_h,
+                                  int dst_fd, int dst_w, int dst_h,
+                                  void *rgb_map, size_t rgb_size) {
+    memset(rgb_map, 0, rgb_size);
+
+    float scale = fminf((float)dst_w / src_w, (float)dst_h / src_h);
+    int content_w = (int)(src_w * scale);
+    int content_h = (int)(src_h * scale);
+    int pad_x = (dst_w - content_w) / 2;
+    int pad_y = (dst_h - content_h) / 2;
+
+    rga_buffer_t src = wrapbuffer_fd_t(src_fd, src_w, src_h, src_w, src_h, RK_FORMAT_YCbCr_420_SP);
+    rga_buffer_t dst = wrapbuffer_fd_t(dst_fd, dst_w, dst_h, dst_w, dst_h, RK_FORMAT_RGB_888);
+
+    im_rect src_rect = {0, 0, src_w, src_h};
+    im_rect dst_rect = {pad_x, pad_y, content_w, content_h};
+    rga_buffer_t pat;
+    memset(&pat, 0, sizeof(pat));
+    im_rect prect;
+    memset(&prect, 0, sizeof(prect));
+
+    int ret = improcess(src, dst, pat, src_rect, dst_rect, prect, IM_SYNC);
+    if (ret != IM_STATUS_SUCCESS) {
+        DBG("det: RGA scale failed: %d\n", ret);
+        return -1;
+    }
+    return 0;
+}
+
+static inline float sigmoid(float x) {
+    return 1.0f / (1.0f + expf(-x));
+}
+
+static int yolov5_decode(Detector *det, rknn_output *outputs,
+                          DetResult *results, int max_results) {
+    int num = 0;
+    int num_classes = -1;
+
+    for (uint32_t o = 0; o < det->io_num.n_output; o++) {
+        rknn_tensor_attr *oa = &det->output_attrs[o];
+        if (oa->n_dims != 3) continue;
+
+        int C = oa->dims[2];
+        if (C != 85 && C != 56) continue;
+        if (num_classes < 0) num_classes = C - 5;
+    }
+    if (num_classes <= 0) return 0;
+
+    int anchors[][3][2] = {
+        {{10,13}, {16,30}, {33,23}},
+        {{30,61}, {62,45}, {59,119}},
+        {{116,90}, {156,198}, {373,326}},
+    };
+    int strides[] = {8, 16, 32};
+
+    for (uint32_t o = 0; o < det->io_num.n_output && o < 3; o++) {
+        rknn_tensor_attr *oa = &det->output_attrs[o];
+        if (oa->n_dims != 3) continue;
+
+        int C = oa->dims[2];
+        if (C != 85 && C != 56) continue;
+
+        int num_anchors = oa->dims[1];
+        int grid_size = num_anchors / 3;
+        int grid_h = grid_size, grid_w = grid_size;
+        for (int g = (int)sqrtf(grid_size); g >= 1; g--) {
+            if (grid_size % g == 0) { grid_h = g; grid_w = grid_size / g; break; }
+        }
+
+        float *data = (float *)outputs[o].buf;
+        int stride = strides[o];
+
+        for (int a = 0; a < 3 && num < max_results; a++) {
+            for (int gy = 0; gy < grid_h && num < max_results; gy++) {
+                for (int gx = 0; gx < grid_w && num < max_results; gx++) {
+                    int idx = a * grid_h * grid_w + gy * grid_w + gx;
+                    float *p = data + idx * C;
+
+                    float obj_conf = sigmoid(p[4]);
+                    if (obj_conf < det->conf_thresh) continue;
+
+                    float max_cls = 0;
+                    int best_cls = 0;
+                    for (int c = 0; c < num_classes; c++) {
+                        float s = sigmoid(p[5 + c]);
+                        if (s > max_cls) { max_cls = s; best_cls = c; }
+                    }
+
+                    float score = obj_conf * max_cls;
+                    if (score < det->conf_thresh) continue;
+
+                    float cx = (sigmoid(p[0]) * 2.0f - 0.5f + gx) * stride;
+                    float cy = (sigmoid(p[1]) * 2.0f - 0.5f + gy) * stride;
+                    float bw = powf(sigmoid(p[2]) * 2.0f, 2) * anchors[o][a][0];
+                    float bh = powf(sigmoid(p[3]) * 2.0f, 2) * anchors[o][a][1];
+
+                    results[num].x1 = cx - bw / 2;
+                    results[num].y1 = cy - bh / 2;
+                    results[num].x2 = cx + bw / 2;
+                    results[num].y2 = cy + bh / 2;
+                    results[num].score = score;
+                    results[num].label = best_cls;
+                    num++;
+                }
+            }
+        }
+    }
+    return num;
+}
+
+static int yolov8_decode(Detector *det, rknn_output *outputs,
+                          DetResult *results, int max_results) {
+    int num = 0;
+
+    for (uint32_t o = 0; o < det->io_num.n_output; o++) {
+        rknn_tensor_attr *oa = &det->output_attrs[o];
+        int n_dims = oa->n_dims;
+        uint32_t *dims = oa->dims;
+
+        if (n_dims != 3) continue;
+
+        int C, H, W;
+        int num_classes;
+
+        if (dims[1] == 84 || dims[1] == 56) {
+            C = dims[1]; H = 1; W = dims[2];
+            num_classes = C - 4;
+        } else if (dims[2] == 84 || dims[2] == 56) {
+            C = dims[2]; H = dims[1]; W = 1;
+            num_classes = C - 4;
+        } else {
+            H = dims[1]; W = dims[2];
+            C = (n_dims > 3) ? dims[3] : 1;
+            num_classes = C - 4;
+        }
+
+        if (num_classes <= 0) continue;
+
+        float *data = (float *)outputs[o].buf;
+
+        int num_candidates;
+        int ch_stride;
+        if (dims[1] == 84 || dims[1] == 56) {
+            num_candidates = dims[2];
+            ch_stride = 1;
+        } else if (dims[2] == 84 || dims[2] == 56) {
+            num_candidates = dims[1];
+            ch_stride = 1;
+        } else {
+            num_candidates = H * W;
+            ch_stride = num_candidates;
+        }
+
+        for (int i = 0; i < num_candidates && num < max_results; i++) {
+            float cx, cy, bw, bh;
+            float max_score = 0;
+            int best_label = -1;
+
+            if (dims[1] == 84 || dims[1] == 56) {
+                cx = data[0 * num_candidates + i];
+                cy = data[1 * num_candidates + i];
+                bw = data[2 * num_candidates + i];
+                bh = data[3 * num_candidates + i];
+                for (int c = 0; c < num_classes; c++) {
+                    float s = data[(4 + c) * num_candidates + i];
+                    if (s > max_score) { max_score = s; best_label = c; }
+                }
+            } else if (dims[2] == 84 || dims[2] == 56) {
+                cx = data[i * C + 0];
+                cy = data[i * C + 1];
+                bw = data[i * C + 2];
+                bh = data[i * C + 3];
+                for (int c = 0; c < num_classes; c++) {
+                    float s = data[i * C + 4 + c];
+                    if (s > max_score) { max_score = s; best_label = c; }
+                }
+            } else {
+                int idx = i;
+                cx = data[0 * ch_stride + idx];
+                cy = data[1 * ch_stride + idx];
+                bw = data[2 * ch_stride + idx];
+                bh = data[3 * ch_stride + idx];
+                for (int c = 0; c < num_classes; c++) {
+                    float s = data[(4 + c) * ch_stride + idx];
+                    if (s > max_score) { max_score = s; best_label = c; }
+                }
+            }
+
+            if (max_score > det->conf_thresh && best_label >= 0) {
+                float x1 = cx - bw / 2;
+                float y1 = cy - bh / 2;
+                float x2 = cx + bw / 2;
+                float y2 = cy + bh / 2;
+                results[num].x1 = x1;
+                results[num].y1 = y1;
+                results[num].x2 = x2;
+                results[num].y2 = y2;
+                results[num].score = max_score;
+                results[num].label = best_label;
+                num++;
+            }
+        }
+    }
+    return num;
+}
+
+static int cmp_score_desc(const void *a, const void *b) {
+    float sa = ((const DetResult *)a)->score;
+    float sb = ((const DetResult *)b)->score;
+    if (sb > sa) return 1;
+    if (sb < sa) return -1;
+    return 0;
+}
+
+static int nms(DetResult *boxes, int num_boxes, float iou_thresh) {
+    if (num_boxes == 0) return 0;
+    int *keep = malloc(num_boxes * sizeof(int));
+    int *removed = calloc(num_boxes, sizeof(int));
+    int kept = 0;
+
+    for (int i = 0; i < num_boxes; i++) {
+        if (removed[i]) continue;
+        keep[kept++] = i;
+        for (int j = i + 1; j < num_boxes; j++) {
+            if (removed[j]) continue;
+            float x1 = fmaxf(boxes[i].x1, boxes[j].x1);
+            float y1 = fmaxf(boxes[i].y1, boxes[j].y1);
+            float x2 = fminf(boxes[i].x2, boxes[j].x2);
+            float y2 = fminf(boxes[i].y2, boxes[j].y2);
+            float inter = fmaxf(0, x2 - x1) * fmaxf(0, y2 - y1);
+            float a1 = (boxes[i].x2 - boxes[i].x1) * (boxes[i].y2 - boxes[i].y1);
+            float a2 = (boxes[j].x2 - boxes[j].x1) * (boxes[j].y2 - boxes[j].y1);
+            float iou = inter / (a1 + a2 - inter + 1e-6f);
+            if (iou > iou_thresh) removed[j] = 1;
+        }
+    }
+
+    for (int i = 0; i < kept; i++)
+        boxes[i] = boxes[keep[i]];
+
+    free(keep);
+    free(removed);
+    return kept;
+}
+
+int detector_detect(Detector *det, int src_nv12_fd, int src_w, int src_h,
+                     DetResult *results, int *num_results) {
+    *num_results = 0;
+
+    if (!det || !det->rgb_initialized) return -1;
+
+    int mw = det->model_w;
+    int mh = det->model_h;
+
+    if (rga_scale_nv12_to_rgb(src_nv12_fd, src_w, src_h,
+                                det->rgb_fd, mw, mh,
+                                det->rgb_map, det->rgb_size) < 0)
+        return -1;
+
+    /* RGA wrote the RGB buffer via DMA; invalidate CPU cache before
+       rknn_inputs_set reads it, otherwise detection sees stale pixels. */
+    {
+        struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_READ };
+        ioctl(det->rgb_fd, DMA_BUF_IOCTL_SYNC, &sync);
+    }
+
+    rknn_input inputs[1];
+    memset(inputs, 0, sizeof(inputs));
+    inputs[0].index = 0;
+    inputs[0].type = RKNN_TENSOR_UINT8;
+    inputs[0].fmt = RKNN_TENSOR_NHWC;
+    inputs[0].pass_through = 0;
+    inputs[0].buf = det->rgb_map;
+    inputs[0].size = det->rgb_size;
+
+    int ret = rknn_inputs_set(det->ctx, 1, inputs);
+    if (ret < 0) { DBG("det: rknn_inputs_set failed %d\n", ret); return -1; }
+
+    ret = rknn_run(det->ctx, NULL);
+    if (ret < 0) { DBG("det: rknn_run failed %d\n", ret); return -1; }
+
+    rknn_output outputs[det->io_num.n_output];
+    memset(outputs, 0, sizeof(outputs));
+    for (uint32_t i = 0; i < det->io_num.n_output; i++) {
+        outputs[i].want_float = 1;
+        outputs[i].is_prealloc = 0;
+        outputs[i].index = i;
+    }
+
+    ret = rknn_outputs_get(det->ctx, det->io_num.n_output, outputs, NULL);
+    if (ret < 0) { DBG("det: rknn_outputs_get failed %d\n", ret); return -1; }
+
+    int num_raw;
+    if (det->model_type == MODEL_YOLOV5)
+        num_raw = yolov5_decode(det, outputs, results, MAX_DETECTIONS);
+    else
+        num_raw = yolov8_decode(det, outputs, results, MAX_DETECTIONS);
+    qsort(results, num_raw, sizeof(DetResult), cmp_score_desc);
+    int num_kept = nms(results, num_raw, det->nms_thresh);
+    *num_results = num_kept;
+
+    rknn_outputs_release(det->ctx, det->io_num.n_output, outputs);
+    return 0;
+}
+
+static const uint8_t BOX_COLORS[][3] = {
+    {255, 0, 0}, {0, 255, 0}, {0, 0, 255}, {255, 255, 0},
+    {255, 0, 255}, {0, 255, 255}, {128, 255, 0}, {255, 128, 0},
+    {0, 128, 255}, {255, 0, 128},
+};
+
+void detector_draw_one_result(DetResult *r, void *y_plane, int out_w, int out_h,
+                               int tile_x, int tile_y, int tile_w, int tile_h,
+                               int src_w, int src_h) {
+    if (!r || !y_plane) return;
+
+    int mw, mh;
+    mw = (tile_w > 0) ? tile_w : out_w;
+    mh = (tile_h > 0) ? tile_h : out_h;
+
+    float scale_x = (float)mw / src_w;
+    float scale_y = (float)mh / src_h;
+
+    int dx1 = tile_x + (int)(r->x1 * scale_x);
+    int dy1 = tile_y + (int)(r->y1 * scale_y);
+    int dx2 = tile_x + (int)(r->x2 * scale_x);
+    int dy2 = tile_y + (int)(r->y2 * scale_y);
+
+    if (dx1 < 0) dx1 = 0;
+    if (dy1 < 0) dy1 = 0;
+    if (dx2 >= out_w) dx2 = out_w - 1;
+    if (dy2 >= out_h) dy2 = out_h - 1;
+    if (dx1 >= dx2 || dy1 >= dy2) return;
+
+    uint8_t *y = (uint8_t *)y_plane;
+    uint8_t *uv = y + out_w * out_h;
+    int color_idx = r->label % 10;
+    uint8_t R = BOX_COLORS[color_idx][0];
+    uint8_t G = BOX_COLORS[color_idx][1];
+    uint8_t B = BOX_COLORS[color_idx][2];
+    uint8_t luma = (uint8_t)(0.299f * R + 0.587f * G + 0.114f * B);
+    uint8_t Cb = (uint8_t)(128.0f - 0.169f * R - 0.331f * G + 0.500f * B);
+    uint8_t Cr = (uint8_t)(128.0f + 0.500f * R - 0.419f * G - 0.081f * B);
+
+    for (int x = dx1; x <= dx2; x++) {
+        y[dy1 * out_w + x] = luma;
+        y[dy2 * out_w + x] = luma;
+    }
+    for (int yc = dy1; yc <= dy2; yc++) {
+        y[yc * out_w + dx1] = luma;
+        y[yc * out_w + dx2] = luma;
+    }
+
+    int uv_x1 = dx1 & ~1;
+    int uv_x2 = (dx2 + 1) & ~1;
+    int uv_y1 = dy1 & ~1;
+    int uv_y2 = (dy2 + 1) & ~1;
+    for (int ux = uv_x1; ux < uv_x2; ux += 2) {
+        uv[uv_y1 * out_w + ux] = Cb;
+        uv[uv_y1 * out_w + ux + 1] = Cr;
+        uv[uv_y2 * out_w + ux] = Cb;
+        uv[uv_y2 * out_w + ux + 1] = Cr;
+    }
+    for (int uy = uv_y1; uy <= uv_y2; uy += 2) {
+        uv[uy * out_w + uv_x1] = Cb;
+        uv[uy * out_w + uv_x1 + 1] = Cr;
+        if (uv_x2 < out_w) {
+            uv[uy * out_w + uv_x2] = Cb;
+            uv[uy * out_w + uv_x2 + 1] = Cr;
+        }
+    }
+}

+ 29 - 0
detector.h

@@ -0,0 +1,29 @@
+#ifndef __DETECTOR_H__
+#define __DETECTOR_H__
+
+#include <stdint.h>
+
+#define MAX_DETECTIONS 200
+
+typedef struct {
+    float x1, y1, x2, y2;
+    float score;
+    int label;
+} DetResult;
+
+typedef struct Detector Detector;
+
+Detector *detector_create(const char *model_path, float conf_thresh, float nms_thresh);
+void detector_destroy(Detector *det);
+
+int detector_get_input_width(Detector *det);
+int detector_get_input_height(Detector *det);
+
+int detector_detect(Detector *det, int src_nv12_fd, int src_w, int src_h,
+                    DetResult *results, int *num_results);
+
+void detector_draw_one_result(DetResult *r, void *y_plane, int out_w, int out_h,
+                              int tile_x, int tile_y, int tile_w, int tile_h,
+                              int src_w, int src_h);
+
+#endif

+ 626 - 0
display.c

@@ -0,0 +1,626 @@
+#include "display.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <sys/mman.h>
+#include <sys/ioctl.h>
+#include <poll.h>
+#include <xf86drm.h>
+#include <xf86drmMode.h>
+#include <drm_fourcc.h>
+#include <linux/dma-heap.h>
+#include <linux/dma-buf.h>
+
+#define MAX_NV12_FB 4
+
+struct Display {
+    char dev_path[64];
+    int fd;
+    drmModeConnector *connector;
+    drmModeEncoder *encoder;
+    drmModeCrtc *saved_crtc;
+    uint32_t crtc_id;
+    uint32_t connector_id;
+    int width;
+    int height;
+    /* The CRTC/mode size actually programmed on the connector. It can differ
+     * from width/height (the scanout buffer size) after a monitor hot-plug:
+     * the app allocates its output buffers once, so a rebuilt display may end
+     * up driving a mode that does not match them. The plane then scales the
+     * buffer into the mode instead of failing the whole scanout. */
+    int mode_w;
+    int mode_h;
+    int native_width;
+    int native_height;
+
+    /* Per-fd NV12 scanout FBs (the composer already produces NV12; we scan it
+     * straight out on the plane and skip RGA colour conversion entirely --
+     * NV12->BGRA via RGA is broken on this driver, so it must not be used). */
+    struct {
+        int key_fd;           /* the fd passed to display_commit */
+        int owned_fd;         /* dup'd fd keeping the dmabuf alive */
+        uint32_t handle;
+        uint32_t fb_id;
+        uint32_t stride;
+    } fbs[MAX_NV12_FB];
+    int nfbs;
+
+    uint32_t plane_id;
+    uint32_t crtc_mode_id;
+    uint32_t plane_fb_prop;
+    uint32_t plane_crtc_prop;
+    uint32_t plane_src_x_prop;
+    uint32_t plane_src_y_prop;
+    uint32_t plane_src_w_prop;
+    uint32_t plane_src_h_prop;
+    uint32_t plane_crtc_x_prop;
+    uint32_t plane_crtc_y_prop;
+    uint32_t plane_crtc_w_prop;
+    uint32_t plane_crtc_h_prop;
+    uint32_t conn_crtc_prop;
+    uint32_t crtc_active_prop;
+    uint32_t crtc_mode_id_prop;
+    uint32_t conn_color_format_prop;
+    uint32_t out_fence_prop;
+    int flip_pending;
+    int modeset_done;
+    drmEventContext drm_ctx;
+
+    /* Other planes sharing our CRTC (e.g. the desktop's RGB scans). They are
+     * disabled on every commit so their colours can't blend under our NV12
+     * overlay (which makes the wall look purple after a hotplug). */
+    uint32_t ext_ids[8];
+    uint32_t ext_fb_prop[8];
+    uint32_t ext_crtc_prop[8];
+    int ext_n;
+};
+
+/* Connector output format override for the first modeset commit.
+ * 0 = auto (leave the driver's own negotiation alone),
+ * 1 = force RGB, 2 = force YCbCr444, 3 = force YCbCr422, 4 = force YCbCr420.
+ * Forcing RGB makes the VOP take the window through a real Y2R CSC path
+ * instead of the YUV passthrough that comes out wrong after a hotplug. */
+static int g_fmt_override = 1;
+
+int display_set_color_format(int fmt) {
+    g_fmt_override = fmt;
+    return 0;
+}
+
+static int dma_heap_alloc(const char *heap_name, size_t size, int *fd) {
+    char path[64];
+    snprintf(path, sizeof(path), "/dev/dma_heap/%s", heap_name);
+    int heap_fd = open(path, O_RDWR | O_CLOEXEC);
+    if (heap_fd < 0) heap_fd = open("/dev/dma_heap/system-uncached", O_RDWR | O_CLOEXEC);
+    if (heap_fd < 0) return -1;
+    struct dma_heap_allocation_data data = { .len = size, .fd_flags = O_RDWR | O_CLOEXEC };
+    int ret = ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &data);
+    close(heap_fd);
+    if (ret < 0) return -1;
+    *fd = data.fd;
+    return 0;
+}
+
+static uint32_t find_prop_id(int fd, uint32_t obj_id, uint32_t obj_type, const char *name) {
+    drmModeObjectProperties *props = drmModeObjectGetProperties(fd, obj_id, obj_type);
+    if (!props) return 0;
+    uint32_t id = 0;
+    for (uint32_t i = 0; i < props->count_props; i++) {
+        drmModePropertyRes *prop = drmModeGetProperty(fd, props->props[i]);
+        if (prop) {
+            if (strcmp(prop->name, name) == 0) id = prop->prop_id;
+            drmModeFreeProperty(prop);
+            if (id) break;
+        }
+    }
+    drmModeFreeObjectProperties(props);
+    return id;
+}
+
+static int conn_allowed(int type) {
+    const char *only = getenv("RTSP_CONN_ONLY");
+    if (only && strcmp(only, "ALL") == 0) return 1; /* legacy: accept any connected output */
+    /* Default (and RTSP_CONN_ONLY=HDMI): drive the real HDMI monitor only.
+     * These boards report a phantom DSI connector as always-connected even
+     * with no panel attached; handing the output to it would leave the real
+     * HDMI monitor showing just the desktop. */
+    return type == DRM_MODE_CONNECTOR_HDMIA || type == DRM_MODE_CONNECTOR_HDMIB;
+}
+
+static int drm_get_connector(Display *disp) {
+    drmModeRes *res = drmModeGetResources(disp->fd);
+    if (!res) { fprintf(stderr, "drmModeGetResources failed\n"); return -1; }
+    const char *only = getenv("RTSP_CONN_ONLY");
+
+    for (int i = 0; i < res->count_connectors; i++) {
+        drmModeConnector *conn = drmModeGetConnector(disp->fd, res->connectors[i]);
+        if (!conn) continue;
+        if (conn_allowed(conn->connector_type) &&
+            conn->connection == DRM_MODE_CONNECTED && conn->count_modes > 0) {
+            disp->connector = conn;
+            break;
+        }
+        drmModeFreeConnector(conn);
+    }
+
+    if (!disp->connector) {
+        /* No connected output. Only fall back to another connector if it
+         * actually has modes; a mode-less (unplugged) connector cannot drive
+         * the CRTC and its modes[0] would be read out of bounds below. We fail
+         * so the caller can retry until a monitor is plugged in. */
+        for (int i = 0; res && i < res->count_connectors && !disp->connector; i++) {
+            drmModeConnector *c = drmModeGetConnector(disp->fd, res->connectors[i]);
+            if (c && conn_allowed(c->connector_type) && c->count_modes > 0)
+                disp->connector = c;
+            else if (c) drmModeFreeConnector(c);
+        }
+    }
+    if (!disp->connector || disp->connector->count_modes == 0) {
+        if (disp->connector) {
+            drmModeFreeConnector(disp->connector);
+            disp->connector = NULL;
+        }
+        fprintf(stderr, "display: no monitor with an active mode (unplugged during boot%s%s); "
+                        "will retry until one is connected\n",
+                only ? "; RTSP_CONN_ONLY=" : "", only ? only : "");
+        drmModeFreeResources(res);
+        return -1;
+    }
+
+    disp->connector_id = disp->connector->connector_id;
+    disp->native_width = disp->connector->modes[0].hdisplay;
+    disp->native_height = disp->connector->modes[0].vdisplay;
+
+    if (disp->connector->encoder_id) {
+        disp->encoder = drmModeGetEncoder(disp->fd, disp->connector->encoder_id);
+        if (disp->encoder) disp->crtc_id = disp->encoder->crtc_id;
+    }
+    if (!disp->encoder) {
+        for (int i = 0; i < res->count_encoders; i++) {
+            drmModeEncoder *enc = drmModeGetEncoder(disp->fd, res->encoders[i]);
+            if (!enc) continue;
+            if (enc->encoder_id == disp->connector->encoder_id ||
+                (enc->crtc_id > 0 && !disp->encoder)) {
+                if (!disp->encoder) { disp->encoder = enc; disp->crtc_id = enc->crtc_id; }
+                else drmModeFreeEncoder(enc);
+            } else drmModeFreeEncoder(enc);
+        }
+    }
+    if (disp->crtc_id == 0 && res->count_crtcs > 0)
+        disp->crtc_id = res->crtcs[0];
+
+    drmModeFreeResources(res);
+    return (disp->crtc_id != 0) ? 0 : -1;
+}
+
+static void page_flip_handler(int fd, unsigned int frame, unsigned int sec,
+                              unsigned int usec, void *data) {
+    (void)fd; (void)frame; (void)sec; (void)usec;
+    Display *disp = data;
+    disp->flip_pending = 0;
+}
+
+static int wait_prev_flip(Display *disp, int timeout_ms) {
+    if (!disp->flip_pending) return 0;
+    while (disp->flip_pending) {
+        struct pollfd pfd = { .fd = disp->fd, .events = POLLIN };
+        int pret = poll(&pfd, 1, timeout_ms);
+        if (pret > 0 && (pfd.revents & POLLIN))
+            drmHandleEvent(disp->fd, &disp->drm_ctx);
+        else
+            return -1;
+    }
+    return 0;
+}
+
+Display *display_create(const char *drm_dev) {
+    Display *disp = calloc(1, sizeof(Display));
+    if (!disp) return NULL;
+    strncpy(disp->dev_path, drm_dev ? drm_dev : "/dev/dri/card0", sizeof(disp->dev_path) - 1);
+    disp->fd = open(disp->dev_path, O_RDWR | O_CLOEXEC);
+    if (disp->fd < 0) { free(disp); return NULL; }
+    for (int i = 0; i < MAX_NV12_FB; i++) { disp->fbs[i].key_fd = -1; disp->fbs[i].owned_fd = -1; }
+    return disp;
+}
+
+void display_destroy(Display *disp) {
+    if (!disp) return;
+    if (disp->saved_crtc) {
+        drmModeSetCrtc(disp->fd, disp->saved_crtc->crtc_id,
+                       disp->saved_crtc->buffer_id,
+                       disp->saved_crtc->x, disp->saved_crtc->y,
+                       &disp->connector_id, 1, &disp->saved_crtc->mode);
+        drmModeFreeCrtc(disp->saved_crtc);
+    }
+    for (int i = 0; i < disp->nfbs; i++) {
+        if (disp->fbs[i].fb_id) drmModeRmFB(disp->fd, disp->fbs[i].fb_id);
+        if (disp->fbs[i].owned_fd >= 0) close(disp->fbs[i].owned_fd);
+    }
+    if (disp->encoder) drmModeFreeEncoder(disp->encoder);
+    if (disp->connector) drmModeFreeConnector(disp->connector);
+    if (disp->crtc_mode_id) drmModeDestroyPropertyBlob(disp->fd, disp->crtc_mode_id);
+    if (disp->fd >= 0) {
+        drmDropMaster(disp->fd);
+        close(disp->fd);
+    }
+    free(disp);
+}
+
+static int setup_plane(Display *disp) {
+    drmModePlaneRes *pres = drmModeGetPlaneResources(disp->fd);
+    if (!pres) return -1;
+
+    drmModeRes *res = drmModeGetResources(disp->fd);
+    int crtc_index = -1;
+    if (res) {
+        for (int i = 0; i < res->count_crtcs; i++) {
+            if (res->crtcs[i] == disp->crtc_id) { crtc_index = i; break; }
+        }
+        drmModeFreeResources(res);
+    }
+    if (crtc_index < 0) {
+        fprintf(stderr, "display: CRTC %u not found\n", disp->crtc_id);
+        drmModeFreePlaneResources(pres);
+        return -1;
+    }
+
+    /* On this SoC the primary plane only scans RGB frames, while NV12 is only
+     * supported by an overlay plane. We scan the composer's NV12 output
+     * straight out on whichever plane advertises NV12 (preferred), falling back
+     * to any primary plane otherwise. This avoids an NV12->BGRA RGA conversion
+     * that is unsupported on this driver. */
+    drmModePlane *chosen = NULL;
+    drmModePlane *fallback = NULL;
+    for (uint32_t i = 0; i < pres->count_planes; i++) {
+        drmModePlane *plane = drmModeGetPlane(disp->fd, pres->planes[i]);
+        if (!plane) continue;
+        if (!(plane->possible_crtcs & (1u << crtc_index))) { drmModeFreePlane(plane); continue; }
+
+        uint32_t type_id = find_prop_id(disp->fd, plane->plane_id,
+                                          DRM_MODE_OBJECT_PLANE, "type");
+        if (!type_id) { drmModeFreePlane(plane); continue; }
+        drmModeObjectProperties *props = drmModeObjectGetProperties(disp->fd,
+            plane->plane_id, DRM_MODE_OBJECT_PLANE);
+        if (!props) { drmModeFreePlane(plane); continue; }
+        uint64_t type_val = 0;
+        for (uint32_t p = 0; p < props->count_props; p++)
+            if (props->props[p] == type_id) type_val = props->prop_values[p];
+        drmModeFreeObjectProperties(props);
+
+        int has_nv12 = 0;
+        for (uint32_t f = 0; f < plane->count_formats; f++)
+            if (plane->formats[f] == DRM_FORMAT_NV12) { has_nv12 = 1; break; }
+
+        if (has_nv12) { chosen = plane; break; }
+        if (type_val == DRM_PLANE_TYPE_PRIMARY && !fallback) fallback = plane;
+        else drmModeFreePlane(plane);
+    }
+    if (!chosen) chosen = fallback;
+    if (!chosen) {
+        drmModeFreePlaneResources(pres);
+        fprintf(stderr, "display: no usable plane found for crtc %u\n", disp->crtc_id);
+        return -1;
+    }
+
+    disp->plane_id = chosen->plane_id;
+    disp->plane_fb_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "FB_ID");
+    disp->plane_crtc_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "CRTC_ID");
+    disp->plane_src_x_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "SRC_X");
+    disp->plane_src_y_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "SRC_Y");
+    disp->plane_src_w_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "SRC_W");
+    disp->plane_src_h_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "SRC_H");
+    disp->plane_crtc_x_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "CRTC_X");
+    disp->plane_crtc_y_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "CRTC_Y");
+    disp->plane_crtc_w_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "CRTC_W");
+    disp->plane_crtc_h_prop = find_prop_id(disp->fd, chosen->plane_id,
+        DRM_MODE_OBJECT_PLANE, "CRTC_H");
+    fprintf(stderr, "display: using plane %u (NV12 scanout)\n", chosen->plane_id);
+    drmModeFreePlane(chosen);
+    if (fallback && fallback != chosen) drmModeFreePlane(fallback);
+    drmModeFreePlaneResources(pres);
+    return 0;
+}
+
+static int build_plane_commit(Display *disp, drmModeAtomicReq *req, uint32_t fb_id) {
+    int props = 0;
+    props += (disp->plane_fb_prop ?
+              (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_fb_prop, fb_id) >= 0) : 0);
+    props += (disp->plane_crtc_prop ?
+              (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_crtc_prop, disp->crtc_id) >= 0) : 0);
+    props += (disp->plane_src_x_prop ?
+              (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_src_x_prop, (uint64_t)0) >= 0) : 0);
+    props += (disp->plane_src_y_prop ?
+              (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_src_y_prop, (uint64_t)0) >= 0) : 0);
+    if (disp->plane_src_w_prop)
+        props += (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_src_w_prop,
+                   (uint64_t)disp->width << 16) >= 0);
+    if (disp->plane_src_h_prop)
+        props += (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_src_h_prop,
+                   (uint64_t)disp->height << 16) >= 0);
+    if (disp->plane_crtc_x_prop)
+        props += (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_crtc_x_prop, (uint64_t)0) >= 0);
+    if (disp->plane_crtc_y_prop)
+        props += (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_crtc_y_prop, (uint64_t)0) >= 0);
+    if (disp->plane_crtc_w_prop)
+        props += (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_crtc_w_prop, (uint64_t)disp->mode_w) >= 0);
+    if (disp->plane_crtc_h_prop)
+        props += (drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_crtc_h_prop, (uint64_t)disp->mode_h) >= 0);
+    return props;
+}
+
+static void display_reset_output(Display *disp);
+
+/* Cache every OTHER plane that can target our CRTC (Xorg/desktop scans).
+ * They must stay disabled while we render, or their RGB content blends under
+ * our NV12 plane and tints the whole output. */
+static void cache_ext_planes(Display *disp) {
+    disp->ext_n = 0;
+    drmModePlaneRes *pres = drmModeGetPlaneResources(disp->fd);
+    if (!pres) return;
+    drmModeRes *res = drmModeGetResources(disp->fd);
+    int crtc_index = -1;
+    if (res) {
+        for (int i = 0; i < res->count_crtcs; i++)
+            if (res->crtcs[i] == disp->crtc_id) { crtc_index = i; break; }
+        drmModeFreeResources(res);
+    }
+    if (crtc_index < 0) { drmModeFreePlaneResources(pres); return; }
+    for (uint32_t i = 0; i < pres->count_planes && disp->ext_n < 8; i++) {
+        drmModePlane *pl = drmModeGetPlane(disp->fd, pres->planes[i]);
+        if (!pl) continue;
+        if (pl->plane_id != disp->plane_id &&
+            (pl->possible_crtcs & (1u << crtc_index))) {
+            uint32_t idx = disp->ext_n;
+            disp->ext_ids[idx] = pl->plane_id;
+            disp->ext_fb_prop[idx] = find_prop_id(disp->fd, pl->plane_id,
+                DRM_MODE_OBJECT_PLANE, "FB_ID");
+            disp->ext_crtc_prop[idx] = find_prop_id(disp->fd, pl->plane_id,
+                DRM_MODE_OBJECT_PLANE, "CRTC_ID");
+            if (disp->ext_fb_prop[idx] || disp->ext_crtc_prop[idx])
+                disp->ext_n++;
+        }
+        drmModeFreePlane(pl);
+    }
+    drmModeFreePlaneResources(pres);
+}
+
+static void add_ext_planes_off(Display *disp, drmModeAtomicReq *req) {
+    for (int i = 0; i < disp->ext_n; i++) {
+        if (disp->ext_fb_prop[i])
+            drmModeAtomicAddProperty(req, disp->ext_ids[i], disp->ext_fb_prop[i], 0);
+        if (disp->ext_crtc_prop[i])
+            drmModeAtomicAddProperty(req, disp->ext_ids[i], disp->ext_crtc_prop[i], 0);
+    }
+}
+
+int display_init(Display *disp, int width, int height) {
+    if (!disp || disp->fd < 0) return -1;
+
+    if (drmSetMaster(disp->fd))
+        fprintf(stderr, "WARN: drmSetMaster: %s\n", strerror(errno));
+    drmSetClientCap(disp->fd, DRM_CLIENT_CAP_UNIVERSAL_PLANES, 1);
+    drmSetClientCap(disp->fd, DRM_CLIENT_CAP_ATOMIC, 1);
+
+    if (drm_get_connector(disp) < 0) return -1;
+
+    /* Choose the CRTC mode. When the caller already owns scanout buffers of a
+     * given size (the app allocates them once at start-up), prefer a connector
+     * mode that matches them exactly so the plane never has to scale. Fall
+     * back to the connector's preferred mode otherwise. */
+    drmModeModeInfo *mode = &disp->connector->modes[0];
+    if (width > 0 && height > 0) {
+        for (int i = 0; i < disp->connector->count_modes; i++) {
+            drmModeModeInfo *m = &disp->connector->modes[i];
+            if (m->hdisplay == width && m->vdisplay == height) { mode = m; break; }
+        }
+    }
+    disp->mode_w = mode->hdisplay;
+    disp->mode_h = mode->vdisplay;
+    /* width/height stay the size of the frames that will be committed, so the
+     * NV12 fb is always registered with the buffer's real geometry. */
+    disp->width  = (width  > 0) ? width  : disp->mode_w;
+    disp->height = (height > 0) ? height : disp->mode_h;
+
+    disp->saved_crtc = drmModeGetCrtc(disp->fd, disp->crtc_id);
+    if (setup_plane(disp) < 0) return -1;
+    cache_ext_planes(disp);
+
+    disp->conn_crtc_prop = find_prop_id(disp->fd, disp->connector_id,
+        DRM_MODE_OBJECT_CONNECTOR, "CRTC_ID");
+    disp->conn_color_format_prop = find_prop_id(disp->fd, disp->connector_id,
+        DRM_MODE_OBJECT_CONNECTOR, "color_format");
+    disp->crtc_active_prop = find_prop_id(disp->fd, disp->crtc_id,
+        DRM_MODE_OBJECT_CRTC, "ACTIVE");
+    disp->crtc_mode_id_prop = find_prop_id(disp->fd, disp->crtc_id,
+        DRM_MODE_OBJECT_CRTC, "MODE_ID");
+    disp->out_fence_prop = find_prop_id(disp->fd, disp->crtc_id,
+        DRM_MODE_OBJECT_CRTC, "OUT_FENCE_PTR");
+
+    if (drmModeCreatePropertyBlob(disp->fd, mode, sizeof(*mode), &disp->crtc_mode_id)) {
+        fprintf(stderr, "drmModeCreatePropertyBlob failed: %s\n", strerror(errno));
+        return -1;
+    }
+
+    disp->flip_pending = 0;
+    memset(&disp->drm_ctx, 0, sizeof(disp->drm_ctx));
+    disp->drm_ctx.version = DRM_EVENT_CONTEXT_VERSION;
+    disp->drm_ctx.page_flip_handler = page_flip_handler;
+
+    /* The CRTC is activated on the first display_commit (which supplies the
+     * NV12 buffer). No RGA / colour conversion is used anywhere in display. */
+    display_reset_output(disp);
+    fprintf(stderr, "display: init OK buffer %dx%d mode %dx%d crtc=%u plane=%u%s\n",
+            disp->width, disp->height, disp->mode_w, disp->mode_h,
+            disp->crtc_id, disp->plane_id,
+            (disp->width != disp->mode_w || disp->height != disp->mode_h)
+                ? " (buffer != mode, plane will scale)" : "");
+    return 0;
+}
+
+/* Add (or find) an NV12 scanout FB for this fd. Returns fb_id or 0. */
+static uint32_t get_nv12_fb(Display *disp, int fd, int stride) {
+    for (int i = 0; i < disp->nfbs; i++)
+        if (disp->fbs[i].key_fd == fd) return disp->fbs[i].fb_id;
+
+    uint32_t handle = 0;
+    if (drmPrimeFDToHandle(disp->fd, fd, &handle)) {
+        fprintf(stderr, "display: drmPrimeFDToHandle(nv12) failed: %s\n", strerror(errno));
+        return 0;
+    }
+    uint32_t handles[4] = {handle, handle, 0, 0};
+    uint32_t pitches[4] = {(uint32_t)stride, (uint32_t)stride, 0, 0};
+    uint32_t offsets[4] = {0, (uint32_t)stride * disp->height, 0, 0};
+    uint32_t fb_id = 0;
+    if (drmModeAddFB2(disp->fd, disp->width, disp->height, DRM_FORMAT_NV12,
+                      handles, pitches, offsets, &fb_id, 0)) {
+        fprintf(stderr, "display: drmModeAddFB2(NV12) failed: %s\n", strerror(errno));
+        return 0;
+    }
+
+    int swap_idx = -1;
+    if (disp->nfbs < MAX_NV12_FB) {
+        swap_idx = disp->nfbs;
+        disp->nfbs++;
+    } else {
+        /* table full: drop the last entry to make room (free its fb/handle) */
+        swap_idx = disp->nfbs - 1;
+        drmModeRmFB(disp->fd, disp->fbs[swap_idx].fb_id);
+        if (disp->fbs[swap_idx].owned_fd >= 0) close(disp->fbs[swap_idx].owned_fd);
+    }
+    int owned = fcntl(fd, F_DUPFD_CLOEXEC, 3);
+    disp->fbs[swap_idx].key_fd = fd;
+    disp->fbs[swap_idx].owned_fd = owned;
+    disp->fbs[swap_idx].handle = handle;
+    disp->fbs[swap_idx].fb_id = fb_id;
+    disp->fbs[swap_idx].stride = (uint32_t)stride;
+    return fb_id;
+}
+
+int display_signal_lost(Display *disp) {
+    if (!disp || disp->fd < 0) return 1;
+    drmModeConnector *conn = drmModeGetConnector(disp->fd, disp->connector_id);
+    if (!conn) return 1;
+    int lost = !(conn->connection == DRM_MODE_CONNECTED && conn->count_modes > 0);
+    drmModeFreeConnector(conn);
+    return lost;
+}
+
+static void display_reset_output(Display *disp) {
+    if (!disp || disp->fd < 0) return;
+    drmModeAtomicReq *req = drmModeAtomicAlloc();
+    if (!req) return;
+    if (disp->plane_fb_prop)
+        drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_fb_prop, 0);
+    if (disp->plane_crtc_prop)
+        drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_crtc_prop, 0);
+    add_ext_planes_off(disp, req);
+    if (disp->crtc_active_prop)
+        drmModeAtomicAddProperty(req, disp->crtc_id, disp->crtc_active_prop, 0);
+    if (disp->conn_crtc_prop)
+        drmModeAtomicAddProperty(req, disp->connector_id, disp->conn_crtc_prop, 0);
+    drmModeAtomicCommit(disp->fd, req,
+                        DRM_MODE_ATOMIC_ALLOW_MODESET | DRM_MODE_ATOMIC_NONBLOCK,
+                        NULL);
+    drmModeAtomicFree(req);
+}
+
+int display_commit(Display *disp, int nv12_fd, int nv12_stride) {
+    if (!disp || disp->fd < 0 || nv12_fd < 0) return -1;
+
+    if (wait_prev_flip(disp, 300) < 0) {
+        fprintf(stderr, "display: previous flip not completed, dropping frame\n");
+        return -1;
+    }
+
+    uint32_t fb_id = get_nv12_fb(disp, nv12_fd, nv12_stride);
+    if (!fb_id) return -1;
+
+    drmModeAtomicReq *req = drmModeAtomicAlloc();
+    if (!req) return -1;
+
+    int is_first = !disp->modeset_done;
+    if (is_first) {
+        /* First commit: enable connector + CRTC modeset together with the
+         * plane so VOP shows the first NV12 frame immediately. */
+        if (disp->conn_crtc_prop)
+            drmModeAtomicAddProperty(req, disp->connector_id, disp->conn_crtc_prop, disp->crtc_id);
+        if (g_fmt_override > 0 && disp->conn_color_format_prop)
+            drmModeAtomicAddProperty(req, disp->connector_id,
+                                     disp->conn_color_format_prop,
+                                     (uint64_t)(g_fmt_override - 1));
+        if (disp->crtc_active_prop)
+            drmModeAtomicAddProperty(req, disp->crtc_id, disp->crtc_active_prop, 1);
+        if (disp->crtc_mode_id_prop)
+            drmModeAtomicAddProperty(req, disp->crtc_id, disp->crtc_mode_id_prop, disp->crtc_mode_id);
+        build_plane_commit(disp, req, fb_id);
+        add_ext_planes_off(disp, req);
+    } else {
+        /* Page flip: just swap the plane FB. */
+        if (disp->plane_fb_prop)
+            drmModeAtomicAddProperty(req, disp->plane_id, disp->plane_fb_prop, fb_id);
+        add_ext_planes_off(disp, req);
+    }
+
+    int out_fence_fd = -1;
+    if (disp->out_fence_prop && !is_first) {
+        uint64_t fence_ptr = (uint64_t)(unsigned long)&out_fence_fd;
+        drmModeAtomicAddProperty(req, disp->crtc_id, disp->out_fence_prop, fence_ptr);
+    }
+
+    int ret;
+    uint32_t flags = DRM_MODE_PAGE_FLIP_EVENT | (is_first ? DRM_MODE_ATOMIC_ALLOW_MODESET : 0);
+
+    int retries = 5;
+    do {
+        ret = drmModeAtomicCommit(disp->fd, req, flags, disp);
+        if (ret == 0) break;
+        if (errno == EBUSY) { usleep(16000); continue; }
+        /* A stale state after hot-plug can make a plain page flip EINVAL;
+         * schedule the full reset+modeset on the next frame. */
+        if (!is_first) disp->modeset_done = 0;
+        fprintf(stderr, "display: AtomicCommit failed: %s\n", strerror(errno));
+        if (out_fence_fd >= 0) close(out_fence_fd);
+        drmModeAtomicFree(req);
+        return -1;
+    } while (--retries > 0);
+
+    if (ret) {
+        fprintf(stderr, "display: AtomicCommit failed after %d retries\n", retries);
+        if (out_fence_fd >= 0) close(out_fence_fd);
+        drmModeAtomicFree(req);
+        return -1;
+    }
+    drmModeAtomicFree(req);
+    disp->modeset_done = 1;
+
+    disp->flip_pending = 1;
+    int confirmed = 0;
+    if (out_fence_fd >= 0) {
+        struct pollfd pfd = { .fd = out_fence_fd, .events = POLLIN };
+        int pret = poll(&pfd, 1, 300);
+        if (pret > 0 && (pfd.revents & POLLIN)) confirmed = 1;
+        close(out_fence_fd);
+    }
+    if (!confirmed)
+        confirmed = (wait_prev_flip(disp, 300) == 0);
+    if (!confirmed) {
+        fprintf(stderr, "display: flip not confirmed at vblank, frame status unknown\n");
+        return -1;
+    }
+    return 0;
+}
+
+int display_get_width(Display *disp) { return disp->width; }
+int display_get_height(Display *disp) { return disp->height; }

+ 26 - 0
display.h

@@ -0,0 +1,26 @@
+#ifndef __DISPLAY_H__
+#define __DISPLAY_H__
+
+#include <stdint.h>
+
+typedef struct Display Display;
+
+Display *display_create(const char *drm_dev);
+void display_destroy(Display *disp);
+
+int display_init(Display *disp, int width, int height);
+int display_commit(Display *disp, int fd, int stride);
+
+/* Force the connector output color format used at the next first modeset.
+ * 0=auto, 1=RGB, 2=YCbCr444, 3=YCbCr422, 4=YCbCr420. */
+int display_set_color_format(int fmt);
+
+int display_get_width(Display *disp);
+int display_get_height(Display *disp);
+
+/* Poll the DRM connector state. Returns 1 when the monitor is gone (signal
+ * lost, e.g. an HDMI switch pointing elsewhere), 0 while connected, -1 on
+ * error. Cheap (one GetConnector), safe to call each frame. */
+int display_signal_lost(Display *disp);
+
+#endif

+ 39 - 0
dma_util.c

@@ -0,0 +1,39 @@
+#include "dma_util.h"
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/ioctl.h>
+#include <linux/dma-heap.h>
+
+int dma_heap_alloc(const char *heap_name, size_t size, int *fd) {
+    /* Try the requested heap first, then fall back to cma then to
+     * system-uncached so existing callers (decoder "cma", detector
+     * "reserved") keep working. Callers that must have a <4G buffer (output
+     * fds and the composer mirror) pass "system-uncached-dma32", whose pages
+     * are guaranteed below 4G. The plain system-uncached heap can hand out
+     * pages above 4G, which the RGA driver refuses to import. */
+    const char *tries[3];
+    int n = 0;
+    if (heap_name && heap_name[0]) tries[n++] = heap_name;
+    if (n == 0 || strcmp(tries[0], "cma") != 0) tries[n++] = "cma";
+    if (n == 0 || strcmp(tries[0], "system-uncached") != 0) tries[n++] = "system-uncached";
+
+    for (int t = 0; t < n; t++) {
+        char path[64];
+        snprintf(path, sizeof(path), "/dev/dma_heap/%s", tries[t]);
+        int heap_fd = open(path, O_RDWR | O_CLOEXEC);
+        if (heap_fd < 0) continue;
+        struct dma_heap_allocation_data data = {
+            .len = size,
+            .fd_flags = O_RDWR | O_CLOEXEC,
+        };
+        int ret = ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &data);
+        close(heap_fd);
+        if (ret < 0) continue;
+        *fd = (int)data.fd;
+        return 0;
+    }
+    return -1;
+}

+ 21 - 0
dma_util.h

@@ -0,0 +1,21 @@
+#ifndef __DMA_UTIL_H__
+#define __DMA_UTIL_H__
+
+#include <stddef.h>
+#include <stdint.h>
+#include <sys/ioctl.h>
+#include <linux/dma-buf.h>
+
+/* Allocate one dma-buf from /dev/dma_heap/<heap_name>, falling back to
+ * "system-uncached" when the named heap does not exist. Returns 0 and sets
+ * *fd on success, -1 on failure. Shared by main/decoder/display/detector. */
+int dma_heap_alloc(const char *heap_name, size_t size, int *fd);
+
+/* Convenience wrapper for the DMA_BUF_IOCTL_SYNC START/END handshake,
+ * e.g. dmabuf_sync(fd, DMA_BUF_SYNC_START | DMA_BUF_SYNC_WRITE). */
+static inline void dmabuf_sync(int fd, int flags) {
+    struct dma_buf_sync s = { .flags = (uint64_t)flags };
+    ioctl(fd, DMA_BUF_IOCTL_SYNC, &s);
+}
+
+#endif

+ 115 - 0
docs/开发历程.md

@@ -0,0 +1,115 @@
+# 开发历程与版本沿革
+
+> 依据 opencode 会话记录(12 个会话、3124 条消息、2026-08-11 → 2026-10-22)、
+> 原始工作目录 `/root/hd-ws`(rtsp / rtsp588 / rtsp568)以及 deploy 目录下的
+> 18 个部署包整理。
+
+## 1. 项目背景与目录沿革
+
+| 目录 | 说明 | 时间 |
+|---|---|---|
+| `/root/hd-ws/rtsp` | 最早版本(RK3588 平台原版拷贝) | 2026-08 前 |
+| `/root/hd-ws/rtsp588` | 中间快照(RK3588 平台) | 2026-08 前 |
+| `/root/hd-ws/rtsp568` | **转 RK3568 后的正式版本**(8-11 定为正式版) | 2026-08-11 |
+| `/root/layoutx` | 2026-09-04 迁移并修复后的现行开发目录 | 2026-09-04 起 |
+
+项目:**多路 RTSP 视频流多画面合成显示系统**,运行于 Orange Pi / Rockchip SoC
+(RK3568),配合西门子 PLC 点位信号自动切换布局(车间随动监控)。
+
+## 2. 开发时间线
+
+### 阶段一 2026-08-11 ~ 08-14(hd-ws/rtsp568):RK3588 转 RK3568 与功能重构
+- RK3588 → RK3568 平台迁移:编译、依赖、字体缺失检查与修复
+- 移除推理(YOLO/检测器)相关无用功能与资源占用
+- 单通道 layout 自动使用主码流逻辑(原配置全为子码流)
+- 摄像头离线处理:不再卡死最后一帧 + 自动重连
+- PLC 离线右下角红字告警(出现"画面闪"问题后暂撤)
+- run_local 本地测试安装脚本;进程残余/CPU/闪/tearing 排查与修复
+- 布局系统大改造:多行自适应布局(第一行 N 个、第二行 M 个、按比例占高)、
+  大/小画面组合(layout0-4:1~3 个大画面 + 其余小画面)、字符占位配置、
+  总高度撑满、宽高比不变形
+- 网格线叠加
+- 海康 ISAPI 批量配置摄像头主/子码流(channels_isapi.conf)
+- 画面撕裂(tearing)修复
+
+### 阶段二 2026-08-25:管线优化调研(只读分析)
+- 视频管线核心模块(decoder/composer/display)与应用层审计,输出优化点清单
+
+### 阶段三 2026-09-04 ~ 09-05(layoutx 新目录):部署化准备
+- 目录迁移 `/root/hd-ws/rtsp568` → `/root/layoutx`,build.sh 修复、花屏修复
+- plc_server.sh 改为前台运行,Ctrl+C 可完全退出全部服务
+- 新增 `deploy/` 与 `pack.sh` 一键生成部署包(首次打包 20260904)
+- PLC DB 块地址解析与 plc_sim 同步修正
+- 自动重启设备功能(日/周周期 + 重启时间,channels.conf 可配)
+- 一键部署到新主机脚本(默认 192.168.1.243 / root / 123456)+ README 更新
+
+### 阶段四 2026-09-05 ~ 10-13:部署实战与网页配置
+- 离线依赖问题暴露(现场板子不便联网),依赖安装方案调整
+- 部署后标题 logo 缺失修复、PLC 未监听 0.0.0.0 修复
+- 上下分割布局落地:第一行 30%(5 路小画面)/第二行 70%(1~3 路,随 PLC 切换,
+  按宽高比居中放置)
+- PLC 规则语义重写(最具体者胜):点位组的"任一触发/全部触发"规则、
+  无匹配时回退默认布局、恢复后回到默认
+- web 配置页面(端口 8080,密码 3730):PLC 地址、摄像头地址、layout、
+  tsap 参数可视化修改;中文页面;保存格式(等号空格/多余键)修复
+- 断线重连机制完善(摄像头掉线/PLC 离线再上线)
+- 现场根目录写满事件(kern.log/syslog 各 12G):日志清理与治理结论
+- 现场闪屏根治:RGA "MMU unsupported memory larger than 4G" → 输出缓冲改用
+  `system-uncached-dma32` 优先分配(其次 cma,再普通内存)
+- 无显示器开机只显示桌面修复:display_init 参与等待显示器重试,
+  HDMI 分配器/录像机切换场景排查,`display: AtomicCommit EINVAL` 修复
+- `--hdmi` 参数调研后取消,start.sh 还原,不改现场 service
+- 紫色画面问题:HDMI RGB 输出 `color_format` 代码硬编码 `display_set_color_format(1)`
+
+### 阶段五 2026-10-13 ~ 10-22:生产板部署与稳定性
+- **部署到生产板 216**:标题栏字体问题修复、TSAP(西门子 1200)、
+  PLC 连接状态角标(右上角绿/红)、网页可配 tsap
+- **216 / 217 双板部署**:完全离线安装(install.sh 依赖自检 ldd+gst-inspect)
+- **布局防抖**:`plc_debounce_ms`(默认 300ms,连续稳定才切换),
+  修复"点位瞬间触发闪切";网页可配防抖参数
+- **网页添加多网段 IP 改走 NetworkManager**(nmcli con mod + reapply,
+  持久化到 /etc/NetworkManager/system-connections/),
+  替换 ifupdown 旧路径(/etc/network/interfaces)
+- **systemctl stop 卡 90 秒修复**:display_init 等显示器循环增加 `g_running`
+  检查,SIGTERM 及时退出(实测 90s → 363ms)
+- **安装包内置静态 IP 步骤**:给 eth0 追加 `192.168.100.245/24`(保留 DHCP)
+- **画面左下角 IP 角标**:显示 eth0 全部 IPv4(每秒刷新),
+  忘记 IP 时直接从画面读出
+- **拔插显示器死机修复(核心修复)**:根因=启动时按当时显示器模式分配的
+  NV12 缓冲区(如 1280x720)在拔插后显示器改为首选模式(1920x1080),
+  `drmModeAddFB2(NV12)` 用模式尺寸+缓冲区 stride → EINVAL 死循环;
+  修复:重建时优先选与缓冲区匹配的模式 + plane SRC/CRTC 几何分离(支持缩放)
+- 换新显示器仍需重新上电才出原生分辨率(缓冲区只建一次,用户决定保持现状)
+
+## 3. 部署包(平台版本)沿革
+
+deploy/ 下共 18 个 tar.gz(20260904 → 20261020),关键版本:
+
+| 部署包时间戳 | 二进制 md5 | 主要变化 |
+|---|---|---|
+| 20260904_092418 / 093406 | - | 首批打包(部署化试行) |
+| 20260905_003525 / 20260909_045758 | - | 部署脚本调整 |
+| 20261013_080759 / 084753 | - | ⚠️ 已弃用(紫色画面修复前) |
+| 20261013_085429 ~ 092459 | - | color_format 硬编码、生产板初部署 |
+| 20261014_022843 | 69ad… | ⚠️ 已弃用 |
+| 20261014_025142 | ce428058 | 布局防抖 |
+| 20261014_030341 | 7b8460d6 | + 网页防抖参数 |
+| 20261014_032433 | 0d8e313f | + 网页加 IP 走 NetworkManager |
+| 20261014_034552 | 5785af80 | + systemctl stop 秒退修复 |
+| 20261014_062749 | d2b642cc | + 画面 IP 角标(217 现仍是此版) |
+| **20261020_010600** | **63252ff9** | **+ 拔插显示器分辨率自适应(当前最新)** |
+
+## 4. 当前版本状态(2026-10-22)
+
+- 开发机(232)编译产物:`63252ff9afca147bd89edd1c3907280d`
+- 生产板 216:`63252ff9…`(最新,含拔插修复)
+- 生产板 217:`d2b642cc…`(缺拔插修复,离线待更新)
+- 现场约定:换不同分辨率显示器后**重新上电**即可出原生分辨率
+
+## 5. git 版本管理
+
+- 仓库位置:`/root/layoutx`(自 2026-10-22 起)
+- 忽略项(.gitignore):build/、build_asan/、deploy/*.tar.gz、models/(143M 权重文件,
+  教程见 convert_yolov8_rknn.py;推理已移除)、plc_sim/capture_fb 二进制、运行日志
+- ⚠️ channels.conf* 内含摄像头验证账号(URL 中)与网页密码,仓库仅限内网/离线保存
+- 原始历史(/root/hd-ws 三个目录)保持原样不纳入 git,如需可作为分支导入

+ 157 - 0
flog.c

@@ -0,0 +1,157 @@
+#include "flog.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdarg.h>
+#include <time.h>
+#include <sys/stat.h>
+#include <pthread.h>
+#include <unistd.h>
+
+#define FLOG_MAX_CATS 32
+
+static char g_path[512];
+static long g_rotate_ms = 3600000;
+static long g_max_bytes = 1 << 20;   /* 1 MiB */
+static FILE *g_fp = NULL;
+static long long g_last_rotate_ms = 0;
+static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
+
+typedef struct {
+    char cat[32];
+    long long last_ms;
+} FlogThrottle;
+
+static FlogThrottle g_throttle[FLOG_MAX_CATS];
+static int g_num_throttle = 0;
+
+static long long now_ms(void) {
+    struct timespec ts;
+    clock_gettime(CLOCK_MONOTONIC, &ts);
+    return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
+}
+
+int flog_init(const char *path, long rotate_ms, long max_bytes) {
+    pthread_mutex_lock(&g_mutex);
+    if (g_fp) { fclose(g_fp); g_fp = NULL; }
+    g_path[0] = '\0';
+    if (path && path[0]) {
+        strncpy(g_path, path, sizeof(g_path) - 1);
+        g_fp = fopen(path, "a");
+        if (!g_fp) {
+            pthread_mutex_unlock(&g_mutex);
+            fprintf(stderr, "flog: cannot open %s\n", path);
+            return -1;
+        }
+    }
+    if (rotate_ms > 0) g_rotate_ms = rotate_ms;
+    if (max_bytes > 0) g_max_bytes = max_bytes;
+    g_last_rotate_ms = now_ms();
+    pthread_mutex_unlock(&g_mutex);
+    return 0;
+}
+
+void flog_shutdown(void) {
+    pthread_mutex_lock(&g_mutex);
+    if (g_fp) { fclose(g_fp); g_fp = NULL; }
+    pthread_mutex_unlock(&g_mutex);
+}
+
+int flog_enabled(void) {
+    return g_fp != NULL;
+}
+
+void flog(const char *fmt, ...) {
+    if (!g_fp) return;
+    char msg[1024];
+    va_list ap;
+    va_start(ap, fmt);
+    vsnprintf(msg, sizeof(msg), fmt, ap);
+    va_end(ap);
+
+    time_t t = time(NULL);
+    struct tm tm;
+    localtime_r(&t, &tm);
+
+    pthread_mutex_lock(&g_mutex);
+    if (!g_fp) { pthread_mutex_unlock(&g_mutex); return; }
+    fprintf(g_fp, "[%04d-%02d-%02d %02d:%02d:%02d] %s\n",
+            tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
+            tm.tm_hour, tm.tm_min, tm.tm_sec, msg);
+    fflush(g_fp);
+    pthread_mutex_unlock(&g_mutex);
+}
+
+void flog_throttled(const char *cat, long min_interval_ms, const char *fmt, ...) {
+    if (!g_fp || !cat) return;
+
+    long long now = now_ms();
+    int i = 0;
+    pthread_mutex_lock(&g_mutex);
+    for (i = 0; i < g_num_throttle; i++)
+        if (strcmp(g_throttle[i].cat, cat) == 0) break;
+    if (i >= FLOG_MAX_CATS) {
+        pthread_mutex_unlock(&g_mutex);
+        return;
+    }
+    if (i == g_num_throttle) {
+        strncpy(g_throttle[i].cat, cat, sizeof(g_throttle[i].cat) - 1);
+        g_throttle[i].cat[sizeof(g_throttle[i].cat) - 1] = '\0';
+        g_throttle[i].last_ms = 0;
+        g_num_throttle++;
+    }
+    if (now - g_throttle[i].last_ms < min_interval_ms) {
+        pthread_mutex_unlock(&g_mutex);
+        return;
+    }
+    g_throttle[i].last_ms = now;
+    pthread_mutex_unlock(&g_mutex);
+
+    char msg[1024];
+    va_list ap;
+    va_start(ap, fmt);
+    vsnprintf(msg, sizeof(msg), fmt, ap);
+    va_end(ap);
+
+    time_t t = time(NULL);
+    struct tm tm;
+    localtime_r(&t, &tm);
+
+    pthread_mutex_lock(&g_mutex);
+    if (!g_fp) { pthread_mutex_unlock(&g_mutex); return; }
+    fprintf(g_fp, "[%04d-%02d-%02d %02d:%02d:%02d] %s\n",
+            tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
+            tm.tm_hour, tm.tm_min, tm.tm_sec, msg);
+    fflush(g_fp);
+    pthread_mutex_unlock(&g_mutex);
+}
+
+void flog_rotate_check(void) {
+    pthread_mutex_lock(&g_mutex);
+    if (!g_fp) { pthread_mutex_unlock(&g_mutex); return; }
+
+    long long now = now_ms();
+    int do_trunc = 0;
+    if (g_rotate_ms > 0 && now - g_last_rotate_ms >= g_rotate_ms) {
+        do_trunc = 1;
+        g_last_rotate_ms = now;
+    }
+    if (g_max_bytes > 0) {
+        struct stat st;
+        if (fstat(fileno(g_fp), &st) == 0 && st.st_size >= g_max_bytes)
+            do_trunc = 1;
+    }
+    if (do_trunc) {
+        fclose(g_fp);
+        g_fp = fopen(g_path, "w");
+        if (!g_fp) {
+            pthread_mutex_unlock(&g_mutex);
+            fprintf(stderr, "flog: cannot reopen %s\n", g_path);
+            return;
+        }
+        fprintf(g_fp, "--- log cleared at %lld ms ---\n", now);
+        fflush(g_fp);
+    }
+    pthread_mutex_unlock(&g_mutex);
+}

+ 23 - 0
flog.h

@@ -0,0 +1,23 @@
+#ifndef __FLOG_H__
+#define __FLOG_H__
+
+#include <stddef.h>
+
+/* Fault log. Writes timestamped lines to a file (thread-safe). The file is
+ * periodically truncated (by time and/or by size). */
+int flog_init(const char *path, long rotate_ms, long max_bytes);
+void flog_shutdown(void);
+int flog_enabled(void);
+
+/* Append one fault entry: "[YYYY-MM-DD HH:MM:SS] msg" */
+void flog(const char *fmt, ...);
+
+/* Rate-limited flog: same 'cat' logs at most once per min_interval_ms.
+ * Replaces high-frequency call sites (commit/compose/decode failures). */
+void flog_throttled(const char *cat, long min_interval_ms, const char *fmt, ...);
+
+/* Call periodically (e.g. once per main-loop tick): truncates the log when
+ * rotate_ms elapsed or file exceeds max_bytes. */
+void flog_rotate_check(void);
+
+#endif

二進制
fltk.png


+ 218 - 0
hikvision.c

@@ -0,0 +1,218 @@
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <errno.h>
+
+#include <curl/curl.h>
+
+#include "hikvision.h"
+#include "flog.h"
+
+static void hurl(char *dst, size_t sz, const HvCam *cam, const char *path) {
+    int hp = cam->http_port > 0 ? cam->http_port : 80;
+    snprintf(dst, sz, "http://%s:%d/ISAPI/%s", cam->ip, hp,
+             path && path[0] ? path : "");
+}
+
+/* Build the RTSP URL for a given logical encoding channel (e.g. 101/102). */
+static void build_url(char *dst, size_t sz, const HvCam *cam, int enc_id) {
+    snprintf(dst, sz, "rtsp://%s:%s@%s:%d/Streaming/Channels/%d",
+             cam->user, cam->pass, cam->ip, cam->port, enc_id);
+}
+
+void hv_build_urls(const HvCam *cam, HvUrls *out) {
+    if (!cam || !out) return;
+    build_url(out->main, sizeof(out->main), cam, cam->login_chan * 100 + 1);
+    build_url(out->sub,  sizeof(out->sub),  cam, cam->login_chan * 100 + 2);
+}
+
+/* ---- libcurl HTTP client (digest auth) ---- */
+
+typedef struct {
+    char *buf;
+    size_t len, cap;
+} CurlStr;
+
+static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *ud) {
+    CurlStr *s = ud;
+    size_t total = size * nmemb;
+    if (s->len + total + 1 > s->cap) {
+        size_t nc = s->cap ? s->cap : 8192;
+        while (nc < s->len + total + 1) nc *= 2;
+        char *nb = realloc(s->buf, nc);
+        if (!nb) return 0;
+        s->buf = nb;
+        s->cap = nc;
+    }
+    memcpy(s->buf + s->len, ptr, total);
+    s->len += total;
+    s->buf[s->len] = '\0';
+    return total;
+}
+
+/* Issue one ISAPI request. Returns 0 on HTTP 2xx; *outbuf receives a
+ * NUL-terminated heap buffer the caller frees (unless outbuf is NULL).
+ * Credentials are passed via CURLOPT_*, never via a shell command line. */
+int hv_request(const HvCam *cam, const char *method, const char *urlpath,
+               const char *body, char **outbuf) {
+    static int curl_ready = 0;
+    if (!curl_ready) {
+        if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return -3;
+        curl_ready = 1;
+    }
+    if (!cam || !cam->ip[0]) return -1;
+
+    char full[512];
+    hurl(full, sizeof(full), cam, urlpath);
+
+    CURL *c = curl_easy_init();
+    if (!c) return -3;
+
+    CurlStr s = {0};
+    struct curl_slist *hdr = NULL;
+    curl_easy_setopt(c, CURLOPT_URL, full);
+    /* cameras typically use digest; allow basic as fallback */
+    curl_easy_setopt(c, CURLOPT_HTTPAUTH, (long)(CURLAUTH_DIGEST | CURLAUTH_BASIC));
+    curl_easy_setopt(c, CURLOPT_USERNAME, cam->user);
+    curl_easy_setopt(c, CURLOPT_PASSWORD, cam->pass);
+    curl_easy_setopt(c, CURLOPT_TIMEOUT, 8L);
+    curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, 4L);
+    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, write_cb);
+    curl_easy_setopt(c, CURLOPT_WRITEDATA, &s);
+    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
+    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
+    curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, method);
+    if (body && body[0]) {
+        hdr = curl_slist_append(hdr, "Content-Type: application/xml");
+        curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdr);
+        curl_easy_setopt(c, CURLOPT_POSTFIELDS, body);
+    }
+
+    CURLcode rc = curl_easy_perform(c);
+    long http_code = 0;
+    curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &http_code);
+    if (hdr) curl_slist_free_all(hdr);
+    curl_easy_cleanup(c);
+
+    if (rc != CURLE_OK) {
+        free(s.buf);
+        flog("ISAPI %s %s: %s", method, full, curl_easy_strerror(rc));
+        return -4;
+    }
+    if (http_code < 200 || http_code >= 300) {
+        free(s.buf);
+        return -5;
+    }
+    if (outbuf) *outbuf = s.buf;
+    else free(s.buf);
+    return 0;
+}
+
+/* Extract integer value of an XML tag "<TAG>N</TAG>" (no attributes). */
+static int xml_int(const char *xml, const char *tag, int dflt) {
+    char open[48], close[48];
+    snprintf(open, sizeof(open), "<%s>", tag);
+    snprintf(close, sizeof(close), "</%s>", tag);
+    const char *a = strstr(xml, open);
+    if (!a) return dflt;
+    a += strlen(open);
+    const char *b = strstr(a, close);
+    if (!b) return dflt;
+    long v = strtol(a, NULL, 10);
+    return (int)v;
+}
+
+/* Replace "<TAG>old</TAG>" with "<TAG>new</TAG>" in-place (grow via realloc).
+ * Returns new string (caller frees) or NULL if tag not found. */
+static char *xml_set_int(char *xml, const char *tag, int val) {
+    char open[48], close[48];
+    snprintf(open, sizeof(open), "<%s>", tag);
+    snprintf(close, sizeof(close), "</%s>", tag);
+    char *a = strstr(xml, open);
+    if (!a) return NULL;
+    char *b = strstr(a + strlen(open), close);
+    if (!b) return NULL;
+    char valbuf[24];
+    snprintf(valbuf, sizeof(valbuf), "%d", val);
+
+    size_t head = (size_t)(a - xml);
+    size_t oldlen = strlen(open) + (size_t)(b - (a + strlen(open))) + strlen(close);
+    size_t newlen = strlen(open) + strlen(valbuf) + strlen(close);
+
+    size_t newcap = strlen(xml) - oldlen + newlen + 1;
+    char *nx = malloc(newcap);
+    if (!nx) return NULL;
+    memcpy(nx, xml, head);
+    strcpy(nx + head, open);
+    strcat(nx + head, valbuf);
+    strcat(nx + head, close);
+    strcat(nx + head, b + strlen(close));
+    free(xml);
+    return nx;
+}
+
+int hv_read_and_log(const HvCam *cam, int chan_enc_id) {
+    char path[128];
+    snprintf(path, sizeof(path), "Streaming/channels/%d", chan_enc_id);
+    char *xml = NULL;
+    if (hv_request(cam, "GET", path, NULL, &xml) != 0 || !xml) {
+        free(xml);
+        return -1;
+    }
+    int w = xml_int(xml, "videoResolutionWidth", -1);
+    int h = xml_int(xml, "videoResolutionHeight", -1);
+    int bitrate = xml_int(xml, "constantBitRate", -1);
+    int fps = xml_int(xml, "maxFrameRate", -1);
+    const char *enc = strstr(xml, "<H265Profile>") ? "H265" :
+                      (strstr(xml, "<H264Profile>") ? "H264" : "?");
+    flog("ISAPI 相机 %s 通道%d: %dx%d %s %dkbps %dfps",
+         cam->ip, chan_enc_id, w, h, enc, bitrate < 0 ? 0 : bitrate, fps < 0 ? 0 : fps);
+    free(xml);
+    return 0;
+}
+
+int hv_write_params(const HvCam *cam, int enc_id,
+                    int width, int height, int fps, int bitrate_kbps) {
+    if (width <= 0 || height <= 0) return -1;
+    char path[128];
+    snprintf(path, sizeof(path), "Streaming/channels/%d", enc_id);
+    char *xml = NULL;
+    if (hv_request(cam, "GET", path, NULL, &xml) != 0 || !xml) {
+        free(xml);
+        return -2;
+    }
+    /* Patch just the requested fields inside <Video>. Only fields the caller
+     * specified (val > 0) are changed; everything else is preserved. */
+    char *cur = xml;
+    char *n1 = xml_set_int(cur, "videoResolutionWidth", width);
+    if (!n1) { free(cur); return -4; }
+    cur = n1;
+    n1 = xml_set_int(cur, "videoResolutionHeight", height);
+    if (!n1) { free(cur); return -4; }
+    cur = n1;
+    if (fps > 0) {
+        n1 = xml_set_int(cur, "maxFrameRate", fps);
+        if (n1) cur = n1;
+    }
+    if (bitrate_kbps > 0) {
+        n1 = xml_set_int(cur, "constantBitRate", bitrate_kbps);
+        if (n1) cur = n1;
+    }
+
+    char *resp = NULL;
+    int rc = hv_request(cam, "PUT", path, cur, &resp);
+    if (rc != 0) {
+        flog("ISAPI 相机 %s 通道%d 写入失败 rc=%d%s", cam->ip, enc_id, rc,
+             resp ? "" : " (无响应)");
+        free(cur);
+        free(resp);
+        return -5;
+    }
+    flog("ISAPI 相机 %s 通道%d 已写 %dx%d %dkbps@%dfps", cam->ip, enc_id,
+         width, height, bitrate_kbps, fps);
+    free(cur);
+    free(resp);
+    return 0;
+}

+ 59 - 0
hikvision.h

@@ -0,0 +1,59 @@
+#ifndef __HIKVISION_H__
+#define __HIKVISION_H__
+
+/* Hikvision ISAPI helper.
+ *
+ * The ISAPI RTSP URL is fixed by protocol:
+ *   sub  = rtsp://USER:PASS@IP:554/Streaming/Channels/{chan*100+2}
+ *   main = rtsp://USER:PASS@IP:554/Streaming/Channels/{chan*100+1}
+ * where `chan` is the login channel number (usually 1).
+ *
+ * ISAPI read/write (device parameter access) goes over HTTP Digest auth and is
+ * performed by shelling out to the system `curl` binary (no compile-time deps).
+ */
+
+/* One camera credential from [camera_list]. */
+typedef struct {
+    int   id;            /* index used by layouts / workers */
+    char  ip[64];        /* camera host/IP */
+    char  user[64];
+    char  pass[64];
+    int   login_chan;    /* Hikvision login channel (default 1) */
+    int   port;          /* RTSP port, default 554 */
+    int   http_port;     /* ISAPI HTTP port, default 80 */
+} HvCam;
+
+/* Resolved RTSP URLs for one camera. */
+typedef struct {
+    char sub[512];   /* sub-stream URL  (e.g. .../Channels/102) */
+    char main[512];  /* main-stream URL (e.g. .../Channels/101) */
+} HvUrls;
+
+/* Build sub/main RTSP URLs for a camera into out->sub / out->main. */
+void hv_build_urls(const HvCam *cam, HvUrls *out);
+
+/* Perform a single ISAPI HTTP(S) request via system curl.
+ *   method : "GET" or "PUT"
+ *   url    : full http://host/ISAPI/... path
+ *   body   : for PUT, the XML payload (may be NULL)
+ *   outbuf : on success receives the response body (caller must free with
+ *            free()); on failure NULL.
+ * Returns 0 on success (HTTP 2xx), non-zero on failure.
+ * SAFETY: caller must sanitise cam->user/pass/url (we quote them). */
+int hv_request(const HvCam *cam, const char *method, const char *urlpath,
+               const char *body, char **outbuf);
+
+/* Read a streaming channel's encoding params (resolution/bitrate/etc.) and log
+ * a one-line summary via flog(). Returns 0 on success, non-zero on failure. */
+int hv_read_and_log(const HvCam *cam, int chan_enc_id);
+
+/* Write a channel's main encoding params (resolution / fps / bitrate).
+ *   enc_id   : encoding channel id (101 main, 102 sub for login chan 1)
+ *   width/height : video resolution
+ *   fps      : frame rate
+ *   bitrate_kbps : target bitrate in kbps
+ * Returns 0 on success, non-zero on failure. */
+int hv_write_params(const HvCam *cam, int enc_id,
+                    int width, int height, int fps, int bitrate_kbps);
+
+#endif

+ 72 - 0
install.sh

@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+set -e
+
+if [ "$EUID" -ne 0 ]; then
+    echo "Please run as root"
+    exit 1
+fi
+
+SERVICE="/etc/systemd/system/rtsp_multi_view.service"
+TARGET="/usr/local/bin/rtsp_multi_view"
+CONFIG_DIR="/etc/rtsp_multi_view"
+
+echo "Building..."
+make -C "$(dirname "$0")/build" -j$(nproc)
+
+echo "Installing binary..."
+cp "$(dirname "$0")/build/rtsp_multi_view" "$TARGET"
+chmod +x "$TARGET"
+
+echo "Installing auto_reboot.sh..."
+cp "$(dirname "$0")/auto_reboot.sh" /usr/local/bin/auto_reboot.sh
+chmod +x /usr/local/bin/auto_reboot.sh
+
+echo "Creating config directory..."
+mkdir -p "$CONFIG_DIR"
+if [ ! -f "$CONFIG_DIR/channels.conf" ]; then
+    cp "$(dirname "$0")/channels.conf" "$CONFIG_DIR/channels.conf" 2>/dev/null || true
+    echo "Created default config at $CONFIG_DIR/channels.conf"
+fi
+
+echo "Creating systemd service..."
+cat > "$SERVICE" << 'UNIT'
+[Unit]
+Description=RTSP Multi-View Display
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/rtsp_multi_view -c /etc/rtsp_multi_view/channels.conf
+Restart=always
+RestartSec=3
+Nice=-10
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+echo "Creating auto-reboot service..."
+cat > /etc/systemd/system/auto-reboot.service << 'UNIT'
+[Unit]
+Description=Scheduled Auto Reboot
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/auto_reboot.sh -c /etc/rtsp_multi_view/channels.conf
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+systemctl daemon-reload
+systemctl enable rtsp_multi_view auto-reboot
+systemctl restart rtsp_multi_view
+systemctl restart auto-reboot
+
+echo "Installed and started rtsp_multi_view + auto-reboot services."
+echo "Status: systemctl status rtsp_multi_view"
+echo "Reboot: systemctl status auto-reboot"
+echo "Logs:   journalctl -u rtsp_multi_view -f"

+ 159 - 0
install_local_test.sh

@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+# 安装并本地联调测试:
+#   1. 构建并安装最新二进制到 /usr/local/bin/rtsp_multi_view
+#   2. 安装本地测试配置 /etc/rtsp_multi_view/channels_local.conf(若不存在)
+#   3. 临时停止系统服务(释放显示输出),用 PLC 模拟器跑若干 plc_sim 循环:
+#      plc_server.py + plc_sim 写状态 + 已安装的 rtsp_multi_view 读布局,
+#      期间模拟一次 PLC 离线,验证右下角"PLC离线"报警与自动恢复
+#   4. 结束后自动恢复系统服务
+#
+# 用法: sudo ./install_local_test.sh [循环数] [每状态毫秒]
+#   循环数     plc_sim 完整循环次数(6 状态 x 间隔 = 1 循环),默认 1
+#   每状态毫秒 状态切换间隔,默认 5000
+# 环境变量: NO_OFFLINE=1 跳过 PLC 离线模拟
+
+set -u
+
+DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$DIR"
+
+BIN_SRC="$DIR/build/rtsp_multi_view"
+BIN_DST="/usr/local/bin/rtsp_multi_view"
+CONFIG_DIR="/etc/rtsp_multi_view"
+OUT="/tmp/opencode/install_local_test"
+
+CYCLE=1
+INTERVAL_MS=5000
+if [ $# -ge 1 ]; then CYCLE="$1"; fi
+if [ $# -ge 2 ]; then INTERVAL_MS="$2"; fi
+if [ "$CYCLE" -lt 1 ]; then CYCLE=1; fi
+if [ "$INTERVAL_MS" -lt 1000 ]; then INTERVAL_MS=1000; fi
+
+if [ "$EUID" -ne 0 ]; then
+    echo "错误:请用 root 运行 (sudo ./install_local_test.sh)"
+    exit 1
+fi
+
+PLCPID=""; SIMPID=""; VIEWPID=""
+SERVICE_ACTIVE=0
+
+stop_test_procs() {
+    [ -n "$VIEWPID" ] && kill "$VIEWPID" 2>/dev/null
+    [ -n "$SIMPID" ] && kill "$SIMPID" 2>/dev/null
+    [ -n "$PLCPID" ] && kill "$PLCPID" 2>/dev/null
+    sleep 1
+    kill -9 "$VIEWPID" "$SIMPID" "$PLCPID" 2>/dev/null
+}
+
+cleanup() {
+    stop_test_procs
+    if [ "$SERVICE_ACTIVE" -eq 1 ]; then
+        systemctl start rtsp_multi_view 2>/dev/null
+        SERVICE_ACTIVE=0
+    fi
+}
+trap cleanup EXIT INT TERM
+
+echo "== 构建 =="
+cmake --build "$DIR/build" -j"$(nproc)" || {
+    echo "构建失败,请先运行 ./build.sh" >&2
+    exit 1
+}
+
+echo "== 安装二进制 -> $BIN_DST =="
+cp "$BIN_SRC" "$BIN_DST"
+chmod +x "$BIN_DST"
+
+echo "== 安装本地测试配置 =="
+mkdir -p "$CONFIG_DIR"
+if [ ! -f "$CONFIG_DIR/channels_local.conf" ]; then
+    cp "$DIR/channels_local.conf" "$CONFIG_DIR/channels_local.conf"
+    echo "已创建 $CONFIG_DIR/channels_local.conf"
+else
+    echo "已存在 $CONFIG_DIR/channels_local.conf(跳过)"
+fi
+
+if systemctl is-active --quiet rtsp_multi_view 2>/dev/null; then
+    SERVICE_ACTIVE=1
+    echo "== 临时停止系统服务 (rtsp_multi_view.service) =="
+    systemctl stop rtsp_multi_view
+fi
+
+rm -rf "$OUT"; mkdir -p "$OUT"
+rm -f "$DIR/flog.txt"
+
+CONF="${CONF:-$DIR/channels_local.conf}"
+
+if ss -ltnp 2>/dev/null | grep -q ':102 '; then
+    echo "错误:127.0.0.1:102 已被占用,无法启动本地 PLC:"
+    ss -ltnp 2>/dev/null | grep ':102 '
+    echo "请先释放端口(或 kill 占用进程)后重试。"
+    exit 1
+fi
+
+echo "== 启动本地 PLC (plc_server.py :102) =="
+python3 "$DIR/plc_server.py" -p 102 >"$OUT/plcserver.log" 2>&1 &
+PLCPID=$!
+sleep 1
+if ! kill -0 "$PLCPID" 2>/dev/null || ! ss -ltnp 2>/dev/null | grep -q "pid=$PLCPID,"; then
+    echo "错误:plc_server 启动失败,日志如下:"
+    cat "$OUT/plcserver.log"
+    exit 1
+fi
+
+echo "== 启动状态切换器 (plc_sim: ${CYCLE} 循环 x ${INTERVAL_MS}ms) =="
+"$DIR/build/plc_sim" -c "$CONF" -t "$INTERVAL_MS" >"$OUT/plcsim.log" 2>&1 &
+SIMPID=$!
+
+echo "== 启动已安装的主程序 ($BIN_DST) =="
+"$BIN_DST" -c "$CONF" >"$OUT/viewer.log" 2>&1 &
+VIEWPID=$!
+
+RUN_SEC=$(( CYCLE * 6 * INTERVAL_MS / 1000 + 10 ))
+OFF_SEC=$(( RUN_SEC / 2 > 8 ? RUN_SEC / 2 : 8 ))
+ONLINE_SEC=$(( OFF_SEC - 4 > 3 ? OFF_SEC - 4 : 3 ))
+
+echo "-- 等待 PLC 连通 (${ONLINE_SEC}s) --"
+sleep "$ONLINE_SEC"
+
+if [ "${NO_OFFLINE:-0}" != "1" ]; then
+    echo "== 模拟 PLC 离线:杀掉 plc_server (${OFF_SEC}s) =="
+    kill "$PLCPID" 2>/dev/null
+    for _ in 1 2 3 4 5; do
+        ss -ltnp 2>/dev/null | grep -q ':102 ' || break
+        sleep 1
+    done
+    sleep "$OFF_SEC"
+    echo "== 恢复 PLC =="
+    python3 "$DIR/plc_server.py" -p 102 >>"$OUT/plcserver.log" 2>&1 &
+    PLCPID=$!
+    sleep 1
+    if ! kill -0 "$PLCPID" 2>/dev/null || ! ss -ltnp 2>/dev/null | grep -q "pid=$PLCPID,"; then
+        echo "错误:恢复 plc_server 失败,日志如下:"
+        cat "$OUT/plcserver.log"
+        exit 1
+    fi
+fi
+
+echo "-- 跑完剩余时间 ($(( RUN_SEC - ONLINE_SEC - OFF_SEC ))s) --"
+sleep $(( RUN_SEC - ONLINE_SEC - OFF_SEC ))
+
+echo "== 停止本地测试进程 =="
+stop_test_procs
+
+if [ "$SERVICE_ACTIVE" -eq 1 ]; then
+    echo "== 恢复系统服务 =="
+    systemctl start rtsp_multi_view
+    SERVICE_ACTIVE=0
+fi
+
+echo "==================== 结果 ===================="
+echo "--- PLC 报警/恢复 (flog.txt) ---"
+grep "PLC" "$DIR/flog.txt" 2>/dev/null || echo "(无 PLC 记录)"
+echo "--- plc_sim 状态循环 ---"
+grep -E "state [0-9]|connected" "$OUT/plcsim.log" | tail -20
+echo "--- viewer 异常 (排除常规超时重连) ---"
+grep -iE "error|fail|超时|失败" "$OUT/viewer.log" | grep -viE "连接失败: rtsp|Timeout waiting for first frame|connect 127.0.0.1:102 failed|recv failed" | head -10 || true
+echo "(done)"
+echo "完整日志: $OUT/"
+echo "生产服务: systemctl status rtsp_multi_view"

+ 17 - 0
linux/dma-heap.h

@@ -0,0 +1,17 @@
+#ifndef _DMA_HEAP_H
+#define _DMA_HEAP_H
+
+#include <linux/ioctl.h>
+#include <linux/types.h>
+
+struct dma_heap_allocation_data {
+    __u64 len;
+    __u32 fd;
+    __u32 fd_flags;
+    __u64 heap_flags;
+};
+
+#define DMA_HEAP_IOC_MAGIC 'H'
+#define DMA_HEAP_IOCTL_ALLOC _IOWR(DMA_HEAP_IOC_MAGIC, 0x0, struct dma_heap_allocation_data)
+
+#endif

+ 1903 - 0
main.c

@@ -0,0 +1,1903 @@
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <strings.h>
+#include <signal.h>
+#include <pthread.h>
+#include <sys/time.h>
+#include <time.h>
+#include <sys/wait.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <unistd.h>
+#include <sys/ioctl.h>
+#include <linux/dma-heap.h>
+#include <linux/dma-buf.h>
+#include <sys/mman.h>
+#include <ifaddrs.h>
+#include <arpa/inet.h>
+#include <netinet/in.h>
+
+#include "dma_util.h"
+#include "decoder.h"
+#include "composer.h"
+#include "display.h"
+#include "plc.h"
+#include "title.h"
+#include "flog.h"
+#include "hikvision.h"
+#include "web_server.h"
+
+#define MAX_STREAMS 25
+#define MAX_TILES 25
+#define MAX_ISAPI_WRITE 64
+
+#define MAX_LAYOUTS 16
+#define MAX_RULES 32
+#define MAX_RULE_BITS 16
+
+/* Split layout mode: fixed top row + PLC-driven adaptive bottom row. */
+#define SPLIT_MAX_BOTTOM 8
+static int g_split_enabled = 0;          /* 1 = use split mode */
+static int g_split_top_ids[MAX_TILES];   /* top row camera ids */
+static int g_split_top_n = 0;            /* top row count */
+static double g_split_top_ratio = 0.3;   /* top row height fraction */
+static int g_split_bottom_ids[SPLIT_MAX_BOTTOM][MAX_TILES]; /* per variant */
+static int g_split_bottom_cnt[SPLIT_MAX_BOTTOM] = {0};      /* cams per variant */
+static int g_split_bottom_valid[SPLIT_MAX_BOTTOM] = {0};    /* 1 if variant usable */
+static int g_split_bottom_count = 0;     /* temp: current bottom_N being parsed */
+static int g_split_bottom_def_ids[MAX_TILES] = {0};   /* [layout_bottom] default */
+static int g_split_bottom_def_cnt = 0;
+static int g_split_bottom_def_valid = 0;
+
+/* Default bottom layout used when no PLC rule matches: the [layout_bottom]
+ * group when present, else the highest valid [layout_bottom_N].  Sentinel 0
+ * means "use the [layout_bottom] default group". */
+static int split_idle_bottom(void) {
+    if (g_split_bottom_def_valid) return 0;
+    for (int bv = SPLIT_MAX_BOTTOM; bv >= 1; bv--)
+        if (g_split_bottom_valid[bv - 1]) return bv;
+    return 1;
+}
+static int split_bottom_cfg(int n, int **ids) {
+    if (n <= 0) { *ids = g_split_bottom_def_ids; return g_split_bottom_def_cnt; }
+    *ids = g_split_bottom_ids[n - 1]; return g_split_bottom_cnt[n - 1];
+}
+
+/* 超过该时长没有任何新帧到达,视为流静默死亡,worker 断开并重连。 */
+#define STREAM_STALL_TIMEOUT_MS 5000
+/* 最后一次新帧后超过该时长仍无数据,主循环停止定格旧帧并显示 NO SIGNAL。 */
+#define HOLD_LAST_FRAME_MS 8000
+
+typedef struct {
+    char url[512];
+    char url_main[512];   /* main-stream URL; used when a single-channel
+                           * layout is active (use_main=1). Empty = none. */
+    Decoder *dec;
+    int stream_id;
+    int running;
+    pthread_t thread;
+    int connected;
+    int codec;
+    int width;
+    int height;
+
+    /* Ring buffer: worker writes, main reads */
+    DecodedFrame ring[2];
+    int ring_count;
+    int ring_head;
+    pthread_mutex_t mutex;
+    pthread_cond_t cond;
+
+    /* Worker-assigned monotonic frame seq (stored in ring[].pts) */
+    int64_t frame_seq;
+
+    /* Main currently holds this decoder buffer index (set by main while it
+     * composes/displays the frame; worker must not reuse it). -1 = none. */
+    int held_pin;
+
+    /* Main/sub stream selection (guarded by mutex). Main thread flips
+     * use_main when the active layout is single-channel; the worker
+     * reconnects to url_main/url accordingly and records which one it
+     * is currently connected to in dec_using_main. */
+    int use_main;
+    int dec_using_main;
+
+    /* Dup'd fd of the last frame main composed from this stream. Kept across
+     * reconnects/layout switches so a brief gap shows the frozen last frame
+     * instead of NO SIGNAL. -1 = never had a frame. (main thread only) */
+    int last_fd;
+    int last_w, last_h, last_stride, last_vstride;
+
+    /* (main thread only) Monotonic time of the last *new* frame and the seq
+     * it was stamped with, so a silent stream is detected even though the
+     * same ring buffer keeps being re-acquired. */
+    int64_t last_seq_seen;
+    double last_frame_mono;
+} StreamWorker;
+
+static StreamWorker workers[MAX_STREAMS];
+static int num_streams = 0;
+static int tile_map[MAX_TILES];
+static int num_tiles = 0;
+static volatile sig_atomic_t g_running = 1;
+static volatile sig_atomic_t g_rebuild_req = 0;
+static Composer *g_composer = NULL;
+static Display *g_display = NULL;
+static int display_fail_streak = 0;   /* consecutive display_commit failures */
+
+static int output_fd[3] = {-1, -1, -1};
+static void *output_map[3] = {NULL, NULL, NULL};
+static int output_front = 0;
+static int output_last = 0;      /* previous front, for triple-buffer reuse */
+static int output_w = 0;
+static int output_h = 0;
+static int output_stride = 0;
+static size_t output_size = 0;
+
+/* Triple buffering: pick a back buffer that is neither the current scanout
+ * (front) nor the immediately previous one (last), so VOP can never be mid-scan
+ * on a buffer we are about to overwrite -> eliminates tearing. */
+#define BACK_BUF()  (3 - output_front - output_last)
+
+/* ---- HikVision ISAPI camera list / optional writes ---- */
+typedef struct {
+    int id;
+    int main;          /* 1 = main stream channel, 0 = sub */
+    int width, height, fps, bitrate;
+} IsapiWrite;
+
+static HvCam g_cams[MAX_STREAMS];
+static HvUrls g_urls[MAX_STREAMS];
+static int g_num_cams = 0;
+static IsapiWrite g_isapi_write[MAX_ISAPI_WRITE];
+static int g_isapi_write_count = 0;
+
+/* ---- reconnect / display behaviour options ---- */
+static int g_reconnect_ms = 3000;static int g_reconnect_max_ms = 15000;
+static int g_connect_timeout_sec = 10;
+static int g_letterbox = 1;
+static int g_no_signal_text = 1;
+static int g_blank_on_missing = 1;
+static int g_grid_lines = 1;   /* draw grid separators between tiles (0 to disable) */
+static int g_hold_layout_sec = 5;
+static double g_last_layout_ts = 0;
+/* Layout switch debounce: a new layout must stay stable for this many ms
+ * before it is applied, so a momentary PLC bit pulse (a point that triggers
+ * and clears within one scan) does not visibly flicker the screen. */
+static int g_layout_debounce_ms = 300;
+static int g_pending_layout = -1;
+static double g_pending_layout_ts = 0;
+
+/* ---- PLC-driven layout state ---- */
+typedef struct {
+    int id;
+    int map[MAX_TILES];      /* cam ids in grid order; -1 = empty cell */
+    int n;                   /* total cells across all rows */
+    int row_cols[MAX_TILES]; /* column count per row (row-major) */
+    int nrows;               /* number of rows */
+} LayoutDef;
+
+typedef struct {
+    int byte_idx;     /* offset relative to plc read_start */
+    int bit;
+} RuleBit;
+
+typedef struct {
+    int layout;       /* index into g_layouts */
+    RuleBit bits[MAX_RULE_BITS];
+    int group[MAX_RULE_BITS];   /* group index per bit (0-based) */
+    int nbits;
+    int ngroups;                /* number of ';' groups */
+    int all;          /* 1 = all bits in a group required, 0 = any */
+} PlcRule;
+
+static LayoutDef g_layouts[MAX_LAYOUTS];
+static int g_num_layouts = 0;
+
+static PlcRule g_rules[MAX_RULES];
+static int g_num_rules = 0;
+
+static int g_plc_enabled = 0;
+static PlcConfig g_plc_cfg;
+
+/* [auto_reboot] scheduled device restart */
+static int g_reboot_enable = 0;    /* 0=off 1=on */
+static int g_reboot_period = 0;    /* 0=daily 1=weekly */
+static int g_reboot_hour = 3;      /* HH */
+static int g_reboot_min = 0;       /* MM */
+static int g_reboot_wday = -1;     /* tm_wday (0=Sun..6=Sat); -1 = any day */
+
+static int g_cur_layout = -1;
+static int g_grid_x[MAX_TILES], g_grid_y[MAX_TILES];
+static int g_grid_w[MAX_TILES], g_grid_h[MAX_TILES];
+static int g_grid_nrows = 0;                 /* base [layout] grid: #rows */
+static int g_grid_row_cols[MAX_TILES];        /* base grid: cols per row */
+
+/* Active layout compiled to regions (merged cells). Each region maps 1:1 to a
+ * camera and a screen rectangle; these drive composition every frame. */
+static int g_disp_sid[MAX_TILES];
+static int g_disp_tiles = 0;
+
+/* ---- title bar ---- */
+static TitleCfg g_title;
+static int g_title_enabled = 0;
+static int g_title_h = 0;
+static int g_title_overlay = 0;
+
+/* ---- fault log ---- */
+static char g_flog_path[512];
+static long g_flog_rotate_ms = 3600000;
+static long g_flog_max_bytes = 1 << 20;
+
+static void sig_handler(int sig) {
+    if (sig == SIGUSR1) { g_rebuild_req = 1; return; }
+    g_running = 0;
+}
+
+static void install_signal_handlers(void) {
+    struct sigaction sa;
+    memset(&sa, 0, sizeof(sa));
+    sa.sa_handler = sig_handler;
+    sigemptyset(&sa.sa_mask);
+    sa.sa_flags = 0;    /* no SA_RESTART: block syscalls must return so loops see g_running */
+    sigaction(SIGINT, &sa, NULL);
+    sigaction(SIGTERM, &sa, NULL);
+    sigaction(SIGUSR1, &sa, NULL);
+}
+
+/* PLC 离线/恢复状态跳变时写一次故障日志(不在画面上叠加提示文字,
+ * 避免画面右下角每帧闪烁)。 */
+static void log_plc_state_change(void) {
+    if (!g_plc_enabled) return;
+    int offline = !plc_is_connected();
+    static int last_offline = -1;
+    if (offline == last_offline) return;
+    last_offline = offline;
+    if (offline) flog("PLC 离线报警");
+    else flog("PLC 恢复在线");
+}
+
+/* Current IPv4 address(es) shown on screen (eth0 first, then any other
+ * non-loopback address), refreshed about once a second. Lets the operator
+ * read the board's IP straight off the video if they forgot it. */
+static char g_ip_text[128] = "";
+static time_t g_ip_last = 0;
+static void update_ip_text(void) {
+    time_t now = time(NULL);
+    if (now == g_ip_last && g_ip_text[0]) return;
+    g_ip_last = now;
+    char eth0[48] = "", other[48] = "", extra[48] = "";
+    struct ifaddrs *ifa = NULL;
+    if (getifaddrs(&ifa) == 0) {
+        for (struct ifaddrs *p = ifa; p; p = p->ifa_next) {
+            if (!p->ifa_addr || p->ifa_addr->sa_family != AF_INET) continue;
+            if (strcmp(p->ifa_name, "lo") == 0) continue;
+            char buf[48];
+            struct sockaddr_in *sin = (struct sockaddr_in *)p->ifa_addr;
+            if (!inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf))) continue;
+            if (strcmp(p->ifa_name, "eth0") == 0) {
+                if (!eth0[0]) snprintf(eth0, sizeof(eth0), "%s", buf);
+                else if (!extra[0]) snprintf(extra, sizeof(extra), "%s", buf);
+            } else if (!other[0]) {
+                snprintf(other, sizeof(other), "%s", buf);
+            }
+        }
+        freeifaddrs(ifa);
+    }
+    const char *a = eth0[0] ? eth0 : other;
+    const char *b = eth0[0] ? extra : NULL;
+    char line[128];
+    if (!a[0]) snprintf(line, sizeof(line), "IP 无地址");
+    else if (b && b[0]) snprintf(line, sizeof(line), "IP %s  %s", a, b);
+    else snprintf(line, sizeof(line), "IP %s", a);
+    if (strcmp(line, g_ip_text) != 0) {
+        snprintf(g_ip_text, sizeof(g_ip_text), "%s", line);
+        fprintf(stderr, "IP 显示: %s\n", g_ip_text);
+    }
+}
+
+/* Draw a 2px grid line around every on-screen tile in the NV12 output buffer.
+ * Lines use a bright gray (Y high, neutral UV) so they read as white grid
+ * separators over any video content. Call after composition, on the back
+ * buffer (which the caller keeps CPU-visible). */
+static void draw_grid_lines(uint8_t *map, int out_w, int out_h,
+                            Composer *comp, int n) {
+    if (!map || !comp || n <= 0) return;
+    static const int TH = 2;            /* line thickness, px */
+    static const uint8_t YW = 220;      /* near-white */
+    uint8_t *Y = map;
+    for (int t = 0; t < n; t++) {
+        int x, y, w, h;
+        composer_get_tile_layout(comp, t, &x, &y, &w, &h);
+        if (x < 0) x = 0;
+        if (y < 0) y = 0;
+        if (x + w > out_w) w = out_w - x;
+        if (y + h > out_h) h = out_h - y;
+        if (w <= 0 || h <= 0) continue;
+        /* top */
+        for (int r = 0; r < TH && y + r < out_h; r++)
+            memset(Y + (size_t)(y + r) * out_w + x, YW, (size_t)w);
+        /* bottom */
+        int by = y + h - TH;
+        if (by < 0) by = 0;
+        for (int r = 0; r < TH && by + r < out_h; r++)
+            memset(Y + (size_t)(by + r) * out_w + x, YW, (size_t)w);
+        /* left */
+        for (int yy = y; yy < y + h && yy < out_h; yy++) {
+            int x0 = x, x1 = x + TH;
+            if (x1 > out_w) x1 = out_w;
+            if (x0 < 0) x0 = 0;
+            for (int xx = x0; xx < x1; xx++)
+                Y[(size_t)yy * out_w + xx] = YW;
+        }
+        /* right */
+        int xr0 = x + w - TH;
+        if (xr0 < 0) xr0 = 0;
+        for (int yy = y; yy < y + h && yy < out_h; yy++) {
+            int xx0 = xr0, xx1 = x + w;
+            if (xx0 < 0) xx0 = 0;
+            if (xx1 > out_w) xx1 = out_w;
+            for (int xx = xx0; xx < xx1; xx++)
+                Y[(size_t)yy * out_w + xx] = YW;
+        }
+    }
+}
+
+static void *worker_thread(void *arg) {
+    StreamWorker *w = (StreamWorker *)arg;
+    int backoff = g_reconnect_ms;
+    struct timespec last_data_ts;
+
+    while (g_running && w->running) {
+        /* Pick the stream kind this worker should use right now. */
+        pthread_mutex_lock(&w->mutex);
+        int want_main = w->use_main && w->url_main[0];
+        pthread_mutex_unlock(&w->mutex);
+
+        if (!w->dec) {
+            const char *url = want_main ? w->url_main : w->url;
+            fprintf(stderr, "DBG: [stream %d] Connecting to %s\n", w->stream_id, url);
+            w->dec = decoder_create(url, g_connect_timeout_sec);
+            if (!w->dec) {
+                fprintf(stderr, "DBG: [stream %d] Failed to connect\n", w->stream_id);
+                flog("视频流 %d 连接失败: %s", w->stream_id, url);
+                usleep((useconds_t)backoff * 1000);
+                if (backoff < g_reconnect_max_ms) {
+                    backoff *= 2;
+                    if (backoff > g_reconnect_max_ms) backoff = g_reconnect_max_ms;
+                }
+                continue;
+            }
+            pthread_mutex_lock(&w->mutex);
+            w->dec_using_main = want_main;
+            w->connected = 1;
+            pthread_mutex_unlock(&w->mutex);
+            backoff = g_reconnect_ms;
+            clock_gettime(CLOCK_MONOTONIC, &last_data_ts);
+            w->codec = decoder_get_codec(w->dec);
+            w->width = decoder_get_width(w->dec);
+            w->height = decoder_get_height(w->dec);
+            fprintf(stderr, "DBG: [stream %d] Connected: %dx%d codec=%d\n",
+                    w->stream_id, w->width, w->height, w->codec);
+            flog("视频流 %d 已连接 %dx%d", w->stream_id, w->width, w->height);
+        }
+
+        while (g_running && w->running) {
+            /* A single-channel layout switched which stream kind to present:
+             * drop out and reconnect on the other URL. */
+            pthread_mutex_lock(&w->mutex);
+            int want_now = w->use_main && w->url_main[0];
+            int switch_now = (want_now != w->dec_using_main);
+            pthread_mutex_unlock(&w->mutex);
+            if (switch_now) {
+                fprintf(stderr, "DBG: [stream %d] switching to %s stream\n",
+                        w->stream_id, want_now ? "main" : "sub");
+                backoff = 50;
+                break;
+            }
+
+            DecodedFrame frame;
+            int ret = decoder_get_frame(w->dec, &frame);
+            if (ret == -3) {
+                /* No frame ready. A live stream delivers continuously, so a
+                 * long silent stretch means the connection died quietly
+                 * (RTSP server gone / TCP dropped without a FIN). */
+                struct timespec now_ts;
+                clock_gettime(CLOCK_MONOTONIC, &now_ts);
+                double idle = (now_ts.tv_sec - last_data_ts.tv_sec) +
+                              (now_ts.tv_nsec - last_data_ts.tv_nsec) / 1000000000.0;
+                if (idle >= STREAM_STALL_TIMEOUT_MS / 1000.0) {
+                    flog_throttled("stream_stall", 10000,
+                                   "视频流 %d 无数据 %d 秒,重连",
+                                   w->stream_id, STREAM_STALL_TIMEOUT_MS / 1000);
+                    break;
+                }
+                usleep(1000);
+                continue;
+            }
+            if (ret < 0) {
+                flog_throttled("stream_break", 10000,
+                               "视频流 %d 解码中断 (ret=%d)", w->stream_id, ret);
+                break;
+            }
+            clock_gettime(CLOCK_MONOTONIC, &last_data_ts);
+            pthread_mutex_lock(&w->mutex);
+            /* Never recycle the buffer main is currently displaying. */
+            while (w->ring_count >= 2 && g_running && w->running &&
+                   w->ring[w->ring_head].idx == w->held_pin) {
+                pthread_cond_wait(&w->cond, &w->mutex);
+            }
+            if (w->ring_count >= 2) {
+                int ri = w->ring_head;
+                decoder_release_frame(w->dec, w->ring[ri].idx);
+                if (w->ring[ri].fd >= 0) close(w->ring[ri].fd);
+                w->ring[ri].fd = -1;
+                w->ring_head = (w->ring_head + 1) % 2;
+                w->ring_count--;
+            }
+            int wi = (w->ring_head + w->ring_count) % 2;
+            w->ring[wi] = frame;
+            w->ring[wi].pts = ++w->frame_seq;
+            w->ring_count++;
+            pthread_cond_signal(&w->cond);
+            pthread_mutex_unlock(&w->mutex);
+        }
+
+        /* Tear down decoder. Wait (bounded) for main to drain the ring so no
+         * decoder buffer is outstanding when the pool is destroyed. */
+        pthread_mutex_lock(&w->mutex);
+        w->connected = 0;
+        if (w->dec) {
+            struct timespec ts;
+            clock_gettime(CLOCK_REALTIME, &ts);
+            ts.tv_sec += 1;
+            while (g_running && w->ring_count > 0) {
+                int rc = pthread_cond_timedwait(&w->cond, &w->mutex, &ts);
+                if (rc == ETIMEDOUT) break;
+            }
+        }
+        Decoder *old = w->dec;
+        w->dec = NULL;
+        pthread_mutex_unlock(&w->mutex);
+        decoder_destroy(old);
+        fprintf(stderr, "DBG: [stream %d] decoder released\n", w->stream_id);
+
+        if (!(g_running && w->running)) break;
+
+        usleep((useconds_t)backoff * 1000);
+        if (backoff < g_reconnect_max_ms) {
+            backoff *= 2;
+            if (backoff > g_reconnect_max_ms) backoff = g_reconnect_max_ms;
+        }
+    }
+
+    /* final cleanup on shutdown */
+    pthread_mutex_lock(&w->mutex);
+    Decoder *last = w->dec;
+    w->dec = NULL;
+    w->connected = 0;
+    pthread_mutex_unlock(&w->mutex);
+    decoder_destroy(last);
+    printf("[stream %d] Exiting\n", w->stream_id);
+    return NULL;
+}
+
+/* Parse one layout row into per-cell region labels.
+ *   - decimal number (0..25) -> that region/camera id
+ *   - single letter A-Z      -> alias for region id (A=0, B=1, ... Z=25)
+ *   - x / X                  -> empty cell (-1)
+ * Cells sharing the same label are merged into one on-screen region. */
+static int parse_grid_row(char *s, int *out, int max) {
+    int nv = 0;
+    while (*s) {
+        while (*s == ' ' || *s == '\t') s++;
+        if (*s == 'x' || *s == 'X') {
+            out[nv++] = -1;                    /* empty cell */
+            s++;
+        } else if (*s >= '0' && *s <= '9') {
+            out[nv++] = atoi(s);
+            while (*s >= '0' && *s <= '9') s++;
+        } else if ((*s >= 'a' && *s <= 'z') || (*s >= 'A' && *s <= 'Z')) {
+            int c = *s;
+            out[nv++] = (c >= 'a' && c <= 'z') ? (c - 'a') : (c - 'A');
+            s++;
+        } else {
+            break;
+        }
+        if (nv >= max) break;
+    }
+    return nv;
+}
+
+static int parse_config(const char *path, int *p_w, int *p_h,
+                         int *p_nstreams, int *p_ntiles, int *tilemap) {
+    FILE *fp = fopen(path, "r");
+    if (!fp) return -1;
+    char line[1024];
+    int section = 0;
+    int nstreams = 0, ntiles = 0, ncols = 0;
+
+    memset(&g_title, 0, sizeof(g_title));
+    g_plc_cfg.tsap_local = -1;
+    g_plc_cfg.tsap_remote = -1;
+    g_title.enable = 0;
+    g_title.height = 80;
+    g_title.font_size = 48;
+    g_title.color = 0xFFFFFF;
+    g_title.logo_pad = 8;
+    g_title.opacity = 255;
+    strncpy(g_title.font, title_default_font(), sizeof(g_title.font) - 1);
+
+    g_flog_path[0] = '\0';
+    g_flog_rotate_ms = 3600000;
+    g_flog_max_bytes = 1 << 20;
+
+    while (fgets(line, sizeof(line), fp)) {
+        char *s = line;
+        while (*s == ' ' || *s == '\t') s++;
+        if (*s == '#' || *s == '\n' || *s == '\r') continue;
+        if (s[0] == '[') {
+            if (strncmp(s, "[output]", 8) == 0) section = 1;
+            else if (strncmp(s, "[streams]", 9) == 0) section = 2;
+            else if (strncmp(s, "[main_streams]", 14) == 0) section = 11;
+            else if (strncmp(s, "[camera_list]", 13) == 0) section = 12;
+            else if (strncmp(s, "[isapi_write]", 13) == 0) section = 13;
+            else if (strncmp(s, "[plc]", 5) == 0) section = 5;
+            else if (strncmp(s, "[title]", 7) == 0) section = 9;
+            else if (strncmp(s, "[log]", 5) == 0) section = 10;
+            else if (strncmp(s, "[plc_rules]", 11) == 0) section = 7;
+            else if (strncmp(s, "[auto_reboot]", 13) == 0) section = 17;
+            else if (strncmp(s, "[layout", 7) == 0) {
+                if (s[7] == ']') {
+                    section = 3;               /* [layout] base grid */
+                } else if (s[7] >= '0' && s[7] <= '9') {
+                    section = 8;               /* [layoutN] named grid */
+                    if (g_num_layouts < MAX_LAYOUTS) {
+                        LayoutDef *L = &g_layouts[g_num_layouts];
+                        memset(L, 0, sizeof(*L));
+                        L->id = atoi(s + 7);
+                        g_num_layouts++;
+                    }
+                } else if (strncmp(s + 7, "_top]", 5) == 0) {
+                    section = 14;              /* [layout_top] */
+                } else if (strncmp(s + 7, "_bottom", 7) == 0) {
+                    if (s[14] == '_' && s[15] >= '0' && s[15] <= '9') {
+                        section = 15;          /* [layout_bottom_N] */
+                        g_split_bottom_count = atoi(s + 15);
+                    } else {
+                        section = 16;          /* [layout_bottom] default row */
+                    }
+                } else {
+                    section = 0;
+                }
+            } else {
+                section = 0;
+            }
+            continue;
+        }
+        if (section == 1) {
+            if (strchr(s, '=')) {
+                char key[64], val[64];
+                if (sscanf(s, "%63s = %63s", key, val) >= 2) {
+                    if (strcmp(key, "letterbox") == 0) g_letterbox = atoi(val) != 0;
+                    else if (strcmp(key, "no_signal_text") == 0) g_no_signal_text = atoi(val) != 0;
+                    else if (strcmp(key, "blank_on_missing") == 0) g_blank_on_missing = atoi(val) != 0;
+                    else if (strcmp(key, "grid_lines") == 0) g_grid_lines = atoi(val) != 0;
+                }
+            } else {
+                sscanf(s, "%d %d", p_w, p_h);
+            }
+        } else if (section == 2) {
+            if (strncmp(s, "reconnect_ms", 12) == 0 && strchr(s, '=')) {
+                g_reconnect_ms = atoi(strchr(s, '=') + 1);
+            } else if (strncmp(s, "reconnect_max_ms", 16) == 0 && strchr(s, '=')) {
+                g_reconnect_max_ms = atoi(strchr(s, '=') + 1);
+            } else if (strncmp(s, "connect_timeout_sec", 19) == 0 && strchr(s, '=')) {
+                g_connect_timeout_sec = atoi(strchr(s, '=') + 1);
+            } else {
+            if (nstreams >= MAX_STREAMS) continue;
+            char *url = strchr(s, ' ');
+            if (!url) url = strchr(s, '\t');
+            if (!url) { url = s; } else { url++; }
+            while (*url == ' ' || *url == '\t') url++;
+            size_t len = strlen(url);
+            while (len > 0 && (url[len-1] == '\n' || url[len-1] == '\r')) url[--len] = 0;
+            if (len > 0) {
+                size_t cp = len < sizeof(workers[nstreams].url) - 1 ?
+                            len : sizeof(workers[nstreams].url) - 1;
+                memcpy(workers[nstreams].url, url, cp);
+                workers[nstreams].url[cp] = '\0';
+                nstreams++;
+            }
+            }
+        } else if (section == 3) {
+            /* Check for key=value (split mode) */
+            if (strchr(s, '=')) {
+                char key[64], val[64];
+                if (sscanf(s, "%63s = %63s", key, val) >= 2) {
+                    if (strcmp(key, "split") == 0) g_split_enabled = atoi(val) != 0;
+                    else if (strcmp(key, "top_ratio") == 0) g_split_top_ratio = atof(val);
+                }
+            } else {
+            int vals[32];
+            int nv = parse_grid_row(s, vals, 32);
+            if (nv > 0) {
+                if (ncols == 0) ncols = nv;
+                if (g_grid_nrows < MAX_TILES) {
+                    g_grid_row_cols[g_grid_nrows++] = nv;
+                }
+                for (int j = 0; j < nv && ntiles < MAX_TILES; j++)
+                    tilemap[ntiles++] = vals[j];
+            }
+            }
+        } else if (section == 14) {
+            /* [layout_top]: space-separated camera IDs for top row */
+            int id;
+            const char *p = s;
+            while (*p && g_split_top_n < MAX_TILES) {
+                while (*p == ' ' || *p == '\t') p++;
+                if (!*p || *p == '\n' || *p == '\r' || *p == '#') break;
+                if (sscanf(p, "%d", &id) == 1) {
+                    g_split_top_ids[g_split_top_n++] = id;
+                    while (*p && *p != ' ' && *p != '\t') p++;
+                } else break;
+            }
+        } else if (section == 15) {
+            /* [layout_bottom_N]: space-separated camera IDs for bottom row */
+            int id;
+            const char *p = s;
+            int cnt = 0;
+            while (*p && cnt < MAX_TILES) {
+                while (*p == ' ' || *p == '\t') p++;
+                if (!*p || *p == '\n' || *p == '\r' || *p == '#') break;
+                if (sscanf(p, "%d", &id) == 1) {
+                    if (g_split_bottom_count >= 1 && g_split_bottom_count <= SPLIT_MAX_BOTTOM)
+                        g_split_bottom_ids[g_split_bottom_count - 1][cnt] = id;
+                    cnt++;
+                    while (*p && *p != ' ' && *p != '\t') p++;
+                } else break;
+            }
+            if (g_split_bottom_count >= 1 && g_split_bottom_count <= SPLIT_MAX_BOTTOM) {
+                g_split_bottom_valid[g_split_bottom_count - 1] = 1;
+                g_split_bottom_cnt[g_split_bottom_count - 1] = cnt;
+                fprintf(stderr, "DBG: [layout_bottom_%d] cnt=%d ids=%d %d %d\n",
+                        g_split_bottom_count, cnt,
+                        g_split_bottom_ids[g_split_bottom_count-1][0],
+                        g_split_bottom_ids[g_split_bottom_count-1][1],
+                        g_split_bottom_ids[g_split_bottom_count-1][2]);
+            }
+        } else if (section == 16) {
+            /* [layout_bottom]: default bottom row used when no PLC rule matches */
+            int id;
+            const char *p = s;
+            int cnt = 0;
+            while (*p && cnt < MAX_TILES) {
+                while (*p == ' ' || *p == '\t') p++;
+                if (!*p || *p == '\n' || *p == '\r' || *p == '#') break;
+                if (sscanf(p, "%d", &id) == 1) {
+                    g_split_bottom_def_ids[cnt++] = id;
+                    while (*p && *p != ' ' && *p != '\t') p++;
+                } else break;
+            }
+            if (cnt > 0) {
+                g_split_bottom_def_cnt = cnt;
+                g_split_bottom_def_valid = 1;
+                fprintf(stderr, "DBG: [layout_bottom] default cnt=%d ids=%d %d %d\n",
+                        cnt, g_split_bottom_def_ids[0],
+                        g_split_bottom_def_ids[1], g_split_bottom_def_ids[2]);
+            }
+        } else if (section == 8) {
+            int vals[32];
+            int nv = parse_grid_row(s, vals, 32);
+            if (nv > 0 && g_num_layouts > 0) {
+                LayoutDef *L = &g_layouts[g_num_layouts - 1];
+                if (L->nrows < MAX_TILES) L->row_cols[L->nrows++] = nv;
+                for (int j = 0; j < nv && L->n < MAX_TILES; j++)
+                    L->map[L->n++] = vals[j];    /* -1 = empty slot */
+            }
+        } else if (section == 5) {
+            char key[64], val[256];
+            if (sscanf(s, "%63s = %255[^\n\r]", key, val) >= 2) {
+                char *c = strchr(val, ';');
+                if (!c) c = strchr(val, '#');
+                if (c) *c = '\0';
+                int vlen = (int)strlen(val);
+                while (vlen > 0 && (val[vlen-1] == ' ' || val[vlen-1] == '\t'))
+                    val[--vlen] = '\0';
+                if (strcmp(key, "enable") == 0) {
+                    g_plc_enabled = atoi(val) != 0;
+                } else if (strcmp(key, "ip") == 0) {
+                    strncpy(g_plc_cfg.ip, val, sizeof(g_plc_cfg.ip) - 1);
+                } else if (strcmp(key, "port") == 0) {
+                    g_plc_cfg.port = atoi(val);
+                } else if (strcmp(key, "area") == 0) {
+                    if (strcasecmp(val, "m") == 0) g_plc_cfg.area = PLC_AREA_M;
+                    else if (strcasecmp(val, "i") == 0) g_plc_cfg.area = PLC_AREA_I;
+                    else if (strcasecmp(val, "q") == 0) g_plc_cfg.area = PLC_AREA_Q;
+                    else if (strcasecmp(val, "db") == 0) g_plc_cfg.area = PLC_AREA_DB;
+                } else if (strcmp(key, "db") == 0) {
+                    g_plc_cfg.db = atoi(val);
+                } else if (strcmp(key, "read_start") == 0) {
+                    g_plc_cfg.read_start = atoi(val);
+                } else if (strcmp(key, "read_len") == 0) {
+                    g_plc_cfg.read_len = atoi(val);
+                } else if (strcmp(key, "poll_ms") == 0) {
+                    g_plc_cfg.poll_ms = atoi(val);
+                } else if (strcmp(key, "reconnect_ms") == 0) {
+                    g_plc_cfg.reconnect_ms = atoi(val);
+                } else if (strcmp(key, "hold_layout_sec") == 0) {
+                    g_hold_layout_sec = atoi(val);
+                } else if (strcmp(key, "debounce_ms") == 0) {
+                    g_layout_debounce_ms = atoi(val);
+                } else if (strcmp(key, "tsap_local") == 0) {
+                    g_plc_cfg.tsap_local = (int)strtol(val, NULL, 0);
+                } else if (strcmp(key, "tsap_remote") == 0) {
+                    g_plc_cfg.tsap_remote = (int)strtol(val, NULL, 0);
+                }
+            }
+        } else if (section == 7) {
+            /* <layout_id> : <byte>.<bit>[,<byte>.<bit>...] [';' ...] [: all]
+             * ';' separates groups; every group must be satisfied (AND across
+             * groups), any single bit inside a group is enough (OR within a
+             * group); ':' suffix "all" also requires every bit of a group. */
+            if (g_num_rules >= MAX_RULES) continue;
+            int lid;
+            char *colon = strchr(s, ':');
+            if (!colon) continue;
+            if (sscanf(s, "%d", &lid) != 1) continue;
+            PlcRule *R = &g_rules[g_num_rules];
+            memset(R, 0, sizeof(*R));
+            char *tok = colon + 1;
+            int cur_group = 0;
+            while (tok && *tok && R->nbits < MAX_RULE_BITS) {
+                while (*tok == ' ' || *tok == '\t' || *tok == ',' || *tok == ';') {
+                    if (*tok == ';') cur_group++;
+                    tok++;
+                }
+                if (*tok == ':' || *tok == '\0' ||
+                    *tok == '\n' || *tok == '\r') break;
+                int by, bi = 0;
+                if (sscanf(tok, "%d.%d", &by, &bi) == 2) {
+                    R->bits[R->nbits].byte_idx = by;
+                    R->bits[R->nbits].bit = bi;
+                } else if (sscanf(tok, "%d", &by) == 1) {
+                    R->bits[R->nbits].byte_idx = by;
+                    R->bits[R->nbits].bit = 0;
+                } else {
+                    break;
+                }
+                R->group[R->nbits] = cur_group;
+                R->nbits++;
+                while (*tok && *tok != ',' && *tok != ';' && *tok != ':')
+                    tok++;
+            }
+            R->ngroups = cur_group + 1;
+            char *mode = strrchr(s, ':');
+            if (mode && strstr(mode, "all")) R->all = 1;
+            int li = -1;
+            for (int i = 0; i < g_num_layouts; i++)
+                if (g_layouts[i].id == lid) { li = i; break; }
+            if (li < 0) continue;
+            R->layout = li;
+            if (R->nbits > 0) g_num_rules++;
+        } else if (section == 9) {
+            char key[64], val[512];
+            if (sscanf(s, "%63s = %511[^\n\r]", key, val) >= 2) {
+                int vlen = (int)strlen(val);
+                while (vlen > 0 && (val[vlen-1] == ' ' || val[vlen-1] == '\t'
+                       || val[vlen-1] == '\r' || val[vlen-1] == '\n'))
+                    val[--vlen] = '\0';
+                char *lead = val;
+                while (*lead == ' ' || *lead == '\t') lead++;
+                if (strcmp(key, "enable") == 0) {
+                    g_title.enable = atoi(lead) != 0;
+                } else if (strcmp(key, "type") == 0) {
+                    if (strncmp(lead, "overlay", 7) == 0 ||
+                        atoi(lead) != 0) g_title.type = 1;
+                    else g_title.type = 0;
+                } else if (strcmp(key, "height") == 0) {
+                    g_title.height = atoi(lead);
+                } else if (strcmp(key, "text") == 0) {
+                    strncpy(g_title.text, lead, sizeof(g_title.text) - 1);
+                } else if (strcmp(key, "logo") == 0) {
+                    strncpy(g_title.logo, lead, sizeof(g_title.logo) - 1);
+                } else if (strcmp(key, "logo_pad") == 0) {
+                    g_title.logo_pad = atoi(lead);
+                } else if (strcmp(key, "opacity") == 0) {
+                    g_title.opacity = atoi(lead);
+                } else if (strcmp(key, "font") == 0) {
+                    strncpy(g_title.font, lead, sizeof(g_title.font) - 1);
+                } else if (strcmp(key, "font_size") == 0) {
+                    g_title.font_size = atoi(lead);
+                } else if (strcmp(key, "color") == 0) {
+                    g_title.color = (unsigned)strtoul(lead, NULL, 0);
+                }
+            }
+        } else if (section == 10) {
+            char key[64], val[512];
+            if (sscanf(s, "%63s = %511[^\n\r]", key, val) >= 2) {
+                int vlen = (int)strlen(val);
+                while (vlen > 0 && (val[vlen-1] == ' ' || val[vlen-1] == '\t'
+                       || val[vlen-1] == '\r' || val[vlen-1] == '\n'))
+                    val[--vlen] = '\0';
+                char *lead = val;
+                while (*lead == ' ' || *lead == '\t') lead++;
+                if (strcmp(key, "file") == 0) {
+                    strncpy(g_flog_path, lead, sizeof(g_flog_path) - 1);
+                } else if (strcmp(key, "rotate_ms") == 0) {
+                    g_flog_rotate_ms = atol(lead);
+                } else if (strcmp(key, "max_bytes") == 0) {
+                    g_flog_max_bytes = atol(lead);
+                }
+            }
+        } else if (section == 17) {
+            char key[64], val[64];
+            if (sscanf(s, "%63s = %63s", key, val) >= 2) {
+                if (strcmp(key, "enable") == 0) {
+                    g_reboot_enable = atoi(val) != 0;
+                } else if (strcmp(key, "period") == 0) {
+                    g_reboot_period = (strcasecmp(val, "weekly") == 0) ? 1 : 0;
+                } else if (strcmp(key, "time") == 0) {
+                    int hh = 3, mm = 0;
+                    if (sscanf(val, "%d:%d", &hh, &mm) >= 1) {
+                        g_reboot_hour = (hh >= 0 && hh <= 23) ? hh : 3;
+                        g_reboot_min = (mm >= 0 && mm <= 59) ? mm : 0;
+                    }
+                } else if (strcmp(key, "weekday") == 0) {
+                    const char *dn[] = {"sunday", "monday", "tuesday",
+                                        "wednesday", "thursday", "friday", "saturday"};
+                    g_reboot_wday = -1;
+                    for (int i = 0; i < 7; i++)
+                        if (strcasecmp(val, dn[i]) == 0) g_reboot_wday = i;
+                }
+            }
+        } else if (section == 11) {
+            /* <index> <main-stream url> ; used only by single-channel layouts */
+            int idx;
+            char *url = strchr(s, ' ');
+            if (!url) url = strchr(s, '\t');
+            if (!url) continue;
+            *url++ = '\0';
+            idx = atoi(s);
+            while (*url == ' ' || *url == '\t') url++;
+            size_t len = strlen(url);
+            while (len > 0 && (url[len-1] == '\n' || url[len-1] == '\r')) url[--len] = 0;
+            if (idx >= 0 && idx < MAX_STREAMS && len > 0) {
+                size_t cp = len < sizeof(workers[idx].url_main) - 1 ?
+                            len : sizeof(workers[idx].url_main) - 1;
+                memcpy(workers[idx].url_main, url, cp);
+                workers[idx].url_main[cp] = '\0';
+            }
+        } else if (section == 12) {
+            /* [camera_list]  <id> <ip> <user> <pass> [login_chan] [rtsp_port] [http_port] */
+            int id, chan = 1, port = 554, http_port = 80;
+            char ip[64], user[64], pass[64];
+            int nf = sscanf(s, "%d %63s %63s %63s %d %d %d",
+                            &id, ip, user, pass, &chan, &port, &http_port);
+            if (nf >= 4 && id >= 0 && id < MAX_STREAMS) {
+                HvCam *c = &g_cams[id];
+                memset(c, 0, sizeof(*c));
+                c->id = id;
+                strncpy(c->ip, ip, sizeof(c->ip) - 1);
+                strncpy(c->user, user, sizeof(c->user) - 1);
+                strncpy(c->pass, pass, sizeof(c->pass) - 1);
+                c->login_chan = (nf >= 5 && chan > 0) ? chan : 1;
+                c->port = (nf >= 6 && port > 0) ? port : 554;
+                c->http_port = (nf >= 7 && http_port > 0) ? http_port : 80;
+                if (id + 1 > g_num_cams) g_num_cams = id + 1;
+                hv_build_urls(c, &g_urls[id]);
+            }
+        } else if (section == 13) {
+            /* [isapi_write]  <id> <main|sub> <width> <height> <fps> <bitrate_kbps>
+             * 可选:启动时通过 ISAPI 把指定通道改成给定分辨率/帧率/码率。 */
+            char stream[16];
+            int id, w = 0, h = 0, fps = 0, br = 0;
+            int nf = sscanf(s, "%d %15s %d %d %d %d",
+                            &id, stream, &w, &h, &fps, &br);
+            if (nf == 6 && id >= 0 && id < MAX_STREAMS &&
+                g_isapi_write_count < MAX_ISAPI_WRITE) {
+                IsapiWrite *r = &g_isapi_write[g_isapi_write_count];
+                r->id = id;
+                r->main = (strcmp(stream, "main") == 0);
+                r->width = w; r->height = h; r->fps = fps; r->bitrate = br;
+                g_isapi_write_count++;
+            }
+        }
+    }
+    fclose(fp);
+    if (g_num_cams > 0) {
+        /* A camera list entirely replaces the manual streams: one worker per
+         * camera, using ISAPI-derived sub/main RTSP URLs. */
+        nstreams = g_num_cams;
+        for (int i = 0; i < nstreams && i < MAX_STREAMS; i++) {
+            strncpy(workers[i].url, g_urls[i].sub, sizeof(workers[i].url) - 1);
+            strncpy(workers[i].url_main, g_urls[i].main,
+                    sizeof(workers[i].url_main) - 1);
+        }
+    }
+    if (nstreams == 0) return -1;
+    if (ntiles == 0) {
+        ntiles = nstreams;
+        for (int i = 0; i < ntiles; i++) tilemap[i] = i;
+    }
+    if (g_reconnect_ms < 100) g_reconnect_ms = 100;
+    if (g_reconnect_max_ms < g_reconnect_ms) g_reconnect_max_ms = g_reconnect_ms;
+    if (g_connect_timeout_sec < 1) g_connect_timeout_sec = 1;
+    if (g_hold_layout_sec < 0) g_hold_layout_sec = 0;
+    if (g_layout_debounce_ms < 0) g_layout_debounce_ms = 0;
+    *p_nstreams = nstreams;
+    *p_ntiles = ntiles;
+    return 0;
+}
+
+/* HikVision ISAPI startup: optional parameter writes first, then read-and-log
+ * the encoding params of every configured camera. Each request can block up
+ * to 8 s, so this runs on a detached background thread and must not delay
+ * video startup. Uses g_cams/g_urls which are only written during
+ * parse_config (before threads start). */
+static void *isapi_startup_thread(void *arg) {
+    (void)arg;
+    for (int w = 0; w < g_isapi_write_count; w++) {
+        IsapiWrite *r = &g_isapi_write[w];
+        if (r->id < 0 || r->id >= g_num_cams) continue;
+        int enc = (g_cams[r->id].login_chan * 100) + (r->main ? 1 : 2);
+        /* Hikvision stores frame rate x100 (e.g. 25fps -> 2500). */
+        hv_write_params(&g_cams[r->id], enc, r->width, r->height,
+                        r->fps * 100, r->bitrate);
+    }
+    for (int i = 0; i < g_num_cams; i++) {
+        if (!g_running) break;
+        hv_read_and_log(&g_cams[i], g_cams[i].login_chan * 100 + 1);
+        hv_read_and_log(&g_cams[i], g_cams[i].login_chan * 100 + 2);
+    }
+    return NULL;
+}
+
+/* Scheduled reboot ([auto_reboot]): wake up exactly at HH:MM (local time),
+ * daily or on the configured weekday, then reboot the device once. The reboot
+ * is executed by a forked child so a hang in the reboot path can't wedge the
+ * running application. */
+static void reboot_now(void) {
+    fprintf(stderr, "DBG: auto reboot triggered at %ld\n", (long)time(NULL));
+    sync();
+    pid_t pid = fork();
+    if (pid == 0) {
+        setsid();
+        sleep(2);
+        execl("/sbin/reboot", "reboot", (char *)NULL);
+        _exit(127);
+    }
+}
+
+static void *reboot_thread(void *arg) {
+    (void)arg;
+    while (1) {
+        time_t now = time(NULL);
+        struct tm t;
+        localtime_r(&now, &t);
+        struct tm cand = t;
+        time_t next = 0;
+        for (int guard = 0; guard < 10; guard++) {
+            struct tm c = cand;
+            c.tm_sec = 0;
+            c.tm_min = g_reboot_min;
+            c.tm_hour = g_reboot_hour;
+            if (g_reboot_period == 1 && g_reboot_wday >= 0) {
+                time_t w = mktime(&c);
+                struct tm m;
+                localtime_r(&w, &m);
+                if (m.tm_wday != g_reboot_wday) { cand.tm_mday++; continue; }
+            }
+            next = mktime(&c);
+            if (next > now) break;
+            cand.tm_mday++;
+        }
+        long dt = (long)(next - now);
+        if (dt < 15 || dt > 2 * 24 * 3600) dt = 24 * 3600;
+        struct timespec ts;
+        ts.tv_sec = dt;
+        ts.tv_nsec = 0;
+        nanosleep(&ts, NULL);
+        if (g_reboot_enable) reboot_now();
+    }
+    return NULL;
+}
+
+static void display_rebuild_now(void) {
+    fprintf(stderr, "display: rebuilding output\n");
+    if (g_display) display_destroy(g_display);
+    for (int tries = 0; tries < 10 && g_running; tries++) {
+        g_display = display_create(NULL);
+        if (!g_display) {
+            fprintf(stderr, "display: recreate failed, retrying in 1s\n");
+            sleep(1);
+            continue;
+        }
+        usleep(500000);
+        /* Ask for the size our existing output buffers were allocated at, so
+         * display_init prefers a matching connector mode. Without this a
+         * monitor hot-plug can re-enumerate a different preferred mode and the
+         * scanout buffer no longer matches the CRTC (drmModeAddFB2 EINVAL
+         * rebuild loop). */
+        if (display_init(g_display, output_w, output_h) == 0) {
+            fprintf(stderr, "display: rebuilt OK\n");
+            return;
+        }
+        fprintf(stderr, "display: init deferred (no monitor), retrying in 1s\n");
+        display_destroy(g_display);
+        g_display = NULL;
+        sleep(1);
+    }
+    fprintf(stderr, "display: rebuild deferred, will retry later\n");
+}
+
+int main(int argc, char **argv) {
+    fprintf(stderr, "DBG: starting\n");
+    install_signal_handlers();
+
+    const char *config_path = NULL;
+
+    if (argc >= 3 && strcmp(argv[1], "-c") == 0) {
+        fprintf(stderr, "DBG: parsing config\n");
+        config_path = argv[2];
+        if (parse_config(config_path, &output_w, &output_h,
+                         &num_streams, &num_tiles, tile_map) < 0) {
+            fprintf(stderr, "Failed to parse config: %s\n", config_path);
+            return 1;
+        }
+        fprintf(stderr, "DBG: parsed: %dx%d %d streams %d tiles\n",
+                output_w, output_h, num_streams, num_tiles);
+        if (flog_init(g_flog_path, g_flog_rotate_ms, g_flog_max_bytes) < 0)
+            fprintf(stderr, "WARN: fault log disabled (%s)\n", g_flog_path);
+
+        /* ISAPI read/write on a background thread: video must not wait on
+         * camera HTTP round-trips. */
+        if (g_num_cams > 0) {
+            pthread_t isapi_thr;
+            if (pthread_create(&isapi_thr, NULL, isapi_startup_thread, NULL) == 0)
+                pthread_detach(isapi_thr);
+        }
+    } else if (argc >= 3) {
+        output_w = atoi(argv[1]);
+        output_h = atoi(argv[2]);
+        num_streams = argc - 3;
+        if (num_streams > MAX_STREAMS) num_streams = MAX_STREAMS;
+        if (num_streams < 1) { printf("At least 1 RTSP URL required\n"); return 1; }
+        num_tiles = num_streams;
+        for (int i = 0; i < num_tiles; i++) {
+            tile_map[i] = i;
+            strncpy(workers[i].url, argv[3 + i], sizeof(workers[i].url) - 1);
+        }
+    } else {
+        printf("Usage 1: %s <width> <height> <url1> [url2...]\n", argv[0]);
+        printf("Usage 2: %s -c <config.conf>\n", argv[0]);
+        printf("Config sections: [output], [streams], [main_streams], [camera_list],\n");
+        printf("  [isapi_write], [layout], [layoutN], [plc], [plc_rules], [title], [log]\n");
+        return 1;
+    }
+
+    if (g_plc_enabled && g_plc_cfg.ip[0]) {
+        if (g_plc_cfg.port <= 0) g_plc_cfg.port = 102;
+        if (g_plc_cfg.area == 0) g_plc_cfg.area = PLC_AREA_M;
+        if (g_plc_cfg.read_len <= 0) g_plc_cfg.read_len = 1;
+        if (g_plc_cfg.tsap_local == -1) g_plc_cfg.tsap_local = 0x0100;
+        if (g_plc_cfg.tsap_remote == -1) g_plc_cfg.tsap_remote = 0x0102;
+        if (plc_init(&g_plc_cfg) < 0) {
+            fprintf(stderr, "WARN: plc_init failed (%s)\n", plc_last_error());
+            g_plc_enabled = 0;
+        } else {
+            fprintf(stderr, "DBG: plc enabled %s:%d area=0x%02x db=%d start=%d len=%d\n",
+                    g_plc_cfg.ip, g_plc_cfg.port, g_plc_cfg.area, g_plc_cfg.db,
+                    g_plc_cfg.read_start, g_plc_cfg.read_len);
+        }
+    }
+
+    /* Start embedded web config server */
+    if (config_path) {
+        web_server_start(config_path, WEB_SERVER_PORT_DEFAULT,
+                         WEB_SERVER_PASS_DEFAULT);
+    }
+
+    if (g_reboot_enable) {
+        pthread_t rbt;
+        if (pthread_create(&rbt, NULL, reboot_thread, NULL) == 0) {
+            pthread_detach(rbt);
+            fprintf(stderr, "DBG: auto reboot on, %02d:%02d %s\n",
+                    g_reboot_hour, g_reboot_min,
+                    g_reboot_period == 1 ? "weekly" : "daily");
+        }
+    }
+
+    fprintf(stderr, "DBG: output %dx%d %d streams %d tiles\n",
+            output_w, output_h, num_streams, num_tiles);
+
+    /* Force the HDMI output to RGB: after a hotplug the VOP's YUV passthrough
+     * path renders with wrong colours (purple wall); RGB keeps the window on
+     * a full Y2R CSC path and the monitor locked to a deterministic format. */
+    display_set_color_format(1);
+    g_display = display_create(NULL);
+    if (!g_display) { fprintf(stderr, "Failed to create display\n"); return 1; }
+    /* If the board powers up without a monitor, the DRM connector has no mode
+     * and display_init fails ("display: no monitor ... retry"). Retry once per
+     * second so the video appears automatically as soon as a monitor is
+     * plugged in; without this the desktop stays up forever. */
+    while (g_running && display_init(g_display, 0, 0) < 0) {
+        fprintf(stderr, "display: init deferred (no monitor connected), retrying in 1s\n");
+        display_destroy(g_display);
+        g_display = display_create(NULL);
+        if (!g_display) { fprintf(stderr, "Failed to create display\n"); return 1; }
+        sleep(1);
+    }
+    if (!g_running) {           /* stop requested while waiting for a monitor */
+        display_destroy(g_display);
+        return 0;
+    }
+    output_w = display_get_width(g_display);
+    output_h = display_get_height(g_display);
+    fprintf(stderr, "DBG: display resolution %dx%d\n", output_w, output_h);
+
+    /* Title bar: enabled only if configured and height fits the output.
+     * bar  = solid bar, reserves g_title_h rows (video area shifted down);
+     * overlay = translucent strip drawn on top of the video, no space taken. */
+    g_title_h = 0;
+    g_title_overlay = (g_title.enable && g_title.type != 0);
+    if (g_title.enable && g_title.height > 0 && g_title.height < output_h) {
+        if (!g_title_overlay) {
+            g_title_h = g_title.height;
+            if (g_title_h & 1) g_title_h--;      /* keep UV rows aligned */
+            if (g_title_h < 2) g_title_h = 0;
+        }
+    }
+
+    output_stride = output_w;
+    output_size = output_w * output_h * 3 / 2;
+
+    for (int i = 0; i < 3; i++) {
+        /* Output buffers feed RGA as dmabuf fds. The RGA driver refuses any
+         * buffer whose physical pages sit above 4G ("RGA_MMU unsupported
+         * memory larger than 4G"). The board maps part of DDR above 4G and
+         * CMA is only 16MB, so prefer the dma32 heap (guaranteed <4G);
+         * cma is the next fallback (also <4G), plain memory last. */
+        if (dma_heap_alloc("system-uncached-dma32", output_size, &output_fd[i]) < 0) {
+            if (dma_heap_alloc("cma", output_size, &output_fd[i]) < 0) {
+                if (dma_heap_alloc("system-uncached", output_size, &output_fd[i]) < 0) {
+                    fprintf(stderr, "Failed to allocate DMA buffer %d\n", i);
+                    return 1;
+                }
+            }
+        }
+        output_map[i] = mmap(NULL, output_size, PROT_READ | PROT_WRITE,
+                             MAP_SHARED, output_fd[i], 0);
+        if (output_map[i] == MAP_FAILED) {
+            fprintf(stderr, "mmap failed for output buffer %d\n", i);
+            return 1;
+        }
+        memset(output_map[i], 0x80, output_size);
+        memset(output_map[i], 0x10, output_w * output_h);
+    }
+
+    /* Render the title bar once into all buffers (runtime cost = 0).
+     * In overlay mode this covers only the initial frame; the main loop
+     * re-draws it on top of every composed frame. */
+    if (g_title_h > 0 || g_title_overlay) {
+        int ok = 1;
+        for (int i = 0; i < 3; i++) {
+            struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_WRITE };
+            ioctl(output_fd[i], DMA_BUF_IOCTL_SYNC, &sync);
+            if (g_title_overlay) {
+                if (title_render_overlay(output_map[i], output_w, output_h, &g_title) != 0)
+                    ok = 0;
+            } else {
+                if (title_render(output_map[i], output_w, output_h, &g_title) != 0)
+                    ok = 0;
+            }
+            struct dma_buf_sync sync2 = { .flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_WRITE };
+            ioctl(output_fd[i], DMA_BUF_IOCTL_SYNC, &sync2);
+        }
+        if (ok) {
+            g_title_enabled = 1;
+            fprintf(stderr, "DBG: title %s %dx%d enabled\n",
+                    g_title_overlay ? "overlay" : "bar", output_w,
+                    g_title_overlay ? g_title.height : g_title_h);
+        } else {
+            /* fall back to no title bar: clear bar area and drop offset */
+            fprintf(stderr, "WARN: title render failed, disabling title bar\n");
+            g_title_h = 0;
+            g_title_overlay = 0;
+            for (int i = 0; i < 3; i++) {
+                memset(output_map[i], 0x80, output_size);
+                memset(output_map[i], 0x10, output_w * output_h);
+            }
+        }
+    } else {
+        fprintf(stderr, "DBG: title bar disabled\n");
+    }
+
+    g_composer = composer_create(output_w, output_h, num_tiles, g_title_h);
+    if (!g_composer) {
+        fprintf(stderr, "Failed to create composer\n");
+        display_destroy(g_display);
+        return 1;
+    }
+    composer_set_letterbox(g_composer, g_letterbox);
+
+    /* Layout the base grid. If the config gave row counts (via [layout]),
+     * compile it into merged regions; otherwise use a uniform grid. */
+    int flow_ok = 0;
+    if (g_split_enabled && g_split_top_n > 0) {
+        /* Split mode: start with the [layout_bottom] default group, fallback
+         * to the highest valid [layout_bottom_N]. */
+        int bottom_n = split_idle_bottom();
+        int *bid = NULL;
+        int bottom_cnt = split_bottom_cfg(bottom_n, &bid);
+        if (bottom_cnt < 1) bottom_cnt = 1;
+        int xs[MAX_TILES], ys[MAX_TILES], ws[MAX_TILES], hs[MAX_TILES], sid_arr[MAX_TILES];
+        int n = composer_split_rects(
+            output_w, output_h, g_title_h,
+            g_split_top_ids, g_split_top_n, g_split_top_ratio,
+            bid, bottom_cnt, bottom_cnt,
+            xs, ys, ws, hs, sid_arr);
+        if (n > 0) {
+            composer_set_tiles(g_composer, xs, ys, ws, hs, n);
+            g_disp_tiles = n;
+            for (int t = 0; t < n; t++)
+                g_disp_sid[t] = (sid_arr[t] >= 0 && sid_arr[t] < MAX_STREAMS) ? sid_arr[t] : -1;
+            flow_ok = 1;
+            fprintf(stderr, "DBG: split mode init top=%d bottom_idle=%d bottom_cnt=%d ratio=%.1f\n",
+                    g_split_top_n, bottom_n, bottom_cnt, g_split_top_ratio);
+        }
+    }
+    if (!flow_ok && g_grid_nrows > 0) {
+        int xs[MAX_TILES], ys[MAX_TILES], ws[MAX_TILES], hs[MAX_TILES], idx[MAX_TILES];
+        int n = composer_region_rects(output_w, output_h, g_grid_row_cols,
+                                      g_grid_nrows, tile_map,
+                                      xs, ys, ws, hs, idx, g_title_h);
+        if (n > 0) {
+            composer_set_tiles(g_composer, xs, ys, ws, hs, n);
+            g_disp_tiles = n;
+            for (int t = 0; t < n; t++)
+                g_disp_sid[t] = (idx[t] >= 0 && idx[t] < MAX_STREAMS) ? idx[t] : -1;
+            flow_ok = 1;
+        }
+    }
+    if (!flow_ok) {
+        for (int i = 0; i < num_tiles; i++) {
+            composer_get_tile_layout(g_composer, i, &g_grid_x[i], &g_grid_y[i],
+                                     &g_grid_w[i], &g_grid_h[i]);
+        }
+        g_disp_tiles = num_tiles;
+        for (int t = 0; t < g_disp_tiles; t++)
+            g_disp_sid[t] = (tile_map[t] >= 0 && tile_map[t] < MAX_STREAMS) ? tile_map[t] : -1;
+    } else {
+        for (int i = 0; i < g_disp_tiles && i < MAX_TILES; i++) {
+            int x, y, w, h;
+            composer_get_tile_layout(g_composer, i, &x, &y, &w, &h);
+            g_grid_x[i] = x; g_grid_y[i] = y; g_grid_w[i] = w; g_grid_h[i] = h;
+        }
+    }
+    fprintf(stderr, "DBG: layouts=%d rules=%d grid%d split=%d\n",
+            g_num_layouts, g_num_rules, flow_ok ? g_grid_nrows : -1,
+            g_split_enabled);
+
+    output_front = 0;
+    output_last = 1;   /* so first BACK_BUF() = 2 (not front, not last) */
+    if (display_commit(g_display, output_fd[output_front], output_stride) < 0)
+        fprintf(stderr, "Warning: initial display_commit failed\n");
+
+    for (int i = 0; i < num_streams; i++) {
+        StreamWorker *w = &workers[i];
+        w->stream_id = i;
+        w->running = 1;
+        w->connected = 0;
+        w->dec = NULL;
+        w->ring_count = 0;
+        w->ring_head = 0;
+        w->frame_seq = 0;
+        w->held_pin = -1;
+        w->use_main = 0;
+        w->dec_using_main = 0;
+        w->last_fd = -1;
+        w->last_w = 0; w->last_h = 0; w->last_stride = 0; w->last_vstride = 0;
+        w->last_seq_seen = -1;
+        w->last_frame_mono = -1e9;
+        pthread_mutex_init(&w->mutex, NULL);
+        pthread_cond_init(&w->cond, NULL);
+        pthread_create(&w->thread, NULL, worker_thread, w);
+    }
+
+    int active_streams = 0, connect_wait = 0;
+    while (g_running && active_streams == 0 && connect_wait < 200) {
+        active_streams = 0;
+        for (int i = 0; i < num_streams; i++)
+            if (workers[i].connected) active_streams++;
+        if (active_streams == 0) { usleep(50000); connect_wait++; }
+    }
+    if (active_streams != num_streams)
+        printf("Proceeding with %d/%d connected streams\n", active_streams, num_streams);
+
+    fprintf(stderr, "DBG: entering main loop\n");
+    printf("Entering display loop (%d tiles from %d streams)...\n", num_tiles, num_streams);
+
+    int frame_count = 0;
+    struct timeval last_tv;
+    gettimeofday(&last_tv, NULL);
+    struct timeval comp_stats_last = last_tv;
+
+    int held_idx[MAX_STREAMS];
+    int held_valid[MAX_STREAMS];
+    memset(held_valid, 0, sizeof(held_valid));
+
+    static int disp_was_lost = -1;      /* hotplug edge state */
+    static double last_disp_rebuild = -1e9;
+
+    while (g_running) {
+        /* Hotplug recovery: when an HDMI switch points this display away our
+         * connector reports disconnected; once it points back we must rebuild
+         * the DRM output (Xorg/desktop may have taken the CRTC meanwhile), or
+         * the screen stays on the desktop. Detect the lost->connected edge
+         * and re-create the display. */
+        if (g_rebuild_req) {
+            g_rebuild_req = 0;
+            fprintf(stderr, "display: SIGUSR1 rebuild requested\n");
+            display_rebuild_now();
+        }
+        if (g_display) {
+            int lost = display_signal_lost(g_display);
+            double now_m = 0;
+            struct timespec mt2;
+            clock_gettime(CLOCK_MONOTONIC, &mt2);
+            now_m = mt2.tv_sec + mt2.tv_nsec / 1000000000.0;
+            if (disp_was_lost == 1 && lost == 0 && now_m - last_disp_rebuild > 0.3) {
+                last_disp_rebuild = now_m;
+                fprintf(stderr, "display: monitor reconnected, rebuilding output\n");
+                display_rebuild_now();
+            }
+            disp_was_lost = lost ? 1 : 0;
+        } else {
+            /* No live display (rebuild gave up while unplugged): keep retrying
+             * so a later plug-in brings the picture back by itself. */
+            double now_m = 0;
+            struct timespec mt2;
+            clock_gettime(CLOCK_MONOTONIC, &mt2);
+            now_m = mt2.tv_sec + mt2.tv_nsec / 1000000000.0;
+            if (now_m - last_disp_rebuild > 0.2) {
+                last_disp_rebuild = now_m;
+                display_rebuild_now();
+            }
+        }
+
+        int src_fds[MAX_STREAMS];
+        int src_widths[MAX_STREAMS];
+        int src_heights[MAX_STREAMS];
+        int src_strides[MAX_STREAMS];
+        int src_vstrides[MAX_STREAMS];
+        int valid_srcs = 0;
+        struct timespec mts;
+        clock_gettime(CLOCK_MONOTONIC, &mts);
+        double mono_now = mts.tv_sec + mts.tv_nsec / 1000000000.0;
+
+        for (int i = 0; i < num_streams; i++) {
+            StreamWorker *w = &workers[i];
+            src_fds[i] = -1;
+            held_valid[i] = 0;
+            pthread_mutex_lock(&w->mutex);
+            if (!w->connected) {
+                /* Worker is tearing down / reconnecting: drain stale frames. */
+                for (int j = 0; j < w->ring_count; j++) {
+                    int ri = (w->ring_head + j) % 2;
+                    if (w->ring[ri].idx >= 0 && w->dec)
+                        decoder_release_frame(w->dec, w->ring[ri].idx);
+                    if (w->ring[ri].fd >= 0) close(w->ring[ri].fd);
+                    w->ring[ri].fd = -1;
+                    w->ring[ri].idx = -1;
+                }
+                w->ring_count = 0;
+                w->held_pin = -1;
+                pthread_cond_broadcast(&w->cond);
+                pthread_mutex_unlock(&w->mutex);
+                continue;
+            }
+            if (w->ring_count > 0) {
+                int latest = (w->ring_head + w->ring_count - 1) % 2;
+                src_fds[i] = fcntl(w->ring[latest].fd, F_DUPFD_CLOEXEC, 3);
+                src_widths[i] = w->ring[latest].width;
+                src_heights[i] = w->ring[latest].height;
+                src_strides[i] = w->ring[latest].hor_stride;
+                src_vstrides[i] = w->ring[latest].ver_stride;
+                held_idx[i] = w->ring[latest].idx;
+                /* A new frame arrived (seq changed): timestamp it. If the
+                 * worker is stuck on a silent stream the same seq keeps being
+                 * re-acquired, so this stays stale and the tile eventually
+                 * falls back to NO SIGNAL. */
+                if (w->ring[latest].pts != w->last_seq_seen) {
+                    w->last_seq_seen = w->ring[latest].pts;
+                    w->last_frame_mono = mono_now;
+                    /* Refresh the frozen last-frame handle (only on new frames;
+                     * the dup stays valid even after the decoder is destroyed
+                     * on a reconnect, so the tile can keep showing this frame
+                     * during the gap). */
+                    if (w->last_fd >= 0) close(w->last_fd);
+                    w->last_fd = fcntl(w->ring[latest].fd, F_DUPFD_CLOEXEC, 3);
+                    w->last_w = w->ring[latest].width;
+                    w->last_h = w->ring[latest].height;
+                    w->last_stride = w->ring[latest].hor_stride;
+                    w->last_vstride = w->ring[latest].ver_stride;
+                }
+                /* Release dropped frames (all except latest). */
+                for (int j = 0; j < w->ring_count - 1; j++) {
+                    int ri = (w->ring_head + j) % 2;
+                    if (w->dec) decoder_release_frame(w->dec, w->ring[ri].idx);
+                    if (w->ring[ri].fd >= 0) close(w->ring[ri].fd);
+                    w->ring[ri].fd = -1;
+                }
+                w->ring_count = 1;
+                w->ring_head = latest;
+                w->held_pin = held_idx[i];
+                held_valid[i] = 1;
+                valid_srcs++;
+            } else {
+                w->held_pin = -1;
+            }
+            pthread_cond_broadcast(&w->cond);
+            pthread_mutex_unlock(&w->mutex);
+        }
+
+        /* --- PLC-driven layout selection --- */
+        int active_layout = -1;   /* -1 = grid ([layout]) */
+        int plc_ok = g_plc_enabled && plc_is_connected();
+        if (plc_ok) {
+            /* Among matching rules, the most specific one wins:
+             * more groups first, then more bits, then earlier in file. */
+            int best_g = -1, best_b = -1;
+            for (int r = 0; r < g_num_rules; r++) {
+                PlcRule *R = &g_rules[r];
+                /* Default: any bit inside a ';'-group is enough (OR within a
+                 * group) and every group must be satisfied (AND across
+                 * groups), e.g. `3 : 6.6,6.7;8.4,8.5` -> (6.6|6.7)&(8.4|8.5).
+                 * Trailing `: all` also requires every bit inside a group. */
+                int match = 1;
+                for (int g = 0; g < R->ngroups; g++) {
+                    int gok = R->all ? 1 : 0;
+                    for (int b = 0; b < R->nbits; b++) {
+                        if (R->group[b] != g) continue;
+                        int v = plc_get_bit(R->bits[b].byte_idx,
+                                            R->bits[b].bit);
+                        if (R->all) {
+                            if (v <= 0) { gok = 0; break; }
+                        } else {
+                            if (v < 0) continue;
+                            if (v) { gok = 1; break; }
+                        }
+                    }
+                    if (!gok) { match = 0; break; }
+                }
+                if (!match) continue;
+                if (R->ngroups > best_g ||
+                    (R->ngroups == best_g && R->nbits > best_b)) {
+                    best_g = R->ngroups;
+                    best_b = R->nbits;
+                    active_layout = R->layout;
+                }
+            }
+        }
+        struct timeval layout_tv;
+        gettimeofday(&layout_tv, NULL);
+        double layout_now = layout_tv.tv_sec + layout_tv.tv_usec / 1000000.0;
+        if (plc_ok) {
+            g_last_layout_ts = layout_now;
+        } else if (g_hold_layout_sec > 0 && g_cur_layout >= 0 &&
+                   layout_now - g_last_layout_ts < g_hold_layout_sec) {
+            /* PLC briefly lost: keep the last good layout. */
+            active_layout = g_cur_layout;
+        }
+        if (active_layout != g_cur_layout) {
+            /* Debounce: only commit a layout change once the new layout has
+             * been stable for g_layout_debounce_ms. A momentary PLC bit pulse
+             * (point triggers then clears within one scan) is thus ignored and
+             * the screen does not flicker. The very first layout applies at
+             * once so there is no startup delay. */
+            int do_switch;
+            if (g_cur_layout < 0 || g_layout_debounce_ms <= 0) {
+                do_switch = 1;
+                g_pending_layout = -1;
+            } else {
+                if (active_layout != g_pending_layout) {
+                    g_pending_layout = active_layout;
+                    g_pending_layout_ts = layout_now;
+                }
+                do_switch = (layout_now - g_pending_layout_ts) * 1000.0
+                            >= g_layout_debounce_ms;
+            }
+            if (do_switch) {
+            g_pending_layout = -1;
+            /* ---- Split mode: fixed top + adaptive bottom ---- */
+            if (g_split_enabled && g_split_top_n > 0) {
+                int bottom_n;
+                if (active_layout < 0) {
+                    /* No PLC rule matched: default bottom ([layout_bottom]). */
+                    bottom_n = split_idle_bottom();
+                } else {
+                    bottom_n = active_layout + 1;  /* layout 0→1cam, 1→2cam, 2→3cam */
+                    if (bottom_n < 1) bottom_n = 1;
+                    if (bottom_n > SPLIT_MAX_BOTTOM) bottom_n = SPLIT_MAX_BOTTOM;
+                }
+                if (bottom_n > 0 && !g_split_bottom_valid[bottom_n - 1]) {
+                    /* Fallback: find first valid bottom variant */
+                    for (int bv = SPLIT_MAX_BOTTOM; bv >= 1; bv--)
+                        if (g_split_bottom_valid[bv - 1]) { bottom_n = bv; break; }
+                }
+                int *bid = NULL;
+                int bottom_cnt = split_bottom_cfg(bottom_n, &bid);
+                if (bottom_cnt < 1) bottom_cnt = 1;
+                int xs[MAX_TILES], ys[MAX_TILES], ws[MAX_TILES], hs[MAX_TILES], sid[MAX_TILES];
+                int placement = composer_split_rects(
+                    output_w, output_h, g_title_h,
+                    g_split_top_ids, g_split_top_n, g_split_top_ratio,
+                    bid, bottom_cnt, bottom_cnt,
+                    xs, ys, ws, hs, sid);
+                if (placement > 0) {
+                    composer_set_tiles(g_composer, xs, ys, ws, hs, placement);
+                    g_disp_tiles = placement;
+                    for (int t = 0; t < placement; t++)
+                        g_disp_sid[t] = (sid[t] >= 0 && sid[t] < MAX_STREAMS) ? sid[t] : -1;
+                }
+                g_cur_layout = active_layout;
+            } else if (active_layout >= 0 && active_layout < g_num_layouts) {
+                LayoutDef *L = &g_layouts[active_layout];
+                int ncell = L->n; if (ncell > MAX_TILES) ncell = MAX_TILES;
+                int xs[MAX_TILES], ys[MAX_TILES], ws[MAX_TILES], hs[MAX_TILES];
+                int idx[MAX_TILES];   /* region -> region sid mapping */
+                int placement = 0;
+                if (L->nrows > 0) {
+                    /* named layout: merged regions for same-label cells
+                     * (cells are already flattened in L->map, row-major) */
+                    placement = composer_region_rects(
+                        output_w, output_h, L->row_cols, L->nrows, L->map,
+                        xs, ys, ws, hs, idx, g_title_h);
+                }
+                if (placement <= 0) {        /* fallback: uniform grid */
+                    composer_grid_rects(output_w, output_h, ncell,
+                                        xs, ys, ws, hs, g_title_h);
+                    placement = ncell;
+                    for (int t = 0; t < placement; t++) idx[t] = L->map[t];
+                }
+                composer_set_tiles(g_composer, xs, ys, ws, hs, placement);
+                g_disp_tiles = placement;
+                for (int t = 0; t < placement; t++)
+                    g_disp_sid[t] = (idx[t] >= 0 && idx[t] < MAX_STREAMS) ? idx[t] : -1;
+            } else if (g_split_enabled && g_split_top_n > 0) {
+                /* Split mode, no PLC rule: default bottom ([layout_bottom]) */
+                int bottom_n = split_idle_bottom();
+                int *bid = NULL;
+                int bottom_cnt = split_bottom_cfg(bottom_n, &bid);
+                if (bottom_cnt < 1) bottom_cnt = 1;
+                int xs[MAX_TILES], ys[MAX_TILES], ws[MAX_TILES], hs[MAX_TILES], sid_arr[MAX_TILES];
+                int placement = composer_split_rects(
+                    output_w, output_h, g_title_h,
+                    g_split_top_ids, g_split_top_n, g_split_top_ratio,
+                    bid, bottom_cnt, bottom_cnt,
+                    xs, ys, ws, hs, sid_arr);
+                if (placement > 0) {
+                    composer_set_tiles(g_composer, xs, ys, ws, hs, placement);
+                    g_disp_tiles = placement;
+                    for (int t = 0; t < placement; t++)
+                        g_disp_sid[t] = (sid_arr[t] >= 0 && sid_arr[t] < MAX_STREAMS) ? sid_arr[t] : -1;
+                }
+            } else {
+                /* base grid: compile to regions as well (merges repeated ids) */
+                if (g_grid_nrows > 0) {
+                    int xs[MAX_TILES], ys[MAX_TILES], ws[MAX_TILES], hs[MAX_TILES], idx[MAX_TILES];
+                    int placement = composer_region_rects(
+                        output_w, output_h, g_grid_row_cols, g_grid_nrows, tile_map,
+                        xs, ys, ws, hs, idx, g_title_h);
+                    if (placement > 0) {
+                        composer_set_tiles(g_composer, xs, ys, ws, hs, placement);
+                        g_disp_tiles = placement;
+                        for (int t = 0; t < placement; t++)
+                            g_disp_sid[t] = (idx[t] >= 0 && idx[t] < MAX_STREAMS) ? idx[t] : -1;
+                    } else {
+                        composer_set_tiles(g_composer, g_grid_x, g_grid_y,
+                                           g_grid_w, g_grid_h, num_tiles);
+                        g_disp_tiles = num_tiles;
+                        for (int t = 0; t < g_disp_tiles; t++)
+                            g_disp_sid[t] = tile_map[t];
+                    }
+                } else {
+                    composer_set_tiles(g_composer, g_grid_x, g_grid_y,
+                                       g_grid_w, g_grid_h, num_tiles);
+                    g_disp_tiles = num_tiles;
+                    for (int t = 0; t < g_disp_tiles; t++)
+                        g_disp_sid[t] = tile_map[t];
+                }
+            }
+            fprintf(stderr, "DBG: layout switched to %d\n", active_layout);
+            g_cur_layout = active_layout;
+            }   /* do_switch */
+        } else {
+            /* active_layout returned to the current one before the debounce
+             * elapsed: cancel any pending change so a later pulse starts a
+             * fresh, full debounce window. */
+            g_pending_layout = -1;
+        }
+
+        /* --- Single-channel active layout: the shown stream's worker
+             switches to its main-channel URL. A layout is single-channel when
+             it compiles to exactly one region. --- */
+        {
+            int sid = -1;
+            if (g_disp_tiles == 1) {
+                sid = (g_disp_sid[0] >= 0) ? g_disp_sid[0] : -1;
+            }
+            for (int i = 0; i < num_streams; i++) {
+                StreamWorker *w = &workers[i];
+                int want = (i == sid && w->url_main[0]);
+                pthread_mutex_lock(&w->mutex);
+                w->use_main = want;
+                pthread_mutex_unlock(&w->mutex);
+            }
+        }
+
+        if (valid_srcs > 0) {
+            int back = BACK_BUF();
+
+            /* Clear the video area only; the title bar rows are drawn once
+             * at startup and must not be wiped each frame. */
+            {
+                struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_WRITE };
+                ioctl(output_fd[back], DMA_BUF_IOCTL_SYNC, &sync);
+            }
+            memset((uint8_t *)output_map[back] + (size_t)output_w * g_title_h, 0x10,
+                   (size_t)output_w * (output_h - g_title_h));
+            memset((uint8_t *)output_map[back] + (size_t)output_w * output_h +
+                   (size_t)output_w * (g_title_h / 2), 0x80,
+                   (size_t)output_w * (output_h - g_title_h) / 2);
+            {
+                struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_WRITE };
+                ioctl(output_fd[back], DMA_BUF_IOCTL_SYNC, &sync);
+            }
+
+            /* --- Composition --- */
+            int tile_fds[MAX_TILES];
+            int tile_sids[MAX_TILES];
+            int tile_widths[MAX_TILES];
+            int tile_heights[MAX_TILES];
+            int tile_strides[MAX_TILES];
+            int tile_vstrides[MAX_TILES];
+
+            /* Resolve each stream to its display source: a fresh frame when
+             * available, else the held last frame if the stream is only
+             * briefly quiet (switch/reconnect), else nothing (NO SIGNAL). */
+            int disp_fd[MAX_STREAMS];
+            int disp_w[MAX_STREAMS];
+            int disp_h[MAX_STREAMS];
+            int disp_stride[MAX_STREAMS];
+            int disp_vstride[MAX_STREAMS];
+            for (int i = 0; i < num_streams; i++) {
+                StreamWorker *sw = &workers[i];
+                disp_fd[i] = -1;
+                if (mono_now - sw->last_frame_mono <= HOLD_LAST_FRAME_MS / 1000.0) {
+                    if (src_fds[i] >= 0) {
+                        disp_fd[i] = src_fds[i];
+                        disp_w[i] = src_widths[i];
+                        disp_h[i] = src_heights[i];
+                        disp_stride[i] = src_strides[i];
+                        disp_vstride[i] = src_vstrides[i];
+                    } else if (sw->last_fd >= 0) {
+                        disp_fd[i] = sw->last_fd;
+                        disp_w[i] = sw->last_w;
+                        disp_h[i] = sw->last_h;
+                        disp_stride[i] = sw->last_stride;
+                        disp_vstride[i] = sw->last_vstride;
+                    }
+                }
+            }
+
+            /* Composition uses the precompiled regions of the active layout
+             * (g_disp_sid[] / g_disp_tiles), each region mapping to one camera
+             * and one screen rect already set on the composer. */
+            int cur_tiles = g_disp_tiles;
+            if (cur_tiles > MAX_TILES) cur_tiles = MAX_TILES;
+            for (int t = 0; t < cur_tiles; t++) {
+                int sid = g_disp_sid[t];
+                if (sid >= 0 && sid < num_streams && disp_fd[sid] >= 0) {
+                    tile_sids[t] = sid;
+                    tile_fds[t] = disp_fd[sid];
+                    tile_widths[t] = disp_w[sid];
+                    tile_heights[t] = disp_h[sid];
+                    tile_strides[t] = disp_stride[sid];
+                    tile_vstrides[t] = disp_vstride[sid];
+                } else {
+                    tile_sids[t] = -1;
+                    tile_fds[t] = -1;
+                }
+            }
+
+            int ret = composer_compose(g_composer, tile_fds, tile_widths,
+                                        tile_heights, tile_strides, tile_vstrides,
+                                        cur_tiles, output_fd[back], output_stride);
+            if (ret != 0) {
+                flog_throttled("compose", 10000, "合成失败 (compose ret=%d)", ret);
+            }
+            /* Commit even when compose fails: the buffer holds the fresh
+             * dark clear, and committing keeps the pipeline alive so the
+             * screen auto-recovers once RGA is healthy again. */
+            {
+                /* Invalidate the CPU cache so the CPU-side overlays (NO
+                 * SIGNAL / detection / translucent title) blend against the
+                 * freshly written pixels, not stale ones. Without this, the
+                 * translucent title strip tears when the content under it
+                 * changes (e.g. layout switch). */
+                {
+                    struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_WRITE };
+                    ioctl(output_fd[back], DMA_BUF_IOCTL_SYNC, &sync);
+                }
+                /* --- Draw detection boxes + NO SIGNAL overlays --- */
+                if (g_grid_lines)
+                    draw_grid_lines((uint8_t *)output_map[back], output_w, output_h,
+                                    g_composer, cur_tiles);
+                if (g_no_signal_text) {
+                    for (int t = 0; t < cur_tiles; t++) {
+                        if (tile_sids[t] >= 0) continue;
+                        int tile_x, tile_y, tile_w, tile_h;
+                        composer_get_tile_layout(g_composer, t,
+                                                 &tile_x, &tile_y, &tile_w, &tile_h);
+                        int fsz = tile_h / 4;
+                        if (fsz < 16) fsz = 16;
+                        if (fsz > 72) fsz = 72;
+                        title_render_text(output_map[back], output_w, output_h,
+                                          g_title.font, "NO SIGNAL", fsz, 0xC0C0C0,
+                                          tile_x + tile_w / 2, tile_y + tile_h / 2);
+                    }
+                }
+                if (g_title_overlay && g_title_enabled) {
+                    title_render_overlay(output_map[back], output_w, output_h,
+                                         &g_title);
+                }
+                if (g_plc_enabled) {
+                    title_render_plc_status(output_map[back], output_w, output_h,
+                                            g_title.font, plc_is_connected());
+                }
+                update_ip_text();
+                title_render_ip(output_map[back], output_w, output_h,
+                                g_title.font, g_ip_text);
+
+                log_plc_state_change();
+
+                __sync_synchronize();
+                /* Flush CPU-written detection boxes so RGA DMA reads them correctly */
+                {
+                    struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_WRITE };
+                    ioctl(output_fd[back], DMA_BUF_IOCTL_SYNC, &sync);
+                }
+                if (g_display && display_commit(g_display, output_fd[back], output_stride) == 0) {
+                    output_last = output_front;
+                    output_front = back;
+                    frame_count++;
+                    display_fail_streak = 0;
+                } else {
+                    flog_throttled("commit", 10000, "显示提交失败 (display_commit)");
+                    if (g_display && ++display_fail_streak >= 5) {
+                        display_fail_streak = 0;
+                        display_rebuild_now();
+                    }
+                }
+            }
+        } else {
+            /* All streams dead / never connected: show a dark NO SIGNAL frame
+             * instead of freezing the last frame. Nothing changes between
+             * iterations here, so throttle to ~5 fps instead of burning the
+             * CPU + display pipeline at vblank rate. */
+            static double last_nosig_commit = -1e9;
+            if (mono_now - last_nosig_commit < 0.2) {
+                log_plc_state_change();
+                goto release_held;
+            }
+            last_nosig_commit = mono_now;
+
+            int back = BACK_BUF();
+            {
+                struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_START | DMA_BUF_SYNC_WRITE };
+                ioctl(output_fd[back], DMA_BUF_IOCTL_SYNC, &sync);
+            }
+            memset((uint8_t *)output_map[back] + (size_t)output_w * g_title_h, 0x08,
+                   (size_t)output_w * (output_h - g_title_h));
+            memset((uint8_t *)output_map[back] + (size_t)output_w * output_h +
+                   (size_t)output_w * (g_title_h / 2), 0x80,
+                   (size_t)output_w * (output_h - g_title_h) / 2);
+            if (g_grid_lines)
+                draw_grid_lines((uint8_t *)output_map[back], output_w, output_h,
+                                g_composer, g_disp_tiles);
+            if (g_no_signal_text) {
+                title_render_text(output_map[back], output_w, output_h,
+                                  g_title.font, "NO SIGNAL", 96, 0xC0C0C0,
+                                  0, output_h / 2);
+            }
+            if (g_title_overlay && g_title_enabled) {
+                title_render_overlay(output_map[back], output_w, output_h,
+                                     &g_title);
+            }
+            update_ip_text();
+            title_render_ip(output_map[back], output_w, output_h,
+                            g_title.font, g_ip_text);
+
+            log_plc_state_change();
+
+            {
+                struct dma_buf_sync sync = { .flags = DMA_BUF_SYNC_END | DMA_BUF_SYNC_WRITE };
+                ioctl(output_fd[back], DMA_BUF_IOCTL_SYNC, &sync);
+            }
+            if (g_display && display_commit(g_display, output_fd[back], output_stride) == 0) {
+                output_last = output_front;
+                output_front = back;
+                frame_count++;
+                display_fail_streak = 0;
+            } else {
+                flog_throttled("commit", 10000, "显示提交失败 (display_commit)");
+                if (g_display && ++display_fail_streak >= 5) {
+                    display_fail_streak = 0;
+                    display_rebuild_now();
+                }
+            }
+        }
+
+        /* --- Release held decoder frames --- */
+release_held:
+        for (int i = 0; i < num_streams; i++) {
+            StreamWorker *w = &workers[i];
+            if (src_fds[i] >= 0) close(src_fds[i]);
+            pthread_mutex_lock(&w->mutex);
+            if (held_valid[i]) {
+                if (w->dec) decoder_release_frame(w->dec, held_idx[i]);
+                held_valid[i] = 0;
+                w->held_pin = -1;
+            }
+            pthread_cond_broadcast(&w->cond);
+            pthread_mutex_unlock(&w->mutex);
+        }
+
+        struct timeval now;
+        gettimeofday(&now, NULL);
+        double elapsed = (now.tv_sec - last_tv.tv_sec) +
+                         (now.tv_usec - last_tv.tv_usec) / 1000000.0;
+        if (elapsed >= 5.0) {
+            printf("Display FPS: %.1f\n", frame_count / elapsed);
+            frame_count = 0;
+            last_tv = now;
+        }
+        /* Every 15s report per-tile compose health (dirty tiles only) so a
+         * recurring "black flashing tile" is attributable from the log. */
+        if (now.tv_sec - comp_stats_last.tv_sec >= 15) {
+            composer_dump_stats(g_composer);
+            comp_stats_last = now;
+        }
+        flog_rotate_check();
+        usleep(1000);
+    }
+
+    printf("Shutting down...\n");
+    flog_shutdown();
+    if (g_plc_enabled) plc_shutdown();
+    for (int i = 0; i < num_streams; i++) {
+        StreamWorker *w = &workers[i];
+        w->running = 0;
+        if (w->thread) pthread_join(w->thread, NULL);
+        for (int j = 0; j < 2; j++) {
+            if (w->ring[j].fd >= 0) { close(w->ring[j].fd); w->ring[j].fd = -1; }
+        }
+        if (w->last_fd >= 0) { close(w->last_fd); w->last_fd = -1; }
+        if (w->dec) decoder_destroy(w->dec);
+        pthread_mutex_destroy(&w->mutex);
+        pthread_cond_destroy(&w->cond);
+    }
+    for (int i = 0; i < 3; i++) {
+        if (output_map[i]) munmap(output_map[i], output_size);
+        if (output_fd[i] >= 0) close(output_fd[i]);
+    }
+    composer_destroy(g_composer);
+    display_destroy(g_display);
+    printf("Done.\n");
+    return 0;
+}

+ 485 - 0
plc.c

@@ -0,0 +1,485 @@
+#include "plc.h"
+#include "flog.h"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <stdarg.h>
+#include <unistd.h>
+
+#define S7_PDU_REQ    0x01
+#define S7_PDU_ACKD   0x03
+
+#define COTP_CR       0xE0
+#define COTP_CC       0xD0
+#define COTP_DT       0xF0
+
+#define TS_BYTE       0x02
+
+#define SOCK_TIMEOUT_MS 3000
+
+typedef struct {
+    uint8_t buf[1024];
+    int len;
+} Frame;
+
+typedef struct {
+    uint8_t data[PLC_MAX_BLOCK];
+    int     len;
+} PlcBlock;
+
+static PlcConfig g_cfg;
+static int g_inited = 0;
+static pthread_t g_thread;
+static int g_running = 0;
+static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER;
+
+static int g_sock = -1;
+static int g_connected = 0;
+static PlcBlock g_block;
+static uint16_t g_seq = 0;
+static char g_err[256];
+
+#define DBG(...) do { if (getenv("PLC_DEBUG")) fprintf(stderr, __VA_ARGS__); } while (0)
+
+static void set_err(const char *fmt, ...) {
+    va_list ap;
+    va_start(ap, fmt);
+    vsnprintf(g_err, sizeof(g_err), fmt, ap);
+    va_end(ap);
+}
+
+static int recv_exact(int fd, void *buf, size_t len) {
+    size_t got = 0;
+    while (got < len) {
+        ssize_t r = recv(fd, (uint8_t *)buf + got, len - got, 0);
+        if (r <= 0) {
+            if (r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
+                set_err("recv timeout");
+            else
+                set_err("recv failed: %s", strerror(errno));
+            return -1;
+        }
+        got += (size_t)r;
+    }
+    return 0;
+}
+
+static int send_all(int fd, const void *buf, size_t len) {
+    size_t sent = 0;
+    while (sent < len) {
+        ssize_t r = send(fd, (const uint8_t *)buf + sent, len - sent, MSG_NOSIGNAL);
+        if (r <= 0) {
+            if (r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
+                set_err("send timeout");
+            else
+                set_err("send failed: %s", strerror(errno));
+            return -1;
+        }
+        sent += (size_t)r;
+    }
+    return 0;
+}
+
+/* Wrap payload in TPKT + COTP-DT and send. */
+static int send_s7(int fd, const uint8_t *s7, int s7len) {
+    uint8_t frame[4 + 3 + 1024];
+    if (3 + s7len > (int)sizeof(frame) - 4) {
+        set_err("s7 frame too large");
+        return -1;
+    }
+    int total = 4 + 3 + s7len;
+    frame[0] = 3;
+    frame[1] = 0;
+    frame[2] = (uint8_t)(total >> 8);
+    frame[3] = (uint8_t)(total & 0xFF);
+    frame[4] = 2;          /* COTP DT header length */
+    frame[5] = COTP_DT;
+    frame[6] = 0x80;       /* EOT + sequence 0 */
+    memcpy(frame + 7, s7, s7len);
+    return send_all(fd, frame, total);
+}
+
+/* Receive one TPKT frame, return COTP payload (sans 3-byte DT header). */
+static int recv_s7(int fd, uint8_t *out, int maxout, int *out_len) {
+    uint8_t hdr[4];
+    if (recv_exact(fd, hdr, 4) < 0) return -1;
+    if (hdr[0] != 3) {
+        set_err("bad TPKT version %d", hdr[0]);
+        return -1;
+    }
+    int total = ((int)hdr[2] << 8) | hdr[3];
+    if (total < 7 || total > 4 + 1024) {
+        set_err("bad TPKT length %d", total);
+        return -1;
+    }
+    uint8_t payload[1024];
+    if (recv_exact(fd, payload, total - 4) < 0) return -1;
+    if (payload[0] != 2 || payload[1] != COTP_DT) {
+        set_err("expected COTP DT, got %02x %02x", payload[0], payload[1]);
+        return -1;
+    }
+    int s7len = total - 4 - 3;
+    if (s7len < 0 || s7len > maxout) {
+        set_err("bad s7 length %d", s7len);
+        return -1;
+    }
+    memcpy(out, payload + 3, s7len);
+    *out_len = s7len;
+    return 0;
+}
+
+static void put_u16(uint8_t *p, uint16_t v) {
+    p[0] = (uint8_t)(v >> 8);
+    p[1] = (uint8_t)(v & 0xFF);
+}
+
+static uint16_t get_u16(const uint8_t *p) {
+    return (uint16_t)(((uint16_t)p[0] << 8) | p[1]);
+}
+
+/* ---- COTP Connection Request ---- */
+static int cotp_connect(int fd) {
+    uint8_t pdu[22];
+    int n = 0;
+    pdu[n++] = 0x11;          /* length (excl. this byte) */
+    pdu[n++] = COTP_CR;
+    pdu[n++] = 0x00; pdu[n++] = 0x00;   /* dst ref */
+    pdu[n++] = 0x00; pdu[n++] = 0x01;   /* src ref */
+    pdu[n++] = 0x00;                    /* class */
+    pdu[n++] = 0xC1; pdu[n++] = 0x02;   /* calling TSAP (local) */
+    pdu[n++] = (uint8_t)((g_cfg.tsap_local >> 8) & 0xFF);
+    pdu[n++] = (uint8_t)(g_cfg.tsap_local & 0xFF);
+    pdu[n++] = 0xC2; pdu[n++] = 0x02;   /* called TSAP (remote) */
+    pdu[n++] = (uint8_t)((g_cfg.tsap_remote >> 8) & 0xFF);
+    pdu[n++] = (uint8_t)(g_cfg.tsap_remote & 0xFF);
+    pdu[n++] = 0xC0; pdu[n++] = 0x01; pdu[n++] = 0x0A; /* PDU size 1024 */
+
+    uint8_t frame[4 + n];
+    int total = 4 + n;
+    frame[0] = 3; frame[1] = 0;
+    frame[2] = (uint8_t)(total >> 8);
+    frame[3] = (uint8_t)(total & 0xFF);
+    memcpy(frame + 4, pdu, n);
+    if (send_all(fd, frame, total) < 0) return -1;
+
+    fprintf(stderr, "[plc] COTP CR (%d bytes): ", total);
+    for (int i = 0; i < total; i++) fprintf(stderr, "%02x ", frame[i]);
+    fprintf(stderr, "\n");
+
+    uint8_t resp[64];
+    int rlen;
+    if (recv_exact(fd, resp, 4) < 0) {
+        fprintf(stderr, "[plc] no CC response (reset after CR)\n");
+        return -1;
+    }
+    if (resp[0] != 3) { set_err("CC bad TPKT version"); return -1; }
+    int rtot = ((int)resp[2] << 8) | resp[3];
+    if (rtot < 7) { set_err("CC too short"); return -1; }
+    rlen = rtot - 4;
+    if (recv_exact(fd, resp + 4, rlen) < 0) return -1;
+    if (resp[4] != 0x0B /* LI=11 */ || resp[5] != COTP_CC) {
+        /* Some stacks vary LI; only type must be CC. */
+        if (resp[5] != COTP_CC) { set_err("expected COTP CC"); return -1; }
+    }
+    DBG("[plc] COTP connected (CC)\n");
+    return 0;
+}
+
+/* ---- S7 Setup Communication (negotiate PDU length) ---- */
+static int s7_setup(int fd) {
+    uint8_t req[18];
+    req[0] = 0x32; req[1] = S7_PDU_REQ;
+    req[2] = 0x00; req[3] = 0x00;
+    put_u16(req + 4, ++g_seq);
+    put_u16(req + 6, 0x0008);   /* param len */
+    put_u16(req + 8, 0x0000);   /* data len */
+    req[10] = 0xF0; req[11] = 0x00;   /* setup communication */
+    put_u16(req + 12, 0x0001);  /* max AMQ caller */
+    put_u16(req + 14, 0x0001);  /* max AMQ callee */
+    put_u16(req + 16, 0x01E0);  /* pdu length 480 */
+
+    if (send_s7(fd, req, sizeof(req)) < 0) return -1;
+
+    uint8_t resp[1024];
+    int rlen;
+    if (recv_s7(fd, resp, sizeof(resp), &rlen) < 0) return -1;
+    if (rlen < 12 || resp[0] != 0x32) {
+        set_err("bad setup response");
+        return -1;
+    }
+    /* Tolerate an error response; negotiation is non-critical. */
+    DBG("[plc] setup done (%d bytes)\n", rlen);
+    return 0;
+}
+
+/* ---- S7 Read Request ---- */
+static int s7_read(int fd, uint8_t *block, int len) {
+    uint8_t req[10 + 14];
+    req[0] = 0x32; req[1] = S7_PDU_REQ;
+    req[2] = 0x00; req[3] = 0x00;
+    put_u16(req + 4, ++g_seq);
+    put_u16(req + 6, 0x000E);   /* param len */
+    put_u16(req + 8, 0x0000);   /* data len */
+    req[10] = 0x04; req[11] = 0x01; /* READ VAR, 1 item */
+    req[12] = 0x12;             /* spec */
+    req[13] = 0x0A;             /* addr spec length */
+    req[14] = 0x10;             /* syntax S7-ANY */
+    req[15] = TS_BYTE;          /* transport size: BYTE */
+    put_u16(req + 16, (uint16_t)len);
+    put_u16(req + 18, g_cfg.area == PLC_AREA_DB ? (uint16_t)g_cfg.db : 0);
+    req[20] = (uint8_t)g_cfg.area;
+    uint32_t addr = (uint32_t)g_cfg.read_start * 8u;
+    req[21] = (uint8_t)(addr >> 16);
+    req[22] = (uint8_t)(addr >> 8);
+    req[23] = (uint8_t)(addr & 0xFF);
+
+    if (send_s7(fd, req, sizeof(req)) < 0) return -1;
+
+    uint8_t resp[1024];
+    int rlen;
+    if (recv_s7(fd, resp, sizeof(resp), &rlen) < 0) return -1;
+    if (rlen < 14 || resp[0] != 0x32) {
+        set_err("bad read response header");
+        return -1;
+    }
+    int pdu_type = resp[1];
+    if (pdu_type != S7_PDU_ACKD) {
+        set_err("unexpected read pdu type %02x", pdu_type);
+        return -1;
+    }
+    uint16_t seq = get_u16(resp + 4);
+    uint16_t param_len = get_u16(resp + 6);
+    uint16_t data_len = get_u16(resp + 8);
+    uint8_t err_cls = resp[10], err_code = resp[11];
+    if (err_cls != 0) {
+        set_err("s7 error class %02x code %02x", err_cls, err_code);
+        return -1;
+    }
+    if (seq != g_seq) {
+        /* Real S7 PLCs echo the request sequence; some simulators reply
+         * with their own counter. Log but do not fail. */
+        DBG("[plc] seq warn: resp %u != sent %u\n", seq, g_seq);
+    }
+    int off = 12 + param_len;
+    int avail = rlen - off;
+    if (avail < 4) {
+        set_err("read response truncated");
+        return -1;
+    }
+    int dlen = data_len;
+    if (dlen > avail) {
+        DBG("[plc] data_len warn: field %u > avail %d, clamping\n",
+            data_len, avail);
+        dlen = avail;
+    }
+    const uint8_t *d = resp + off;
+    /* item: rc(1) ts(1) bitlen(2) data */
+    if (dlen < 4) { set_err("read data empty"); return -1; }
+    uint8_t rc = d[0];
+    uint8_t ts = d[1];
+    uint16_t blen = get_u16(d + 2);
+    if (rc != 0xFF) {
+        set_err("read item rc=0x%02x", rc);
+        return -1;
+    }
+    int nbytes = (ts == TS_BYTE) ? blen : blen / 8;
+    if (nbytes > (int)data_len - 4) {
+        set_err("read data length mismatch (%d > %d)", nbytes, data_len - 4);
+        return -1;
+    }
+    if (nbytes > PLC_MAX_BLOCK) nbytes = PLC_MAX_BLOCK;
+    memcpy(block, d + 4, nbytes);
+    return nbytes;
+}
+
+static int tcp_connect_handshake(void) {
+    int fd = socket(AF_INET, SOCK_STREAM, 0);
+    if (fd < 0) { set_err("socket: %s", strerror(errno)); return -1; }
+
+    struct sockaddr_in sa;
+    memset(&sa, 0, sizeof(sa));
+    sa.sin_family = AF_INET;
+    sa.sin_port = htons((uint16_t)g_cfg.port);
+    if (inet_pton(AF_INET, g_cfg.ip, &sa.sin_addr) != 1) {
+        set_err("bad ip %s", g_cfg.ip);
+        close(fd);
+        return -1;
+    }
+
+    /* Non-blocking connect + poll, so a slow/unreachable PLC cannot stall
+     * the reconnect loop. */
+    int flags = fcntl(fd, F_GETFL, 0);
+    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+    int rc = connect(fd, (struct sockaddr *)&sa, sizeof(sa));
+    if (rc < 0 && errno != EINPROGRESS) {
+        set_err("tcp connect: %s", strerror(errno));
+        close(fd);
+        return -1;
+    }
+    if (rc < 0) {
+        struct pollfd pfd = { .fd = fd, .events = POLLOUT };
+        int pr = poll(&pfd, 1, SOCK_TIMEOUT_MS);
+        if (pr <= 0) {
+            set_err(pr == 0 ? "tcp connect timeout" : "tcp connect: %s",
+                    strerror(errno));
+            close(fd);
+            return -1;
+        }
+        int soerr = 0;
+        socklen_t sl = sizeof(soerr);
+        if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &sl) < 0 || soerr != 0) {
+            set_err("tcp connect refused: %s", strerror(soerr));
+            close(fd);
+            return -1;
+        }
+    }
+    fcntl(fd, F_SETFL, flags);
+
+    struct timeval tv;
+    tv.tv_sec = SOCK_TIMEOUT_MS / 1000;
+    tv.tv_usec = (SOCK_TIMEOUT_MS % 1000) * 1000;
+    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+    setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
+    int one = 1;
+    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
+
+    if (cotp_connect(fd) < 0) { close(fd); return -1; }
+    if (s7_setup(fd) < 0) {
+        /* Setup is not fatal; keep the transport connection. */
+        DBG("[plc] setup failed (%s), continuing\n", plc_last_error());
+    }
+    g_sock = fd;
+    return 0;
+}
+
+static void *plc_thread(void *arg) {
+    (void)arg;
+    int was_err_logged = 0;
+    while (g_running) {
+        if (g_sock < 0) {
+            if (tcp_connect_handshake() < 0) {
+                if (!was_err_logged) {
+                    fprintf(stderr, "[plc] connect %s:%d failed: %s\n",
+                            g_cfg.ip, g_cfg.port, plc_last_error());
+                    flog("PLC 连接 %s:%d 失败: %s", g_cfg.ip, g_cfg.port, plc_last_error());
+                    was_err_logged = 1;
+                }
+                usleep((useconds_t)g_cfg.reconnect_ms * 1000);
+                continue;
+            }
+            was_err_logged = 0;
+            DBG("[plc] connected to %s:%d\n", g_cfg.ip, g_cfg.port);
+        }
+
+        uint8_t block[PLC_MAX_BLOCK];
+        int n = s7_read(g_sock, block, g_cfg.read_len);
+        if (n < 0) {
+            fprintf(stderr, "[plc] read failed: %s\n", plc_last_error());
+            flog_throttled("plc_read", 10000, "PLC 读取失败: %s", plc_last_error());
+            close(g_sock);
+            g_sock = -1;
+            pthread_mutex_lock(&g_mutex);
+            g_connected = 0;
+            pthread_mutex_unlock(&g_mutex);
+            usleep((useconds_t)g_cfg.reconnect_ms * 1000);
+            continue;
+        }
+
+        pthread_mutex_lock(&g_mutex);
+        memcpy(g_block.data, block, n);
+        g_block.len = n;
+        g_connected = 1;
+        pthread_mutex_unlock(&g_mutex);
+
+        DBG("[plc] read %d bytes ok\n", n);
+        usleep((useconds_t)g_cfg.poll_ms * 1000);
+    }
+    return NULL;
+}
+
+int plc_init(const PlcConfig *cfg) {
+    if (g_inited) return 0;
+    if (!cfg || cfg->port <= 0 || cfg->read_len <= 0 ||
+        cfg->read_len > PLC_MAX_BLOCK) {
+        set_err("invalid plc config");
+        return -1;
+    }
+    g_cfg = *cfg;
+    g_cfg.ip[sizeof(g_cfg.ip) - 1] = '\0';
+    if (g_cfg.poll_ms <= 0) g_cfg.poll_ms = 100;
+    if (g_cfg.reconnect_ms <= 0) g_cfg.reconnect_ms = 3000;
+
+    g_running = 1;
+    g_sock = -1;
+    g_connected = 0;
+    if (pthread_create(&g_thread, NULL, plc_thread, NULL) != 0) {
+        g_running = 0;
+        set_err("pthread_create failed");
+        return -1;
+    }
+    g_inited = 1;
+    return 0;
+}
+
+void plc_shutdown(void) {
+    if (!g_inited) return;
+    g_running = 0;
+    if (g_sock >= 0) {
+        shutdown(g_sock, SHUT_RDWR);
+        close(g_sock);
+        g_sock = -1;
+    }
+    pthread_join(g_thread, NULL);
+    g_inited = 0;
+}
+
+int plc_is_connected(void) {
+    pthread_mutex_lock(&g_mutex);
+    int c = g_connected;
+    pthread_mutex_unlock(&g_mutex);
+    return c;
+}
+
+int plc_get_bit(int byte_idx, int bit_idx) {
+    int r = -1;
+    pthread_mutex_lock(&g_mutex);
+    if (g_connected && byte_idx >= 0 && byte_idx < g_block.len &&
+        bit_idx >= 0 && bit_idx < 8) {
+        r = (g_block.data[byte_idx] >> bit_idx) & 1;
+    }
+    pthread_mutex_unlock(&g_mutex);
+    return r;
+}
+
+int plc_get_byte(int byte_idx, uint8_t *out) {
+    int r = -1;
+    pthread_mutex_lock(&g_mutex);
+    if (g_connected && out && byte_idx >= 0 && byte_idx < g_block.len) {
+        *out = g_block.data[byte_idx];
+        r = 0;
+    }
+    pthread_mutex_unlock(&g_mutex);
+    return r;
+}
+
+const char *plc_last_error(void) {
+    pthread_mutex_lock(&g_mutex);
+    if (g_err[0] == '\0') {
+        pthread_mutex_unlock(&g_mutex);
+        return "no error";
+    }
+    static char copy[256];
+    strncpy(copy, g_err, sizeof(copy) - 1);
+    copy[sizeof(copy) - 1] = '\0';
+    pthread_mutex_unlock(&g_mutex);
+    return copy;
+}

+ 45 - 0
plc.h

@@ -0,0 +1,45 @@
+#ifndef __PLC_H__
+#define __PLC_H__
+
+#include <stdint.h>
+
+#define PLC_AREA_I  0x81
+#define PLC_AREA_Q  0x82
+#define PLC_AREA_M  0x83
+#define PLC_AREA_DB 0x84
+
+#define PLC_MAX_BLOCK 256
+
+typedef struct {
+    char ip[64];
+    int  port;
+    int  area;          /* PLC_AREA_* */
+    int  db;            /* used when area == PLC_AREA_DB */
+    int  read_start;    /* first byte of the polled block (block-relative) */
+    int  read_len;      /* bytes per poll, <= PLC_MAX_BLOCK */
+    int  poll_ms;
+    int  reconnect_ms;
+    int  tsap_local;    /* e.g. 0x0100 */
+    int  tsap_remote;   /* e.g. 0x0102 (rack0 slot2), 0x0001 for S7-200 SMART */
+} PlcConfig;
+
+/* Start background polling thread. 0 on success, -1 on error. */
+int plc_init(const PlcConfig *cfg);
+
+/* Stop thread and close connection. */
+void plc_shutdown(void);
+
+/* 1 when the last poll succeeded, 0 otherwise. */
+int plc_is_connected(void);
+
+/* Read a single bit of the cached block. byte_idx is relative to read_start.
+ * Returns 0/1 on success, -1 if the block is not (yet) available. */
+int plc_get_bit(int byte_idx, int bit_idx);
+
+/* Copy one cached byte. Returns 0 on success, -1 if unavailable. */
+int plc_get_byte(int byte_idx, uint8_t *out);
+
+/* Human readable last error (static buffer). */
+const char *plc_last_error(void);
+
+#endif

+ 196 - 0
plc_server.py

@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+"""Local S7comm PLC simulator (server).
+
+Stands in for a physical Siemens S7 PLC so that rtsp_multi_view can READ and
+plc_sim can WRITE a small "process image" (byte array) over S7comm / ISO-on-TCP.
+
+Handles: COTP Connect Request/Confirm, S7 Setup Communication, S7 Read Var
+(function 0x04) and S7 Write Var (function 0x05). Multiple clients are served
+on separate threads.
+
+Usage:
+    plc_server.py [-p PORT] [-l LEN] [--cycle] [-t CYCLE_MS]
+
+    -p PORT    listen port (default 102; needs root on Linux)
+    -l LEN     process image size in bytes (default 16)
+    --cycle    also rotate the 6 layout-switch states every cycle
+    -t MS      state cycle interval in ms (default 10000)
+
+With --cycle the server drives byte0 through 0x00,0x03,0x0C,0x30,0x0F,0x3F
+all by itself, so plc_sim is not required.
+"""
+
+import argparse
+import socket
+import sys
+import threading
+import time
+
+TS_BYTE = 0x02
+CYCLE_STATES = [0x00, 0x03, 0x0C, 0x30, 0x0F, 0x3F]
+STATE_NAMES = [
+    "state 0: all bits 0 (default layout)",
+    "state 1: M0.0|M0.1 (layout 0)",
+    "state 2: M0.2|M0.3 (layout 1)",
+    "state 3: M0.4|M0.5 (layout 2)",
+    "state 4: M0.0,0.1;M0.2,0.3 (layout 3)",
+    "state 5: M0.0,0.1;M0.2,0.3;M0.4,0.5 (layout 4)",
+]
+
+CC = bytes([0x03, 0x00, 0x00, 0x15]) + bytes([
+    0x0B, 0xD0, 0x00, 0x00, 0x01, 0x00,
+    0xC0, 0x01, 0x0A,
+    0xC1, 0x02, 0x01, 0x00,
+    0xC2, 0x02, 0x01, 0x02,
+])
+
+
+def put16(v):
+    return bytes([(v >> 8) & 0xFF, v & 0xFF])
+
+
+def tpkt(s7):
+    total = 4 + 3 + len(s7)
+    return bytes([0x03, 0x00, (total >> 8) & 0xFF, total & 0xFF,
+                  0x02, 0xF0, 0x80]) + s7
+
+
+def recv_exact(c, n):
+    buf = b''
+    while len(buf) < n:
+        r = c.recv(n - len(buf))
+        if not r:
+            return None
+        buf += r
+    return buf
+
+
+def read_tpkt(c):
+    h = recv_exact(c, 4)
+    if not h:
+        return None
+    total = (h[2] << 8) | h[3]
+    if total < 7 or total > 4096:
+        return None
+    body = recv_exact(c, total - 4)
+    if body is None:
+        return None
+    return h + body
+
+
+class PlcServer:
+    def __init__(self, area_len, cycle, cycle_ms):
+        self.lock = threading.Lock()
+        self.area = bytearray(area_len)
+        self.cycle = cycle
+        self.cycle_ms = cycle_ms
+        self.state_idx = 0
+
+    def setup_resp(self, seq):
+        return bytes([0x32, 0x03, 0x00, 0x00]) + seq + \
+            bytes([0x00, 0x08, 0x00, 0x00, 0x00, 0x00,
+                   0xF0, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0xE0])
+
+    def read_var(self, seq, s7):
+        ts = s7[15]
+        n = (s7[16] << 8) | s7[17]
+        if n > len(self.area):
+            n = len(self.area)
+        with self.lock:
+            data = bytes(self.area[:n])
+        blen = n if ts == TS_BYTE else n * 8
+        resp = bytes([0x32, 0x03, 0x00, 0x00]) + seq + \
+            put16(2) + put16(4 + n) + bytes([0x00, 0x00])
+        resp += bytes([0x04, 0x01])
+        resp += bytes([0xFF, ts]) + put16(blen) + data
+        return resp
+
+    def write_var(self, seq, s7):
+        ts = s7[15]
+        n = (s7[16] << 8) | s7[17]
+        item = s7[24:]
+        blen = (item[2] << 8) | item[3]
+        nbytes = blen if ts == TS_BYTE else blen // 8
+        if nbytes > len(self.area):
+            nbytes = len(self.area)
+        data = item[4:4 + nbytes]
+        with self.lock:
+            self.area[:nbytes] = data
+        print("[plc] write %d bytes @%d -> byte0=0x%02X" %
+              (nbytes, 0, data[0] if data else 0), flush=True)
+        return bytes([0x32, 0x03, 0x00, 0x00]) + seq + \
+            bytes([0x00, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x05, 0x01, 0xFF])
+
+    def handle(self, conn, addr):
+        try:
+            conn.settimeout(120)
+            while True:
+                frame = read_tpkt(conn)
+                if frame is None:
+                    break
+                payload = frame[4:]
+                if payload[1] == 0xE0:            # COTP Connect Request
+                    conn.sendall(CC)
+                elif payload[1] == 0xF0:          # COTP Data
+                    s7 = payload[3:]
+                    if len(s7) < 12:
+                        continue
+                    func = s7[10]
+                    seq = s7[4:6]
+                    if func == 0xF0:              # S7 Setup Communication
+                        conn.sendall(tpkt(self.setup_resp(seq)))
+                    elif func == 0x04:            # S7 Read Var
+                        conn.sendall(tpkt(self.read_var(seq, s7)))
+                    elif func == 0x05:            # S7 Write Var
+                        conn.sendall(tpkt(self.write_var(seq, s7)))
+                    # else: ignore unknown requests
+        except Exception:
+            pass
+        finally:
+            conn.close()
+        print("[plc] client %s:%d disconnected" % addr, flush=True)
+
+    def cycle_loop(self):
+        while True:
+            time.sleep(self.cycle_ms / 1000.0)
+            v = CYCLE_STATES[self.state_idx]
+            with self.lock:
+                self.area[0] = v
+            print("[plc] %s (byte0=0x%02X)" %
+                  (STATE_NAMES[self.state_idx], v), flush=True)
+            self.state_idx = (self.state_idx + 1) % len(CYCLE_STATES)
+
+
+def main():
+    ap = argparse.ArgumentParser(description="Local S7comm PLC simulator")
+    ap.add_argument("-p", "--port", type=int, default=102)
+    ap.add_argument("-l", "--len", type=int, default=16)
+    ap.add_argument("--cycle", action="store_true",
+                    help="self-rotate the 6 layout states every cycle")
+    ap.add_argument("-t", "--cycle-ms", type=int, default=10000)
+    args = ap.parse_args()
+
+    server = PlcServer(args.len, args.cycle, args.cycle_ms)
+
+    if args.cycle:
+        threading.Thread(target=server.cycle_loop, daemon=True).start()
+
+    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+    srv.bind(("0.0.0.0", args.port))
+    srv.listen(8)
+    print("[plc] local PLC listening on 0.0.0.0:%d area_len=%d%s" %
+          (args.port, args.len, " (cycling)" if args.cycle else ""), flush=True)
+
+    while True:
+        conn, addr = srv.accept()
+        print("[plc] client %s:%d connected" % addr, flush=True)
+        threading.Thread(target=server.handle, args=(conn, addr),
+                         daemon=True).start()
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except KeyboardInterrupt:
+        sys.exit(0)

+ 47 - 0
plc_server.sh

@@ -0,0 +1,47 @@
+#!/bin/bash
+# 本地 PLC 布局切换联调脚本:
+#   1. 启动本地 PLC(S7comm 服务端,plc_server.py)
+#   2. 启动状态切换写入器(plc_sim,每 5 秒写一个状态)
+#   3. 启动自动重启调度器(auto_reboot.sh,按配置定时重启)
+# Ctrl+C 可完全退出所有服务。
+# 注意:TCP 102 为特权端口,需要 root 运行。
+
+DIR="$(cd "$(dirname "$0")" && pwd)"
+CONF="${CONF:-${DIR}/channels.conf}"
+PLCPID=""; SIMPID=""; REBOOTPID=""
+
+cleanup() {
+    echo ""
+    echo "== 正在停止服务 =="
+    [ -n "$SIMPID" ]     && kill "$SIMPID"     2>/dev/null && wait "$SIMPID"     2>/dev/null
+    [ -n "$REBOOTPID" ]  && kill "$REBOOTPID"  2>/dev/null && wait "$REBOOTPID"  2>/dev/null
+    [ -n "$PLCPID" ]     && kill "$PLCPID"     2>/dev/null && wait "$PLCPID"     2>/dev/null
+    echo "== 已停止 =="
+}
+trap cleanup INT TERM
+
+if ! command -v python3 >/dev/null 2>&1; then
+    echo "ERROR: python3 not found" >&2
+    exit 1
+fi
+for bin in plc_sim rtsp_multi_view; do
+    if [ ! -x "${DIR}/build/${bin}" ]; then
+        echo "ERROR: ${DIR}/build/${bin} not built" >&2
+        exit 1
+    fi
+done
+
+echo "== [1/3] 启动本地 PLC (plc_server.py) =="
+python3 "${DIR}/plc_server.py" -p 102 &
+PLCPID=$!
+
+echo "== [2/3] 启动状态切换器 (plc_sim, 每 5 秒切换) =="
+"${DIR}/build/plc_sim" -c "$CONF" -t 5000 &
+SIMPID=$!
+
+echo "== [3/3] 启动自动重启调度器 (auto_reboot) =="
+"${DIR}/auto_reboot.sh" -c "$CONF" &
+REBOOTPID=$!
+
+wait
+

+ 558 - 0
plc_sim.c

@@ -0,0 +1,558 @@
+/* PLC simulator: connects to the PLC as an S7comm client and every
+ * <interval_ms> writes the next state pattern into the app's read area,
+ * cycling through 6 states:
+ *   0: all bits 0                          -> default layout
+ *   1: byte0 = 0b00000011 (M0.0|M0.1)      -> layout 0
+ *   2: byte0 = 0b00001100 (M0.2|M0.3)      -> layout 1
+ *   3: byte0 = 0b00110000 (M0.4|M0.5)      -> layout 2
+ *   4: byte0 = 0b00001111 (two groups)     -> layout 3
+ *   5: byte0 = 0b00111111 (three groups)   -> layout 4
+ *
+ * Usage: plc_sim [-c channels.conf] [-t interval_ms] [-f first_state]
+ */
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <unistd.h>
+
+#define S7_PDU_REQ    0x01
+#define S7_PDU_ACKD   0x03
+#define COTP_CR       0xE0
+#define COTP_CC       0xD0
+#define COTP_DT       0xF0
+#define TS_BYTE       0x02
+
+#define SOCK_TIMEOUT_MS 3000
+#define MAX_CFG_STR    256
+#define MAX_BLOCK      64
+
+static volatile int g_running = 1;
+static uint16_t g_seq = 0;
+static char g_err[256];
+
+typedef struct {
+    char ip[MAX_CFG_STR];
+    int  port;
+    int  area;              /* 0x81 I, 0x82 Q, 0x83 M, 0x84 DB */
+    int  db;
+    int  start;
+    int  len;
+    int  tsap_local;
+    int  tsap_remote;
+} SimCfg;
+
+static const char *area_names[] = { "?", "I", "Q", "M", "DB" };
+
+#define MAX_RULES 8
+#define MAX_RULE_BITS 16
+
+/* One bit position (byte.bit relative to read_start) taken from [plc_rules]. */
+typedef struct {
+    int byte;
+    int bit;
+} RuleBit;
+
+typedef struct {
+    int layout;            /* rule id -> layout index */
+    RuleBit bits[MAX_RULE_BITS];
+    int nbits;
+    int has_bits;          /* non-zero if the rule carries explicit bits */
+} PlcRuleCfg;
+
+static PlcRuleCfg g_rules[MAX_RULES];
+static int g_num_rules = 0;
+
+/* Build the write block (cfg.len bytes) for a given rule state.
+ * state 0 -> all bits cleared (default layout).
+ * n>=1   -> set the bits listed in g_rules[state-1] (fall back to byte0). */
+static void state_to_block(uint8_t *data, int len, int state, int nrules) {
+    memset(data, 0, (size_t)len * sizeof(data[0]));
+    if (state <= 0) return;
+    int idx = state - 1;
+    if (idx >= nrules || idx < 0) return;
+    PlcRuleCfg *R = &g_rules[idx];
+    if (!R->has_bits) {
+        /* no [plc_rules] for this state: fall back to byte0 low bits */
+        if (len > 0) data[0] = (uint8_t)(0x03u << (2 * (idx % 3)));
+        return;
+    }
+    for (int i = 0; i < R->nbits; i++) {
+        int by = R->bits[i].byte;
+        int bi = R->bits[i].bit;
+        if (by < 0 || by >= len || bi < 0 || bi > 7) continue;
+        data[by] |= (uint8_t)(1u << bi);
+    }
+}
+
+/* Render the set bits of a rule state as `prefix byte.bit` / `db.byte.bit`,
+ * matching the config grammar, for display only. */
+static int format_state_addr(char *buf, size_t buflen,
+                             const SimCfg *cfg, const uint8_t *data, int len) {
+    const char *prefix = "?";
+    switch (cfg->area) {
+        case 0x81: prefix = "I";  break;
+        case 0x82: prefix = "Q";  break;
+        case 0x83: prefix = "M";  break;
+        case 0x84: prefix = "DB"; break;
+    }
+    int first = 1;
+    size_t o = 0;
+    for (int i = 0; i < len * 8 && o < buflen; i++) {
+        int byte = i / 8;
+        int bit = i % 8;
+        if (!(data[byte] & (1u << bit))) continue;
+        int pos = cfg->start + byte;
+        int n;
+        if (cfg->area == 0x84)
+            n = snprintf(buf + o, buflen - o, "%s%d.%d.%d",
+                         first ? "" : ",", cfg->db, pos, bit);
+        else
+            n = snprintf(buf + o, buflen - o, "%s%s%d.%d",
+                         first ? "" : ",", prefix, pos, bit);
+        if (n < 0 || (size_t)n >= buflen - o) break;
+        o += (size_t)n;
+        first = 0;
+    }
+    if (o == 0 && buflen > 0) { buf[0] = '\0'; }
+    return (int)o;
+}
+
+static void sig_handler(int sig) {
+    (void)sig;
+    g_running = 0;
+}
+
+static void set_err(const char *fmt, ...) {
+    va_list ap;
+    va_start(ap, fmt);
+    vsnprintf(g_err, sizeof(g_err), fmt, ap);
+    va_end(ap);
+}
+
+static int recv_exact(int fd, void *buf, size_t len) {
+    size_t got = 0;
+    while (got < len) {
+        ssize_t r = recv(fd, (uint8_t *)buf + got, len - got, 0);
+        if (r <= 0) {
+            if (r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
+                set_err("recv timeout");
+            else
+                set_err("recv failed: %s", strerror(errno));
+            return -1;
+        }
+        got += (size_t)r;
+    }
+    return 0;
+}
+
+static int send_all(int fd, const void *buf, size_t len) {
+    size_t sent = 0;
+    while (sent < len) {
+        ssize_t r = send(fd, (const uint8_t *)buf + sent, len - sent, MSG_NOSIGNAL);
+        if (r <= 0) {
+            if (r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
+                set_err("send timeout");
+            else
+                set_err("send failed: %s", strerror(errno));
+            return -1;
+        }
+        sent += (size_t)r;
+    }
+    return 0;
+}
+
+static int send_s7(int fd, const uint8_t *s7, int s7len) {
+    uint8_t frame[4 + 3 + 1024];
+    if (3 + s7len > (int)sizeof(frame) - 4) {
+        set_err("s7 frame too large");
+        return -1;
+    }
+    int total = 4 + 3 + s7len;
+    frame[0] = 3; frame[1] = 0;
+    frame[2] = (uint8_t)(total >> 8);
+    frame[3] = (uint8_t)(total & 0xFF);
+    frame[4] = 2;          /* COTP DT header length */
+    frame[5] = COTP_DT;
+    frame[6] = 0x80;       /* EOT + sequence 0 */
+    memcpy(frame + 7, s7, s7len);
+    return send_all(fd, frame, total);
+}
+
+static int recv_s7(int fd, uint8_t *out, int maxout, int *out_len) {
+    uint8_t hdr[4];
+    if (recv_exact(fd, hdr, 4) < 0) return -1;
+    if (hdr[0] != 3) { set_err("bad TPKT version %d", hdr[0]); return -1; }
+    int total = ((int)hdr[2] << 8) | hdr[3];
+    if (total < 7 || total > 4 + 1024) {
+        set_err("bad TPKT length %d", total);
+        return -1;
+    }
+    uint8_t payload[1024];
+    if (recv_exact(fd, payload, total - 4) < 0) return -1;
+    if (payload[0] != 2 || payload[1] != COTP_DT) {
+        set_err("expected COTP DT, got %02x %02x", payload[0], payload[1]);
+        return -1;
+    }
+    int s7len = total - 4 - 3;
+    if (s7len < 0 || s7len > maxout) {
+        set_err("bad s7 length %d", s7len);
+        return -1;
+    }
+    memcpy(out, payload + 3, s7len);
+    *out_len = s7len;
+    return 0;
+}
+
+static void put_u16(uint8_t *p, uint16_t v) {
+    p[0] = (uint8_t)(v >> 8);
+    p[1] = (uint8_t)(v & 0xFF);
+}
+
+/* ---- COTP Connection Request ---- */
+static int cotp_connect(int fd, const SimCfg *cfg) {
+    uint8_t pdu[22];
+    int n = 0;
+    pdu[n++] = 0x11;
+    pdu[n++] = COTP_CR;
+    pdu[n++] = 0x00; pdu[n++] = 0x00;
+    pdu[n++] = 0x00; pdu[n++] = 0x01;
+    pdu[n++] = 0x00;
+    pdu[n++] = 0xC1; pdu[n++] = 0x02;
+    pdu[n++] = (uint8_t)((cfg->tsap_local >> 8) & 0xFF);
+    pdu[n++] = (uint8_t)(cfg->tsap_local & 0xFF);
+    pdu[n++] = 0xC2; pdu[n++] = 0x02;
+    pdu[n++] = (uint8_t)((cfg->tsap_remote >> 8) & 0xFF);
+    pdu[n++] = (uint8_t)(cfg->tsap_remote & 0xFF);
+    pdu[n++] = 0xC0; pdu[n++] = 0x01; pdu[n++] = 0x0A;
+
+    uint8_t frame[4 + n];
+    int total = 4 + n;
+    frame[0] = 3; frame[1] = 0;
+    frame[2] = (uint8_t)(total >> 8);
+    frame[3] = (uint8_t)(total & 0xFF);
+    memcpy(frame + 4, pdu, n);
+    if (send_all(fd, frame, total) < 0) return -1;
+
+    uint8_t resp[64];
+    if (recv_exact(fd, resp, 4) < 0) return -1;
+    if (resp[0] != 3) { set_err("CC bad TPKT version"); return -1; }
+    int rtot = ((int)resp[2] << 8) | resp[3];
+    if (rtot < 7) { set_err("CC too short"); return -1; }
+    if (recv_exact(fd, resp + 4, rtot - 4) < 0) return -1;
+    if (resp[5] != COTP_CC) { set_err("expected COTP CC"); return -1; }
+    return 0;
+}
+
+/* ---- S7 Setup Communication ---- */
+static int s7_setup(int fd) {
+    uint8_t req[18];
+    req[0] = 0x32; req[1] = S7_PDU_REQ;
+    req[2] = 0x00; req[3] = 0x00;
+    put_u16(req + 4, ++g_seq);
+    put_u16(req + 6, 0x0008);
+    put_u16(req + 8, 0x0000);
+    req[10] = 0xF0; req[11] = 0x00;
+    put_u16(req + 12, 0x0001);
+    put_u16(req + 14, 0x0001);
+    put_u16(req + 16, 0x01E0);
+    if (send_s7(fd, req, sizeof(req)) < 0) return -1;
+
+    uint8_t resp[1024];
+    int rlen;
+    if (recv_s7(fd, resp, sizeof(resp), &rlen) < 0) return -1;
+    return 0;
+}
+
+/* ---- S7 Write Var ---- */
+static int s7_write(int fd, const SimCfg *cfg, const uint8_t *data, int len) {
+    uint8_t req[10 + 14 + 4 + MAX_BLOCK];
+    int o = 0;
+    req[o++] = 0x32; req[o++] = S7_PDU_REQ;
+    req[o++] = 0x00; req[o++] = 0x00;
+    put_u16(req + o, ++g_seq); o += 2;      /* PDU ref */
+    put_u16(req + o, 0x000E); o += 2;       /* param len = 14 */
+    put_u16(req + o, 0x0004 + len); o += 2; /* data len */
+
+    req[o++] = 0x05; req[o++] = 0x01;       /* WRITE VAR, 1 item */
+    req[o++] = 0x12; req[o++] = 0x0A;       /* spec, addr spec len */
+    req[o++] = 0x10;                        /* syntax S7-ANY */
+    req[o++] = TS_BYTE;                     /* transport size BYTE */
+    put_u16(req + o, (uint16_t)len); o += 2;
+    put_u16(req + o, (uint16_t)cfg->db); o += 2;
+    req[o++] = (uint8_t)cfg->area;
+    uint32_t addr = (uint32_t)cfg->start * 8u;
+    req[o++] = (uint8_t)(addr >> 16);
+    req[o++] = (uint8_t)(addr >> 8);
+    req[o++] = (uint8_t)(addr & 0xFF);
+
+    req[o++] = 0x00;                        /* return code (ignored) */
+    req[o++] = TS_BYTE;
+    put_u16(req + o, (uint16_t)(len * 8)); o += 2;
+    memcpy(req + o, data, len); o += len;
+
+    if (send_s7(fd, req, o) < 0) return -1;
+
+    uint8_t resp[1024];
+    int rlen;
+    if (recv_s7(fd, resp, sizeof(resp), &rlen) < 0) return -1;
+    if (rlen < 14 || resp[0] != 0x32 || resp[1] != S7_PDU_ACKD) {
+        set_err("bad write response header");
+        return -1;
+    }
+    if (resp[10] != 0 || resp[11] != 0) {
+        set_err("s7 error class %02x code %02x", resp[10], resp[11]);
+        return -1;
+    }
+    /* param: 0x05 0x01 0xFF  (0xFF = item success) */
+    if (rlen > 14 && resp[14] != 0xFF) {
+        set_err("write item rc=0x%02x", resp[14]);
+        return -1;
+    }
+    return 0;
+}
+
+static int tcp_connect_handshake(const SimCfg *cfg) {
+    int fd = socket(AF_INET, SOCK_STREAM, 0);
+    if (fd < 0) { set_err("socket: %s", strerror(errno)); return -1; }
+
+    struct sockaddr_in sa;
+    memset(&sa, 0, sizeof(sa));
+    sa.sin_family = AF_INET;
+    sa.sin_port = htons((uint16_t)cfg->port);
+    if (inet_pton(AF_INET, cfg->ip, &sa.sin_addr) != 1) {
+        set_err("bad ip %s", cfg->ip);
+        close(fd);
+        return -1;
+    }
+
+    int flags = fcntl(fd, F_GETFL, 0);
+    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+    int rc = connect(fd, (struct sockaddr *)&sa, sizeof(sa));
+    if (rc < 0 && errno != EINPROGRESS) {
+        set_err("tcp connect: %s", strerror(errno));
+        close(fd);
+        return -1;
+    }
+    if (rc < 0) {
+        struct pollfd pfd = { .fd = fd, .events = POLLOUT };
+        int pr = poll(&pfd, 1, SOCK_TIMEOUT_MS);
+        if (pr <= 0) {
+            set_err(pr == 0 ? "tcp connect timeout" : "tcp connect: %s",
+                    strerror(errno));
+            close(fd);
+            return -1;
+        }
+        int soerr = 0;
+        socklen_t sl = sizeof(soerr);
+        if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &sl) < 0 || soerr != 0) {
+            set_err("tcp connect refused: %s", strerror(soerr));
+            close(fd);
+            return -1;
+        }
+    }
+    fcntl(fd, F_SETFL, flags);
+
+    struct timeval tv;
+    tv.tv_sec = SOCK_TIMEOUT_MS / 1000;
+    tv.tv_usec = (SOCK_TIMEOUT_MS % 1000) * 1000;
+    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+    setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
+    int one = 1;
+    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
+
+    if (cotp_connect(fd, cfg) < 0) { close(fd); return -1; }
+    if (s7_setup(fd) < 0) {
+        /* Setup is non-fatal. */
+    }
+    return fd;
+}
+
+/* Read ip/port/area/db/read_start/read_len/tsap from the [plc] section and the
+ * layout-switch bit rules from the [plc_rules] section of a channels.conf-style
+ * file. Rules use `byte.bit` (relative to read_start), e.g. `6.6,6.7`.
+ * Unknown keys are ignored. */
+static int parse_config(const char *path, SimCfg *cfg) {
+    FILE *fp = fopen(path, "r");
+    if (!fp) { set_err("cannot open %s", path); return -1; }
+    int section = 0;
+    int rules_seen = 0;
+    char line[512];
+    while (fgets(line, sizeof(line), fp)) {
+        char *s = line;
+        while (*s == ' ' || *s == '\t') s++;
+        if (*s == '\n' || *s == '\r' || *s == '#' || *s == '\0') continue;
+        if (s[0] == '[') {
+            if (strncmp(s, "[plc]", 5) == 0) section = 5;
+            else if (strncmp(s, "[plc_rules]", 11) == 0) section = 7;
+            else section = 0;
+            continue;
+        }
+        if (section == 5) {
+            char key[64], val[256];
+            if (sscanf(s, "%63s = %255[^\n\r]", key, val) < 2) continue;
+            char *c = strchr(val, ';');
+            if (!c) c = strchr(val, '#');
+            if (c) *c = '\0';
+            int vlen = (int)strlen(val);
+            while (vlen > 0 && (val[vlen-1] == ' ' || val[vlen-1] == '\t'))
+                val[--vlen] = '\0';
+            if (strcmp(key, "ip") == 0) {
+                strncpy(cfg->ip, val, sizeof(cfg->ip) - 1);
+            } else if (strcmp(key, "port") == 0) {
+                cfg->port = atoi(val);
+            } else if (strcmp(key, "area") == 0) {
+                if (strcasecmp(val, "i") == 0) cfg->area = 0x81;
+                else if (strcasecmp(val, "q") == 0) cfg->area = 0x82;
+                else if (strcasecmp(val, "m") == 0) cfg->area = 0x83;
+                else if (strcasecmp(val, "db") == 0) cfg->area = 0x84;
+            } else if (strcmp(key, "db") == 0) {
+                cfg->db = atoi(val);
+            } else if (strcmp(key, "read_start") == 0) {
+                cfg->start = atoi(val);
+            } else if (strcmp(key, "read_len") == 0) {
+                cfg->len = atoi(val);
+            } else if (strcmp(key, "tsap_local") == 0) {
+                cfg->tsap_local = (int)strtol(val, NULL, 0);
+            } else if (strcmp(key, "tsap_remote") == 0) {
+                cfg->tsap_remote = (int)strtol(val, NULL, 0);
+            }
+        } else if (section == 7) {
+            /* e.g. `0 : 6.6,6.7  ; 8.4,8.5`  (byte.bit, ';' = another group) */
+            int layout;
+            char *colon = strchr(s, ':');
+            if (!colon) continue;
+            if (sscanf(s, "%d", &layout) != 1) continue;
+            if (layout < 0 || layout >= MAX_RULES) continue;
+            PlcRuleCfg *R = &g_rules[layout];
+            memset(R, 0, sizeof(*R));
+            R->layout = layout;
+            char *tok = colon + 1;
+            while (tok && *tok && R->nbits < MAX_RULE_BITS) {
+                while (*tok == ' ' || *tok == '\t' || *tok == ',' || *tok == ';')
+                    tok++;
+                if (*tok == ':' || *tok == '\0' ||
+                    *tok == '\n' || *tok == '\r' || *tok == '#') break;
+                int by, bi = 0;
+                if (sscanf(tok, "%d.%d", &by, &bi) == 2) {
+                    /* ok */
+                } else if (sscanf(tok, "%d", &by) == 1) {
+                    bi = 0;
+                } else {
+                    break;
+                }
+                R->bits[R->nbits].byte = by;
+                R->bits[R->nbits].bit = bi;
+                R->nbits++;
+                R->has_bits = 1;
+                while (*tok && *tok != ',' && *tok != ';' && *tok != ':')
+                    tok++;
+            }
+            if (R->nbits > 0 && layout + 1 > g_num_rules)
+                g_num_rules = layout + 1;
+            rules_seen = 1;
+        }
+    }
+    fclose(fp);
+    if (cfg->len <= 0 || cfg->len > MAX_BLOCK) cfg->len = 1;
+    if (!rules_seen) {
+        /* no [plc_rules]: fall back to a fixed 6-state cycle on byte0 */
+        g_num_rules = 5;
+    }
+    return 0;
+}
+
+static void usage(const char *prog) {
+    fprintf(stderr,
+        "Usage: %s [-c channels.conf] [-t interval_ms] [-f first_state]\n"
+        "  -c   config file with [plc] and [plc_rules] sections (default: channels.conf)\n"
+        "  -t   interval between state switches in ms (default: 5000)\n"
+        "  -f   first state index 0..N (default: 0)\n",
+        prog);
+}
+
+int main(int argc, char **argv) {
+    SimCfg cfg;
+    memset(&cfg, 0, sizeof(cfg));
+    strncpy(cfg.ip, "192.168.1.90", sizeof(cfg.ip) - 1);
+    cfg.port = 102;
+    cfg.area = 0x81;          /* I area, matches channels.conf */
+    cfg.db = 1;
+    cfg.start = 0;
+    cfg.len = 1;
+    cfg.tsap_local = 0x0100;
+    cfg.tsap_remote = 0x0102;
+
+    const char *config_path = "channels.conf";
+    int interval_ms = 5000;
+    int state = 0;
+
+    int opt;
+    while ((opt = getopt(argc, argv, "c:t:f:h")) != -1) {
+        switch (opt) {
+        case 'c': config_path = optarg; break;
+        case 't': interval_ms = atoi(optarg); break;
+        case 'f': state = atoi(optarg); break;
+        case 'h': default: usage(argv[0]); return 1;
+        }
+    }
+    if (interval_ms <= 0) interval_ms = 5000;
+    if (state < 0) state = 0;
+
+    if (parse_config(config_path, &cfg) < 0)
+        fprintf(stderr, "WARN: %s\n", g_err);
+    if (state >= g_num_rules + 1) state = 0;
+
+    signal(SIGINT, sig_handler);
+    signal(SIGTERM, sig_handler);
+
+    printf("PLC simulator: %s:%d area=%s db=%d start=%d len=%d "
+           "interval=%dms rules=%d\n",
+           cfg.ip, cfg.port,
+           cfg.area >= 0x81 && cfg.area <= 0x84 ? area_names[cfg.area - 0x80] : "?",
+           cfg.db, cfg.start, cfg.len, interval_ms, g_num_rules);
+
+    int fd = -1;
+    while (g_running) {
+        if (fd < 0) {
+            fd = tcp_connect_handshake(&cfg);
+            if (fd < 0) {
+                printf("connect failed (%s), retrying in 2s...\n", g_err);
+                for (int i = 0; i < 40 && g_running; i++) usleep(50000);
+                continue;
+            }
+            printf("connected to %s:%d\n", cfg.ip, cfg.port);
+        }
+
+        uint8_t data[MAX_BLOCK];
+        char write_desc[256];
+        state_to_block(data, cfg.len, state, g_num_rules);
+        format_state_addr(write_desc, sizeof(write_desc), &cfg, data, cfg.len);
+
+        if (s7_write(fd, &cfg, data, cfg.len) < 0) {
+            printf("write failed (%s), reconnecting...\n", g_err);
+            close(fd);
+            fd = -1;
+            continue;
+        }
+        printf("state %d: %s (byte0=0x%02X)\n", state, write_desc, data[0]);
+
+        state = (state + 1) % (g_num_rules + 1);
+
+        for (int i = 0; i < interval_ms / 50 && g_running; i++)
+            usleep(50000);
+    }
+
+    if (fd >= 0) close(fd);
+    printf("done\n");
+    return 0;
+}

+ 804 - 0
rknn_api.h

@@ -0,0 +1,804 @@
+/****************************************************************************
+*
+*    Copyright (c) 2017 - 2022 by Rockchip Corp.  All rights reserved.
+*
+*    The material in this file is confidential and contains trade secrets
+*    of Rockchip Corporation. This is proprietary information owned by
+*    Rockchip Corporation. No part of this work may be disclosed,
+*    reproduced, copied, transmitted, or used in any way for any purpose,
+*    without the express written permission of Rockchip Corporation.
+*
+*****************************************************************************/
+
+
+#ifndef _RKNN_API_H
+#define _RKNN_API_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include <stdint.h>
+
+/*
+    Definition of extended flag for rknn_init.
+*/
+/* set high priority context. */
+#define RKNN_FLAG_PRIOR_HIGH                    0x00000000
+
+/* set medium priority context */
+#define RKNN_FLAG_PRIOR_MEDIUM                  0x00000001
+
+/* set low priority context. */
+#define RKNN_FLAG_PRIOR_LOW                     0x00000002
+
+/* asynchronous mode.
+   when enable, rknn_outputs_get will not block for too long because it directly retrieves the result of
+   the previous frame which can increase the frame rate on single-threaded mode, but at the cost of
+   rknn_outputs_get not retrieves the result of the current frame.
+   in multi-threaded mode you do not need to turn this mode on. */
+#define RKNN_FLAG_ASYNC_MASK                    0x00000004
+
+/* collect performance mode.
+   when enable, you can get detailed performance reports via rknn_query(ctx, RKNN_QUERY_PERF_DETAIL, ...),
+   but it will reduce the frame rate. */
+#define RKNN_FLAG_COLLECT_PERF_MASK             0x00000008
+
+/* allocate all memory in outside, includes weight/internal/inputs/outputs */
+#define RKNN_FLAG_MEM_ALLOC_OUTSIDE             0x00000010
+
+/* weight sharing with the same network structure */
+#define RKNN_FLAG_SHARE_WEIGHT_MEM              0x00000020
+
+/* send fence fd from outside */
+#define RKNN_FLAG_FENCE_IN_OUTSIDE              0x00000040
+
+/* get fence fd from inside */
+#define RKNN_FLAG_FENCE_OUT_OUTSIDE             0x00000080
+
+/* dummy init flag: could only get total_weight_size and total_internal_size by rknn_query*/
+#define RKNN_FLAG_COLLECT_MODEL_INFO_ONLY       0x00000100
+
+/* allocate internal memory in outside */
+#define RKNN_FLAG_INTERNAL_ALLOC_OUTSIDE        0x00000200
+
+/* set GPU as the preferred execution backend When the operator is not supported by the NPU */
+#define RKNN_FLAG_EXECUTE_FALLBACK_PRIOR_DEVICE_GPU 0x00000400
+
+/* enable allocate sram type buffers */
+#define RKNN_FLAG_ENABLE_SRAM                   0x00000800
+
+/* sram type buffers are shared among different contexts */
+#define RKNN_FLAG_SHARE_SRAM                    0x00001000
+
+/* default nice -19, this flag can disable default priority */
+#define RKNN_FLAG_DISABLE_PROC_HIGH_PRIORITY    0x00002000
+
+/* don't flush input buffer cache, the user must ensure that the input tensor has flushed the cache before calling rknn_run. 
+!!! Don't use this flags when you call rknn_inputs_set() to set input data. */
+#define RKNN_FLAG_DISABLE_FLUSH_INPUT_MEM_CACHE    0x00004000
+
+/* Don't invalid output buffer cache. 
+   Users cannot directly access output_mem->virt_addr, 
+   which will cause cache consistency problems. 
+   If you want to use output_mem->virt_addr, 
+   you must use rknn_mem_sync (ctx, mem, RKNN_MEMORY_SYNC_FROM_DEVICE) to flush the cache. 
+   This flags is generally used when the output data of the NPU is not accessed by the CPU, 
+   but is accessed by the GPU or RGA to reduce the time required to flush the cache. 
+   !!! Don't use this flags when you call rknn_outputs_get() to get output data.*/
+#define RKNN_FLAG_DISABLE_FLUSH_OUTPUT_MEM_CACHE   0x00008000
+
+/* This flag is used when the model data buffer is allocated by NPU, and can be accessed by NPU directly. */
+#define RKNN_FLAG_MODEL_BUFFER_ZERO_COPY           0x00010000
+
+/* This flag is a memory allocation flag, which is used in rknn_create_mem2() when no context is available. */
+#define RKNN_MEM_FLAG_ALLOC_NO_CONTEXT             0x00020000
+
+
+/*
+    Error code returned by the RKNN API.
+*/
+#define RKNN_SUCC                               0       /* execute succeed. */
+#define RKNN_ERR_FAIL                           -1      /* execute failed. */
+#define RKNN_ERR_TIMEOUT                        -2      /* execute timeout. */
+#define RKNN_ERR_DEVICE_UNAVAILABLE             -3      /* device is unavailable. */
+#define RKNN_ERR_MALLOC_FAIL                    -4      /* memory malloc fail. */
+#define RKNN_ERR_PARAM_INVALID                  -5      /* parameter is invalid. */
+#define RKNN_ERR_MODEL_INVALID                  -6      /* model is invalid. */
+#define RKNN_ERR_CTX_INVALID                    -7      /* context is invalid. */
+#define RKNN_ERR_INPUT_INVALID                  -8      /* input is invalid. */
+#define RKNN_ERR_OUTPUT_INVALID                 -9      /* output is invalid. */
+#define RKNN_ERR_DEVICE_UNMATCH                 -10     /* the device is unmatch, please update rknn sdk
+                                                           and npu driver/firmware. */
+#define RKNN_ERR_INCOMPATILE_PRE_COMPILE_MODEL  -11     /* This RKNN model use pre_compile mode, but not compatible with current driver. */
+#define RKNN_ERR_INCOMPATILE_OPTIMIZATION_LEVEL_VERSION  -12     /* This RKNN model set optimization level, but not compatible with current driver. */
+#define RKNN_ERR_TARGET_PLATFORM_UNMATCH        -13     /* This RKNN model set target platform, but not compatible with current platform. */
+
+/*
+    Definition for tensor
+*/
+#define RKNN_MAX_DIMS                           16      /* maximum dimension of tensor. */
+#define RKNN_MAX_NUM_CHANNEL                    15      /* maximum channel number of input tensor. */
+#define RKNN_MAX_NAME_LEN                       256     /* maximum name lenth of tensor. */
+#define RKNN_MAX_DYNAMIC_SHAPE_NUM              512     /* maximum number of dynamic shape for each input. */
+
+#ifdef __arm__
+typedef uint32_t rknn_context;
+#else
+typedef uint64_t rknn_context;
+#endif
+
+
+/*
+    The query command for rknn_query
+*/
+typedef enum _rknn_query_cmd {
+    RKNN_QUERY_IN_OUT_NUM = 0,                              /* query the number of input & output tensor. */
+    RKNN_QUERY_INPUT_ATTR = 1,                              /* query the attribute of input tensor. */
+    RKNN_QUERY_OUTPUT_ATTR = 2,                             /* query the attribute of output tensor. */
+    RKNN_QUERY_PERF_DETAIL = 3,                             /* query the detail performance, need set
+                                                               RKNN_FLAG_COLLECT_PERF_MASK when call rknn_init,
+                                                               this query needs to be valid after rknn_outputs_get. */
+    RKNN_QUERY_PERF_RUN = 4,                                /* query the time of run,
+                                                               this query needs to be valid after rknn_outputs_get. */
+    RKNN_QUERY_SDK_VERSION = 5,                             /* query the sdk & driver version */
+
+    RKNN_QUERY_MEM_SIZE = 6,                                /* query the weight & internal memory size */
+    RKNN_QUERY_CUSTOM_STRING = 7,                           /* query the custom string */
+
+    RKNN_QUERY_NATIVE_INPUT_ATTR = 8,                       /* query the attribute of native input tensor. */
+    RKNN_QUERY_NATIVE_OUTPUT_ATTR = 9,                      /* query the attribute of native output tensor. */
+
+    RKNN_QUERY_NATIVE_NC1HWC2_INPUT_ATTR = 8,               /* query the attribute of native input tensor. */
+    RKNN_QUERY_NATIVE_NC1HWC2_OUTPUT_ATTR = 9,              /* query the attribute of native output tensor. */
+
+    RKNN_QUERY_NATIVE_NHWC_INPUT_ATTR = 10,                 /* query the attribute of native input tensor. */
+    RKNN_QUERY_NATIVE_NHWC_OUTPUT_ATTR = 11,                /* query the attribute of native output tensor. */
+
+    RKNN_QUERY_DEVICE_MEM_INFO = 12,                        /* query the attribute of rknn memory information. */
+
+    RKNN_QUERY_INPUT_DYNAMIC_RANGE = 13,                    /* query the dynamic shape range of rknn input tensor. */
+    RKNN_QUERY_CURRENT_INPUT_ATTR = 14,                     /* query the current shape of rknn input tensor, only valid for dynamic rknn model*/
+    RKNN_QUERY_CURRENT_OUTPUT_ATTR = 15,                    /* query the current shape of rknn output tensor, only valid for dynamic rknn model*/
+
+    RKNN_QUERY_CURRENT_NATIVE_INPUT_ATTR = 16,              /* query the current native shape of rknn input tensor, only valid for dynamic rknn model*/
+    RKNN_QUERY_CURRENT_NATIVE_OUTPUT_ATTR = 17,             /* query the current native shape of rknn output tensor, only valid for dynamic rknn model*/
+
+
+    RKNN_QUERY_CMD_MAX
+} rknn_query_cmd;
+
+/*
+    the tensor data type.
+*/
+typedef enum _rknn_tensor_type {
+    RKNN_TENSOR_FLOAT32 = 0,                            /* data type is float32. */
+    RKNN_TENSOR_FLOAT16,                                /* data type is float16. */
+    RKNN_TENSOR_INT8,                                   /* data type is int8. */
+    RKNN_TENSOR_UINT8,                                  /* data type is uint8. */
+    RKNN_TENSOR_INT16,                                  /* data type is int16. */
+    RKNN_TENSOR_UINT16,                                 /* data type is uint16. */
+    RKNN_TENSOR_INT32,                                  /* data type is int32. */
+    RKNN_TENSOR_UINT32,                                 /* data type is uint32. */
+    RKNN_TENSOR_INT64,                                  /* data type is int64. */
+    RKNN_TENSOR_BOOL,
+    RKNN_TENSOR_INT4,
+    RKNN_TENSOR_BFLOAT16,
+
+    RKNN_TENSOR_TYPE_MAX
+} rknn_tensor_type;
+
+inline static const char* get_type_string(rknn_tensor_type type)
+{
+    switch(type) {
+    case RKNN_TENSOR_FLOAT32: return "FP32";
+    case RKNN_TENSOR_FLOAT16: return "FP16";
+    case RKNN_TENSOR_INT8: return "INT8";
+    case RKNN_TENSOR_UINT8: return "UINT8";
+    case RKNN_TENSOR_INT16: return "INT16";
+    case RKNN_TENSOR_UINT16: return "UINT16";
+    case RKNN_TENSOR_INT32: return "INT32";
+    case RKNN_TENSOR_UINT32: return "UINT32";
+    case RKNN_TENSOR_INT64: return "INT64";
+    case RKNN_TENSOR_BOOL: return "BOOL";
+    case RKNN_TENSOR_INT4: return "INT4";
+    case RKNN_TENSOR_BFLOAT16: return "BF16";
+    default: return "UNKNOW";
+    }
+}
+
+/*
+    the quantitative type.
+*/
+typedef enum _rknn_tensor_qnt_type {
+    RKNN_TENSOR_QNT_NONE = 0,                           /* none. */
+    RKNN_TENSOR_QNT_DFP,                                /* dynamic fixed point. */
+    RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC,                  /* asymmetric affine. */
+
+    RKNN_TENSOR_QNT_MAX
+} rknn_tensor_qnt_type;
+
+inline static const char* get_qnt_type_string(rknn_tensor_qnt_type type)
+{
+    switch(type) {
+    case RKNN_TENSOR_QNT_NONE: return "NONE";
+    case RKNN_TENSOR_QNT_DFP: return "DFP";
+    case RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC: return "AFFINE";
+    default: return "UNKNOW";
+    }
+}
+
+/*
+    the tensor data format.
+*/
+typedef enum _rknn_tensor_format {
+    RKNN_TENSOR_NCHW = 0,                               /* data format is NCHW. */
+    RKNN_TENSOR_NHWC,                                   /* data format is NHWC. */
+    RKNN_TENSOR_NC1HWC2,                                /* data format is NC1HWC2. */
+    RKNN_TENSOR_UNDEFINED,
+
+    RKNN_TENSOR_FORMAT_MAX
+} rknn_tensor_format;
+
+/*
+    the mode of running on target NPU core.
+*/
+typedef enum _rknn_core_mask {
+    RKNN_NPU_CORE_AUTO = 0,                                       /* default, run on NPU core randomly. */
+    RKNN_NPU_CORE_0 = 1,                                          /* run on NPU core 0. */
+    RKNN_NPU_CORE_1 = 2,                                          /* run on NPU core 1. */
+    RKNN_NPU_CORE_2 = 4,                                          /* run on NPU core 2. */
+    RKNN_NPU_CORE_0_1 = RKNN_NPU_CORE_0 | RKNN_NPU_CORE_1,        /* run on NPU core 0 and core 1. */
+    RKNN_NPU_CORE_0_1_2 = RKNN_NPU_CORE_0_1 | RKNN_NPU_CORE_2,    /* run on NPU core 0 and core 1 and core 2. */
+    RKNN_NPU_CORE_ALL = 0xffff,                                   /* auto choice, run on NPU cores depending on platform */
+
+    RKNN_NPU_CORE_UNDEFINED,
+} rknn_core_mask;
+
+inline static const char* get_format_string(rknn_tensor_format fmt)
+{
+    switch(fmt) {
+    case RKNN_TENSOR_NCHW: return "NCHW";
+    case RKNN_TENSOR_NHWC: return "NHWC";
+    case RKNN_TENSOR_NC1HWC2: return "NC1HWC2";
+    case RKNN_TENSOR_UNDEFINED: return "UNDEFINED";
+    default: return "UNKNOW";
+    }
+}
+
+/*
+    the information for RKNN_QUERY_IN_OUT_NUM.
+*/
+typedef struct _rknn_input_output_num {
+    uint32_t n_input;                                   /* the number of input. */
+    uint32_t n_output;                                  /* the number of output. */
+} rknn_input_output_num;
+
+/*
+    the information for RKNN_QUERY_INPUT_ATTR / RKNN_QUERY_OUTPUT_ATTR.
+*/
+typedef struct _rknn_tensor_attr {
+    uint32_t index;                                     /* input parameter, the index of input/output tensor,
+                                                           need set before call rknn_query. */
+
+    uint32_t n_dims;                                    /* the number of dimensions. */
+    uint32_t dims[RKNN_MAX_DIMS];                       /* the dimensions array. */
+    char name[RKNN_MAX_NAME_LEN];                       /* the name of tensor. */
+
+    uint32_t n_elems;                                   /* the number of elements. */
+    uint32_t size;                                      /* the bytes size of tensor. */
+
+    rknn_tensor_format fmt;                             /* the data format of tensor. */
+    rknn_tensor_type type;                              /* the data type of tensor. */
+    rknn_tensor_qnt_type qnt_type;                      /* the quantitative type of tensor. */
+    int8_t fl;                                          /* fractional length for RKNN_TENSOR_QNT_DFP. */
+    int32_t zp;                                         /* zero point for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */
+    float scale;                                        /* scale for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */
+
+    uint32_t w_stride;                                  /* the stride of tensor along the width dimention of input,
+                                                           Note: it is read-only, 0 means equal to width. */
+    uint32_t size_with_stride;                          /* the bytes size of tensor with stride. */
+
+    uint8_t pass_through;                               /* pass through mode, for rknn_set_io_mem interface.
+                                                           if TRUE, the buf data is passed directly to the input node of the rknn model
+                                                                    without any conversion. the following variables do not need to be set.
+                                                           if FALSE, the buf data is converted into an input consistent with the model
+                                                                     according to the following type and fmt. so the following variables
+                                                                     need to be set.*/
+    uint32_t h_stride;                                  /* the stride along the height dimention of input,
+                                                           Note: it is write-only, if it was set to 0, h_stride = height. */
+} rknn_tensor_attr;
+
+typedef struct _rknn_input_range {
+    uint32_t index;                                                 /* input parameter, the index of input/output tensor,
+                                                                        need set before call rknn_query. */
+    uint32_t shape_number;                                          /* the number of shape. */
+    rknn_tensor_format fmt;                                         /* the data format of tensor. */
+    char name[RKNN_MAX_NAME_LEN];                                   /* the name of tensor. */
+    uint32_t dyn_range[RKNN_MAX_DYNAMIC_SHAPE_NUM][RKNN_MAX_DIMS];  /* the dynamic input dimensions range. */
+    uint32_t n_dims;                                                /* the number of dimensions. */
+
+} rknn_input_range;
+
+/*
+    the information for RKNN_QUERY_PERF_DETAIL.
+*/
+typedef struct _rknn_perf_detail {
+    char* perf_data;                                    /* the string pointer of perf detail. don't need free it by user. */
+    uint64_t data_len;                                  /* the string length. */
+} rknn_perf_detail;
+
+/*
+    the information for RKNN_QUERY_PERF_RUN.
+*/
+typedef struct _rknn_perf_run {
+    int64_t run_duration;                               /* real inference time (us) */
+} rknn_perf_run;
+
+/*
+    the information for RKNN_QUERY_SDK_VERSION.
+*/
+typedef struct _rknn_sdk_version {
+    char api_version[256];                              /* the version of rknn api. */
+    char drv_version[256];                              /* the version of rknn driver. */
+} rknn_sdk_version;
+
+/*
+    the information for RKNN_QUERY_MEM_SIZE.
+*/
+typedef struct _rknn_mem_size {
+    uint32_t total_weight_size;                         /* the weight memory size */
+    uint32_t total_internal_size;                       /* the internal memory size, exclude inputs/outputs */
+    uint64_t total_dma_allocated_size;                  /* total dma memory allocated size */
+    uint32_t total_sram_size;                           /* total system sram size reserved for rknn */
+    uint32_t free_sram_size;                            /* free system sram size reserved for rknn */
+    uint32_t reserved[10];                              /* reserved */
+} rknn_mem_size;
+
+/*
+    the information for RKNN_QUERY_CUSTOM_STRING.
+*/
+typedef struct _rknn_custom_string {
+    char string[1024];                                  /* the string of custom, lengths max to 1024 bytes */
+} rknn_custom_string;
+
+/*
+   The flags of rknn_tensor_mem.
+*/
+typedef enum _rknn_tensor_mem_flags {
+    RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE = 1,           /*Used to mark in rknn_destroy_mem() whether it is necessary to release the "mem" pointer itself.
+                                                         If the flag RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE is set, rknn_destroy_mem() will call free(mem).*/
+    RKNN_TENSOR_MEMORY_FLAGS_FROM_FD      = 2,           /*Used to mark in rknn_create_mem_from_fd() whether it is necessary to release the "mem" pointer itself.
+                                                         If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_FD is set, rknn_destroy_mem() will call free(mem).*/
+    RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS    = 3,           /*Used to mark in rknn_create_mem_from_phys() whether it is necessary to release the "mem" pointer itself.
+                                                         If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS is set, rknn_destroy_mem() will call free(mem).*/
+    RKNN_TENSOR_MEMORY_FLAGS_UNKNOWN
+} rknn_tensor_mem_flags;
+
+/*
+   The mode to allocate rknn memory.
+*/
+typedef enum _rknn_mem_alloc_flags {
+    RKNN_FLAG_MEMORY_FLAGS_DEFAULT = 0 << 0, /* Same with RKNN_FLAG_MEMORY_CACHEABLE */
+    RKNN_FLAG_MEMORY_CACHEABLE  = 1 << 0, /* Create Cacheable memory. */
+    RKNN_FLAG_MEMORY_NON_CACHEABLE = 1 << 1, /* Create NON-Cacheable memory. */
+    RKNN_FLAG_MEMORY_TRY_ALLOC_SRAM = 1 << 2, /* Try to allocate memory in SRAM if possible. if SRAM is not enough, allocate rest memory in DRAM. */
+} rknn_mem_alloc_flags;
+
+/*
+   The mode to sync cacheable rknn memory.
+*/
+typedef enum _rknn_mem_sync_mode {
+    RKNN_MEMORY_SYNC_TO_DEVICE = 0x1, /* the mode used for consistency of device access after CPU accesses data. */
+    RKNN_MEMORY_SYNC_FROM_DEVICE = 0x2, /* the mode used for consistency of CPU access after device accesses data. */
+    RKNN_MEMORY_SYNC_BIDIRECTIONAL = RKNN_MEMORY_SYNC_TO_DEVICE | RKNN_MEMORY_SYNC_FROM_DEVICE, /* the mode used for consistency of data access
+                                                                                                         between device and CPU in both directions. */
+} rknn_mem_sync_mode;
+
+/*
+    the memory information of tensor.
+*/
+typedef struct _rknn_tensor_memory {
+    void*            virt_addr;                         /* the virtual address of tensor buffer. */
+    uint64_t         phys_addr;                         /* the physical address of tensor buffer. */
+    int32_t          fd;                                /* the fd of tensor buffer. */
+    int32_t          offset;                            /* indicates the offset of the memory. */
+    uint32_t         size;                              /* the size of tensor buffer. */
+    uint32_t         flags;                             /* the flags of tensor buffer, reserved */
+    void *           priv_data;                         /* the private data of tensor buffer. */
+} rknn_tensor_mem;
+
+/*
+    the input information for rknn_input_set.
+*/
+typedef struct _rknn_input {
+    uint32_t index;                                     /* the input index. */
+    void* buf;                                          /* the input buf for index. */
+    uint32_t size;                                      /* the size of input buf. */
+    uint8_t pass_through;                               /* pass through mode.
+                                                           if TRUE, the buf data is passed directly to the input node of the rknn model
+                                                                    without any conversion. the following variables do not need to be set.
+                                                           if FALSE, the buf data is converted into an input consistent with the model
+                                                                     according to the following type and fmt. so the following variables
+                                                                     need to be set.*/
+    rknn_tensor_type type;                              /* the data type of input buf. */
+    rknn_tensor_format fmt;                             /* the data format of input buf.
+                                                           currently the internal input format of NPU is NCHW by default.
+                                                           so entering NCHW data can avoid the format conversion in the driver. */
+} rknn_input;
+
+/*
+    the output information for rknn_outputs_get.
+*/
+typedef struct _rknn_output {
+    uint8_t want_float;                                 /* want transfer output data to float */
+    uint8_t is_prealloc;                                /* whether buf is pre-allocated.
+                                                           if TRUE, the following variables need to be set.
+                                                           if FALSE, the following variables do not need to be set. */
+    uint32_t index;                                     /* the output index. */
+    void* buf;                                          /* the output buf for index.
+                                                           when is_prealloc = FALSE and rknn_outputs_release called,
+                                                           this buf pointer will be free and don't use it anymore. */
+    uint32_t size;                                      /* the size of output buf. */
+} rknn_output;
+
+/*
+    the extend information for rknn_init.
+*/
+typedef struct _rknn_init_extend {
+    rknn_context ctx;                                    /* rknn context */
+    int32_t      real_model_offset;                      /* real rknn model file offset, only valid when init context with rknn file path and zero-copy model model */
+    uint32_t     real_model_size;                        /* real rknn model file size, only valid when init context with rknn file path and zero-copy model model */
+    int32_t      model_buffer_fd;                        /* the fd of model buffer. */
+    uint32_t     model_buffer_flags;                     /* the flags of model_buffer */
+    uint8_t      reserved[112];                          /* reserved */
+} rknn_init_extend;
+
+/*
+    the extend information for rknn_run.
+*/
+typedef struct _rknn_run_extend {
+    uint64_t frame_id;                                  /* output parameter, indicate current frame id of run. */
+    int32_t non_block;                                  /* block flag of run, 0 is block else 1 is non block */
+    int32_t timeout_ms;                                 /* timeout for block mode, in milliseconds */
+    int32_t fence_fd;                                   /* fence fd from other unit */
+} rknn_run_extend;
+
+/*
+    the extend information for rknn_outputs_get.
+*/
+typedef struct _rknn_output_extend {
+    uint64_t frame_id;                                  /* output parameter, indicate the frame id of outputs, corresponds to
+                                                           struct rknn_run_extend.frame_id.*/
+} rknn_output_extend;
+
+
+/*  rknn_init
+
+    initial the context and load the rknn model.
+
+    input:
+        rknn_context* context       the pointer of context handle.
+        void* model                 if size > 0, pointer to the rknn model, if size = 0, filepath to the rknn model.
+        uint32_t size               the size of rknn model.
+        uint32_t flag               extend flag, see the define of RKNN_FLAG_XXX_XXX.
+        rknn_init_extend* extend    the extend information of init.
+    return:
+        int                         error code.
+*/
+int rknn_init(rknn_context* context, void* model, uint32_t size, uint32_t flag, rknn_init_extend* extend);
+
+/*  rknn_dup_context
+
+    initial the context and load the rknn model.
+
+    input:
+        rknn_context* context_in       the pointer of context in handle.
+        rknn_context* context_out      the pointer of context out handle.
+    return:
+        int                         error code.
+*/
+int rknn_dup_context(rknn_context* context_in, rknn_context* context_out);
+
+/*  rknn_destroy
+
+    unload the rknn model and destroy the context.
+
+    input:
+        rknn_context context        the handle of context.
+    return:
+        int                         error code.
+*/
+int rknn_destroy(rknn_context context);
+
+
+/*  rknn_query
+
+    query the information about model or others. see rknn_query_cmd.
+
+    input:
+        rknn_context context        the handle of context.
+        rknn_query_cmd cmd          the command of query.
+        void* info                  the buffer point of information.
+        uint32_t size               the size of information.
+    return:
+        int                         error code.
+*/
+int rknn_query(rknn_context context, rknn_query_cmd cmd, void* info, uint32_t size);
+
+
+/*  rknn_inputs_set
+
+    set inputs information by input index of rknn model.
+    inputs information see rknn_input.
+
+    input:
+        rknn_context context        the handle of context.
+        uint32_t n_inputs           the number of inputs.
+        rknn_input inputs[]         the arrays of inputs information, see rknn_input.
+    return:
+        int                         error code
+*/
+int rknn_inputs_set(rknn_context context, uint32_t n_inputs, rknn_input inputs[]);
+
+/*
+    rknn_set_batch_core_num
+
+    set rknn batch core_num.
+
+    input:
+        rknn_context context        the handle of context.
+        int core_num                the core number.
+    return:
+        int                         error code.
+
+*/
+int rknn_set_batch_core_num(rknn_context context, int core_num);
+
+/*  rknn_set_core_mask
+
+    set the core mask for the model.(only supported on multi-core NPU platform)
+
+    RKNN_NPU_CORE_AUTO: auto mode, default value
+    RKNN_NPU_CORE_0: core 0 mode
+    RKNN_NPU_CORE_1: core 1 mode
+    RKNN_NPU_CORE_2: core 2 mode
+    RKNN_NPU_CORE_0_1: combine core 0/1 mode
+    RKNN_NPU_CORE_0_1_2: combine core 0/1/2 mode
+    RKNN_NPU_CORE_ALL: auto mode, select multiple npu cores to run depending on platform 
+
+
+    input:
+        rknn_context context        the handle of context.
+        rknn_core_mask core_mask    the core mask.
+    return:
+        int                         error code.
+*/
+int rknn_set_core_mask(rknn_context context, rknn_core_mask core_mask);
+
+/*  rknn_run
+
+    run the model to execute inference.
+
+    input:
+        rknn_context context        the handle of context.
+        rknn_run_extend* extend     the extend information of run.
+    return:
+        int                         error code.
+*/
+int rknn_run(rknn_context context, rknn_run_extend* extend);
+
+
+/*  rknn_wait
+
+    wait the model after execute inference.
+
+    input:
+        rknn_context context        the handle of context.
+        rknn_run_extend* extend     the extend information of run.
+    return:
+        int                         error code.
+*/
+int rknn_wait(rknn_context context, rknn_run_extend* extend);
+
+
+/*  rknn_outputs_get
+
+    wait the inference to finish and get the outputs.
+    this function will block until inference finish.
+    the results will set to outputs[].
+
+    input:
+        rknn_context context        the handle of context.
+        uint32_t n_outputs          the number of outputs.
+        rknn_output outputs[]       the arrays of output, see rknn_output.
+        rknn_output_extend*         the extend information of output.
+    return:
+        int                         error code.
+*/
+int rknn_outputs_get(rknn_context context, uint32_t n_outputs, rknn_output outputs[], rknn_output_extend* extend);
+
+
+/*  rknn_outputs_release
+
+    release the outputs that get by rknn_outputs_get.
+    after called, the rknn_output[x].buf get from rknn_outputs_get will
+    also be free when rknn_output[x].is_prealloc = FALSE.
+
+    input:
+        rknn_context context        the handle of context.
+        uint32_t n_ouputs           the number of outputs.
+        rknn_output outputs[]       the arrays of output.
+    return:
+        int                         error code
+*/
+int rknn_outputs_release(rknn_context context, uint32_t n_ouputs, rknn_output outputs[]);
+
+
+/* new api for zero copy */
+
+/*  rknn_create_mem_from_phys (memory allocated outside)
+
+    initialize tensor memory from physical address.
+
+    input:
+        rknn_context ctx            the handle of context.
+        uint64_t phys_addr          physical address.
+        void *virt_addr             virtual address.
+        uint32_t size               the size of tensor buffer.
+    return:
+        rknn_tensor_mem             the pointer of tensor memory information.
+*/
+rknn_tensor_mem* rknn_create_mem_from_phys(rknn_context ctx, uint64_t phys_addr, void *virt_addr, uint32_t size);
+
+
+/*  rknn_create_mem_from_fd (memory allocated outside)
+
+    initialize tensor memory from file description.
+
+    input:
+        rknn_context ctx            the handle of context.
+        int32_t fd                  file description.
+        void *virt_addr             virtual address.
+        uint32_t size               the size of tensor buffer.
+        int32_t offset              indicates the offset of the memory (virt_addr without offset).
+    return:
+        rknn_tensor_mem             the pointer of tensor memory information.
+*/
+rknn_tensor_mem* rknn_create_mem_from_fd(rknn_context ctx, int32_t fd, void *virt_addr, uint32_t size, int32_t offset);
+
+
+/*  rknn_create_mem_from_mb_blk (memory allocated outside)
+
+    create tensor memory from mb_blk.
+
+    input:
+        rknn_context ctx            the handle of context.
+        void *mb_blk                mb_blk allocate from system api.
+        int32_t offset              indicates the offset of the memory.
+    return:
+        rknn_tensor_mem             the pointer of tensor memory information.
+*/
+rknn_tensor_mem* rknn_create_mem_from_mb_blk(rknn_context ctx, void *mb_blk, int32_t offset);
+
+
+/*  rknn_create_mem (memory allocated inside)
+
+    create tensor memory.
+
+    input:
+        rknn_context ctx            the handle of context.
+        uint32_t size               the size of tensor buffer.
+    return:
+        rknn_tensor_mem             the pointer of tensor memory information.
+*/
+rknn_tensor_mem* rknn_create_mem(rknn_context ctx, uint32_t size);
+
+/*  rknn_create_mem2 (memory allocated inside)
+
+    create tensor memory.
+
+    input:
+        rknn_context ctx            the handle of context.
+        uint64_t size               the size of tensor buffer.
+        uint64_t alloc_flags        memory allocation flags.
+    return:
+        rknn_tensor_mem             the pointer of tensor memory information.
+*/
+rknn_tensor_mem* rknn_create_mem2(rknn_context ctx, uint64_t size, uint64_t alloc_flags);
+
+/*  rknn_destroy_mem (support allocate inside and outside)
+
+    destroy tensor memory.
+
+    input:
+        rknn_context ctx            the handle of context.
+        rknn_tensor_mem *mem        the pointer of tensor memory information.
+    return:
+        int                         error code
+*/
+int rknn_destroy_mem(rknn_context ctx, rknn_tensor_mem *mem);
+
+
+/*  rknn_set_weight_mem
+
+    set the weight memory.
+
+    input:
+        rknn_context ctx            the handle of context.
+        rknn_tensor_mem *mem        the array of tensor memory information
+    return:
+        int                         error code.
+*/
+int rknn_set_weight_mem(rknn_context ctx, rknn_tensor_mem *mem);
+
+
+/*  rknn_set_internal_mem
+
+    set the internal memory.
+
+    input:
+        rknn_context ctx            the handle of context.
+        rknn_tensor_mem *mem        the array of tensor memory information
+    return:
+        int                         error code.
+*/
+int rknn_set_internal_mem(rknn_context ctx, rknn_tensor_mem *mem);
+
+
+/*  rknn_set_io_mem
+
+    set the input and output tensors buffer.
+
+    input:
+        rknn_context ctx            the handle of context.
+        rknn_tensor_mem *mem        the array of tensor memory information.
+        rknn_tensor_attr *attr      the attribute of input or output tensor buffer.
+    return:
+        int                         error code.
+*/
+int rknn_set_io_mem(rknn_context ctx, rknn_tensor_mem *mem, rknn_tensor_attr *attr);
+
+/*  rknn_set_input_shape(deprecated)
+
+    set the input tensor shape (only valid for dynamic shape rknn model).
+
+    input:
+        rknn_context ctx            the handle of context.
+        rknn_tensor_attr *attr      the attribute of input or output tensor buffer.
+    return:
+        int                         error code.
+*/
+int rknn_set_input_shape(rknn_context ctx, rknn_tensor_attr* attr);
+
+/*  rknn_set_input_shapes
+
+    set all the input tensor shapes. graph will run under current set of input shapes after rknn_set_input_shapes.(only valid for dynamic shape rknn model).
+
+    input:
+        rknn_context ctx            the handle of context.
+        uint32_t n_inputs           the number of inputs.
+        rknn_tensor_attr attr[]     the attribute array of all input tensors.
+    return:
+        int                         error code.
+*/
+int rknn_set_input_shapes(rknn_context ctx, uint32_t n_inputs, rknn_tensor_attr attr[]);
+
+/*  rknn_mem_sync
+
+    sync cacheable rknn memory when both cpu and device access data.
+
+    input:
+        rknn_context context        the handle of context.
+        rknn_tensor_mem *mem        the pointer of tensor memory information.
+        rknn_mem_sync_mode mode     the mode of sync cache.
+    return:
+        int                         error code.
+*/
+int rknn_mem_sync(rknn_context context, rknn_tensor_mem* mem, rknn_mem_sync_mode mode);
+
+#ifdef __cplusplus
+} //extern "C"
+#endif
+
+#endif  //_RKNN_API_H

+ 21 - 0
start.sh

@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -e
+
+DIR="$(cd "$(dirname "$0")" && pwd)"
+BIN="$DIR/build/rtsp_multi_view"
+CONF="${CONF:-$DIR/channels.conf}"
+
+if [ ! -f "$BIN" ]; then
+    echo "Binary not found: $BIN"
+    echo "Run ./build.sh first."
+    exit 1
+fi
+
+if [ ! -f "$CONF" ]; then
+    echo "Config not found: $CONF"
+    echo "Set CONF=/path/to/channels.conf to override."
+    exit 1
+fi
+
+# 单通道布局时自动切换为 [main_streams] 里的主码流 URL(见 channels.conf)
+exec stdbuf -oL "$BIN" -c "$CONF" "$@"

+ 156 - 0
test_detector_bench.c

@@ -0,0 +1,156 @@
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/time.h>
+#include <fcntl.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <linux/dma-heap.h>
+#include <math.h>
+
+#include "detector.h"
+
+static int dma_heap_alloc(size_t size, int *fd) {
+    char path[64];
+    snprintf(path, 64, "/dev/dma_heap/reserved");
+    int heap_fd = open(path, O_RDWR | O_CLOEXEC);
+    if (heap_fd < 0) {
+        heap_fd = open("/dev/dma_heap/system-uncached", O_RDWR | O_CLOEXEC);
+        if (heap_fd < 0) return -1;
+    }
+    struct dma_heap_allocation_data d = { .len = size, .fd_flags = O_RDWR | O_CLOEXEC };
+    int ret = ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &d);
+    close(heap_fd);
+    if (ret < 0) return -1;
+    *fd = d.fd;
+    return 0;
+}
+
+static double get_time_ms(void) {
+    struct timeval tv;
+    gettimeofday(&tv, NULL);
+    return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0;
+}
+
+static int bench_model(const char *model_path, const char *model_name,
+                       int nv12_fd, int src_w, int src_h,
+                       int warmup, int iterations) {
+    printf("\n=== Benchmark: %s (%s) ===\n", model_name, model_path);
+
+    Detector *det = detector_create(model_path, 0.25f, 0.45f);
+    if (!det) {
+        printf("  FAILED to create detector\n");
+        return -1;
+    }
+
+    int mw = detector_get_input_width(det);
+    int mh = detector_get_input_height(det);
+    printf("  Model input: %dx%d\n", mw, mh);
+
+    DetResult results[MAX_DETECTIONS];
+    int num_results = 0;
+
+    for (int i = 0; i < warmup; i++) {
+        detector_detect(det, nv12_fd, src_w, src_h, results, &num_results);
+    }
+
+    double t_start = get_time_ms();
+    int total_detections = 0;
+    int valid_runs = 0;
+
+    for (int i = 0; i < iterations; i++) {
+        num_results = 0;
+        int ret = detector_detect(det, nv12_fd, src_w, src_h, results, &num_results);
+        if (ret == 0) {
+            total_detections += num_results;
+            valid_runs++;
+        }
+    }
+
+    double t_end = get_time_ms();
+    double elapsed = t_end - t_start;
+    double avg_ms = valid_runs > 0 ? elapsed / valid_runs : 0;
+    double fps = avg_ms > 0 ? 1000.0 / avg_ms : 0;
+    int avg_det = valid_runs > 0 ? total_detections / valid_runs : 0;
+
+    printf("  Warmup: %d, Iterations: %d (valid: %d)\n", warmup, iterations, valid_runs);
+    printf("  Total time: %.1f ms\n", elapsed);
+    printf("  Avg inference: %.2f ms/frame\n", avg_ms);
+    printf("  Avg FPS: %.1f\n", fps);
+    printf("  Avg detections: %d\n", avg_det);
+
+    if (valid_runs > 0 && num_results > 0) {
+        printf("  Last frame detections:\n");
+        for (int i = 0; i < num_results && i < 10; i++) {
+            printf("    [%d] label=%d score=%.3f box=[%.1f,%.1f,%.1f,%.1f]\n",
+                   i, results[i].label, results[i].score,
+                   results[i].x1, results[i].y1, results[i].x2, results[i].y2);
+        }
+        if (num_results > 10)
+            printf("    ... and %d more\n", num_results - 10);
+    }
+
+    detector_destroy(det);
+    return 0;
+}
+
+int main(int argc, char **argv) {
+    const char *model5 = "models/yolov5s.rknn";
+    const char *model8 = "models/yolov8s.rknn";
+    int src_w = 1920, src_h = 1080;
+    int warmup = 5, iterations = 50;
+
+    for (int i = 1; i < argc; i++) {
+        if (strcmp(argv[i], "-w") == 0 && i + 1 < argc) { src_w = atoi(argv[++i]); }
+        else if (strcmp(argv[i], "-h") == 0 && i + 1 < argc) { src_h = atoi(argv[++i]); }
+        else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) { iterations = atoi(argv[++i]); }
+        else if (strcmp(argv[i], "-m5") == 0 && i + 1 < argc) { model5 = argv[++i]; }
+        else if (strcmp(argv[i], "-m8") == 0 && i + 1 < argc) { model8 = argv[++i]; }
+    }
+
+    printf("Detector Benchmark: YOLOv5s vs YOLOv8s on RK3588 NPU\n");
+    printf("Source: %dx%d NV12 (synthetic), Warmup: %d, Iterations: %d\n",
+           src_w, src_h, warmup, iterations);
+
+    size_t nv12_size = src_w * src_h * 3 / 2;
+    int nv12_fd = -1;
+    if (dma_heap_alloc(nv12_size, &nv12_fd) < 0) {
+        printf("ERROR: failed to allocate NV12 buffer\n");
+        return 1;
+    }
+
+    void *nv12_map = mmap(NULL, nv12_size, PROT_READ | PROT_WRITE,
+                           MAP_SHARED, nv12_fd, 0);
+    if (nv12_map == MAP_FAILED) {
+        printf("ERROR: mmap failed\n");
+        close(nv12_fd);
+        return 1;
+    }
+
+    memset(nv12_map, 0x80, nv12_size);
+    uint8_t *y_plane = (uint8_t *)nv12_map;
+    for (int row = 0; row < src_h; row++) {
+        for (int col = 0; col < src_w; col++) {
+            int val = ((row + col) % 200) + 30;
+            y_plane[row * src_w + col] = (uint8_t)val;
+        }
+    }
+
+    printf("NV12 buffer ready (%zu bytes)\n", nv12_size);
+
+    int ret5 = bench_model(model5, "YOLOv5s", nv12_fd, src_w, src_h, warmup, iterations);
+    int ret8 = bench_model(model8, "YOLOv8s", nv12_fd, src_w, src_h, warmup, iterations);
+
+    printf("\n=== Summary ===\n");
+    if (ret5 == 0 && ret8 == 0) {
+        printf("Both models loaded and ran successfully.\n");
+        printf("See above for per-model latency and FPS comparison.\n");
+    } else {
+        if (ret5 < 0) printf("YOLOv5s: FAILED\n");
+        if (ret8 < 0) printf("YOLOv8s: FAILED\n");
+    }
+
+    munmap(nv12_map, nv12_size);
+    close(nv12_fd);
+    return 0;
+}

+ 110 - 0
test_dma_heap.c

@@ -0,0 +1,110 @@
+/*
+ * Unit test: DMA-BUF heap allocation + DRM import
+ * Tests whether dma_heap buffers can be imported into DRM (GEM handle).
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <linux/dma-heap.h>
+#include <xf86drm.h>
+#include <xf86drmMode.h>
+
+static int dma_heap_alloc(const char *heap_name, size_t size, int *fd) {
+    char path[64];
+    snprintf(path, sizeof(path), "/dev/dma_heap/%s", heap_name);
+    int heap_fd = open(path, O_RDWR | O_CLOEXEC);
+    if (heap_fd < 0) {
+        fprintf(stderr, "  open(%s) failed: %s\n", path, strerror(errno));
+        return -1;
+    }
+    struct dma_heap_allocation_data data = {
+        .len = size,
+        .fd_flags = O_RDWR | O_CLOEXEC,
+    };
+    int ret = ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &data);
+    close(heap_fd);
+    if (ret < 0) {
+        fprintf(stderr, "  DMA_HEAP_IOCTL_ALLOC(%s) failed: %s\n", heap_name, strerror(errno));
+        return -1;
+    }
+    *fd = data.fd;
+    return 0;
+}
+
+static void test_heap(const char *heap_name) {
+    printf("=== Testing heap: %s ===\n", heap_name);
+
+    int fd = -1;
+    if (dma_heap_alloc(heap_name, 1920 * 1080 * 3 / 2, &fd) < 0) {
+        printf("  FAIL: cannot allocate from %s\n\n", heap_name);
+        return;
+    }
+    printf("  OK: allocated buffer fd=%d size=%zu\n", 1920 * 1080 * 3 / 2, 1920 * 1080 * 3 / 2);
+
+    /* Test mmap */
+    size_t sz = 1920 * 1080 * 3 / 2;
+    void *map = mmap(NULL, sz, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+    if (map == MAP_FAILED) {
+        printf("  FAIL: mmap failed: %s\n\n", strerror(errno));
+        close(fd);
+        return;
+    }
+    printf("  OK: mmap at %p\n", map);
+    /* Write something */
+    memset(map, 0x80, sz);
+    printf("  OK: memset succeeded\n");
+    munmap(map, sz);
+
+    /* Test DRM import */
+    int drm_fd = open("/dev/dri/card0", O_RDWR);
+    if (drm_fd < 0) {
+        printf("  WARN: cannot open /dev/dri/card0: %s\n", strerror(errno));
+        close(fd);
+        return;
+    }
+
+    uint32_t handle = 0;
+    int ret = drmPrimeFDToHandle(drm_fd, fd, &handle);
+    if (ret) {
+        printf("  FAIL: drmPrimeFDToHandle(fd=%d) failed: %d (%s)\n",
+               fd, ret, strerror(-ret));
+    } else {
+        printf("  OK: drmPrimeFDToHandle => GEM handle=%u\n", handle);
+    }
+
+    /* Try second import (should reuse handle) */
+    uint32_t handle2 = 0;
+    ret = drmPrimeFDToHandle(drm_fd, fd, &handle2);
+    if (ret) {
+        printf("  FAIL: second drmPrimeFDToHandle failed: %d\n", ret);
+    } else {
+        printf("  OK: second import => handle=%u (same=%d)\n", handle2, handle2 == handle);
+    }
+
+    close(drm_fd);
+    close(fd);
+    printf("\n");
+}
+
+int main(void) {
+    printf("=== DMA-BUF Heap Unit Test ===\n\n");
+
+    /* List available heaps */
+    printf("Available heaps (from /proc):\n");
+    system("ls -la /dev/dma_heap/ 2>/dev/null || echo '  (no /dev/dma_heap/)'");
+    printf("\n");
+
+    test_heap("reserved");
+    test_heap("system-uncached");
+    test_heap("system");
+    test_heap("cma");
+
+    printf("=== Done ===\n");
+    return 0;
+}

+ 481 - 0
test_drm_display.c

@@ -0,0 +1,481 @@
+/*
+ * Unit test: DRM display subsystem
+ * Tests connector detection, CRTC, plane enumeration, and framebuffer creation.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <poll.h>
+#include <linux/dma-heap.h>
+#include <xf86drm.h>
+#include <xf86drmMode.h>
+#include <drm_fourcc.h>
+
+static int dma_heap_alloc(const char *heap_name, size_t size, int *fd) {
+    char path[64];
+    snprintf(path, sizeof(path), "/dev/dma_heap/%s", heap_name);
+    int heap_fd = open(path, O_RDWR | O_CLOEXEC);
+    if (heap_fd < 0) return -1;
+    struct dma_heap_allocation_data data = { .len = size, .fd_flags = O_RDWR | O_CLOEXEC };
+    int ret = ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &data);
+    close(heap_fd);
+    if (ret < 0) return -1;
+    *fd = data.fd;
+    return 0;
+}
+
+static int create_fb(int drm_fd, int buf_fd, uint32_t w, uint32_t h,
+                     uint32_t stride, uint32_t format, uint32_t *fb_id) {
+    uint32_t handles[4] = {0}, pitches[4] = {0}, offsets[4] = {0};
+    if (drmPrimeFDToHandle(drm_fd, buf_fd, &handles[0])) {
+        fprintf(stderr, "  drmPrimeFDToHandle failed\n");
+        return -1;
+    }
+    if (format == DRM_FORMAT_NV12) {
+        handles[1] = handles[0];
+        pitches[0] = stride;
+        pitches[1] = stride;
+        offsets[0] = 0;
+        offsets[1] = stride * h;
+    } else {
+        pitches[0] = stride;
+        offsets[0] = 0;
+    }
+    int ret = drmModeAddFB2(drm_fd, w, h, format, handles, pitches, offsets, fb_id, 0);
+    if (ret)
+        fprintf(stderr, "  drmModeAddFB2(%ux%u fmt=0x%x stride=%u) failed: %s\n",
+                w, h, format, stride, strerror(errno));
+    return ret;
+}
+
+static void page_flip_handler(int fd, unsigned int seq,
+                              unsigned int tv_sec, unsigned int tv_usec,
+                              void *user_data) {
+    (void)fd; (void)seq; (void)tv_sec; (void)tv_usec; (void)user_data;
+}
+
+int main(void) {
+    printf("=== DRM Display Unit Test ===\n\n");
+
+    int drm_fd = open("/dev/dri/card0", O_RDWR);
+    if (drm_fd < 0) {
+        perror("open /dev/dri/card0");
+        return 1;
+    }
+    printf("Opened /dev/dri/card0 fd=%d\n\n", drm_fd);
+
+    /* Try to become DRM master */
+    if (drmSetMaster(drm_fd)) {
+        printf("drmSetMaster failed: %s (errno=%d)\n", strerror(errno), errno);
+    } else {
+        printf("Acquired DRM master\n");
+    }
+
+    /* Set universal planes capability */
+    if (drmSetClientCap(drm_fd, DRM_CLIENT_CAP_UNIVERSAL_PLANES, 1))
+        printf("UNIVERSAL_PLANES cap failed: %s\n", strerror(errno));
+    else
+        printf("Set UNIVERSAL_PLANES cap\n");
+
+    if (drmSetClientCap(drm_fd, DRM_CLIENT_CAP_ATOMIC, 1))
+        printf("ATOMIC cap failed: %s\n", strerror(errno));
+    else
+        printf("Set ATOMIC cap\n");
+
+    /* --- Enumerate connectors --- */
+    drmModeRes *res = drmModeGetResources(drm_fd);
+    if (!res) {
+        fprintf(stderr, "drmModeGetResources failed\n");
+        return 1;
+    }
+
+    printf("Connectors: %d\n", res->count_connectors);
+    uint32_t conn_id = 0, crtc_id = 0;
+    for (int i = 0; i < res->count_connectors; i++) {
+        drmModeConnector *conn = drmModeGetConnector(drm_fd, res->connectors[i]);
+        if (!conn) continue;
+        printf("  [%d] connector %u: %s, %d modes, %d encoders\n",
+               i, conn->connector_id,
+               conn->connection == DRM_MODE_CONNECTED ? "CONNECTED" : "disconnected",
+               conn->count_modes, conn->count_encoders);
+        if (conn->connection == DRM_MODE_CONNECTED && conn->count_modes > 0 && !conn_id) {
+            conn_id = conn->connector_id;
+            printf("  => Selected connector %u, mode: %ux%u\n",
+                   conn_id, conn->modes[0].hdisplay, conn->modes[0].vdisplay);
+        }
+        drmModeFreeConnector(conn);
+    }
+
+    /* --- Enumerate CRTCs --- */
+    printf("\nCRTCs: %d\n", res->count_crtcs);
+    for (int i = 0; i < res->count_crtcs; i++) {
+        drmModeCrtc *crtc = drmModeGetCrtc(drm_fd, res->crtcs[i]);
+        if (!crtc) continue;
+        printf("  [%d] crtc %u: fb=%u %ux%u\n",
+               i, crtc->crtc_id, crtc->buffer_id,
+               crtc->width, crtc->height);
+        drmModeFreeCrtc(crtc);
+    }
+
+    /* --- Find encoder/CRTC for our connector --- */
+    if (conn_id) {
+        drmModeConnector *conn = drmModeGetConnector(drm_fd, conn_id);
+        if (conn) {
+            if (conn->encoder_id) {
+                drmModeEncoder *enc = drmModeGetEncoder(drm_fd, conn->encoder_id);
+                if (enc) {
+                    crtc_id = enc->crtc_id;
+                    printf("\nEncoder %u => crtc %u\n", enc->encoder_id, crtc_id);
+                    drmModeFreeEncoder(enc);
+                }
+            }
+            if (!crtc_id && conn->count_encoders > 0) {
+                drmModeEncoder *enc = drmModeGetEncoder(drm_fd, conn->encoders[0]);
+                if (enc) {
+                    crtc_id = enc->crtc_id;
+                    printf("\nFallback encoder %u => crtc %u\n", enc->encoder_id, crtc_id);
+                    drmModeFreeEncoder(enc);
+                }
+            }
+            drmModeFreeConnector(conn);
+        }
+    }
+    if (!crtc_id && res->count_crtcs > 0) {
+        crtc_id = res->crtcs[0];
+        printf("\nFallback: using first crtc %u\n", crtc_id);
+    }
+    printf("Selected crtc: %u\n", crtc_id);
+
+    /* --- Enumerate all planes --- */
+    drmModePlaneRes *planes = drmModeGetPlaneResources(drm_fd);
+    printf("\nPlanes: %d\n", planes ? planes->count_planes : 0);
+
+    int crtc_index = -1;
+    for (int i = 0; i < res->count_crtcs; i++) {
+        if (res->crtcs[i] == crtc_id) { crtc_index = i; break; }
+    }
+    printf("CRTC %u is at index %d\n", crtc_id, crtc_index);
+
+    uint32_t overlay_plane_id = 0;
+
+    if (planes) {
+        for (uint32_t i = 0; i < planes->count_planes; i++) {
+            drmModePlane *p = drmModeGetPlane(drm_fd, planes->planes[i]);
+            if (!p) continue;
+
+            uint32_t plane_id = planes->planes[i];
+            int compatible = (p->possible_crtcs & (1u << crtc_index)) != 0;
+
+            drmModeObjectProperties *props =
+                drmModeObjectGetProperties(drm_fd, plane_id, DRM_MODE_OBJECT_PLANE);
+
+            uint64_t type_val = 0;
+            uint64_t in_fmt_blob = 0;
+            if (props) {
+                for (uint32_t j = 0; j < props->count_props; j++) {
+                    drmModePropertyRes *prop = drmModeGetProperty(drm_fd, props->props[j]);
+                    if (!prop) continue;
+                    if (strcmp(prop->name, "type") == 0)
+                        type_val = props->prop_values[j];
+                    if (strcmp(prop->name, "IN_FORMATS") == 0)
+                        in_fmt_blob = props->prop_values[j];
+                    drmModeFreeProperty(prop);
+                }
+            }
+
+            int has_nv12 = 0;
+            if (in_fmt_blob) {
+                drmModePropertyBlobRes *blob = drmModeGetPropertyBlob(drm_fd, in_fmt_blob);
+                if (blob && blob->length >= 4) {
+                    uint32_t *data = (uint32_t *)blob->data;
+                    uint32_t num_u32 = blob->length / 4;
+                    /* Dump first 16 u32 values */
+                    printf("  plane %u IN_FORMATS blob: %zu bytes (%u u32s)\n",
+                           plane_id, (size_t)blob->length, num_u32);
+                    for (uint32_t k = 0; k < (num_u32 < 16 ? num_u32 : 16); k++) {
+                        uint32_t v = data[k];
+                        char cc[5] = {(char)(v & 0xff), (char)((v >> 8) & 0xff),
+                                      (char)((v >> 16) & 0xff), (char)((v >> 24) & 0xff), 0};
+                        printf("    [%u] = 0x%08x (%s)\n", k, v, cc);
+                    }
+                    /* The blob format is: count_formats(u32), count_modifiers(u32),
+                       formats[count_formats](u32), then modifier entries.
+                       But we might have a different format. Try scanning all u32s. */
+                    for (uint32_t k = 0; k < num_u32; k++) {
+                        if (data[k] == DRM_FORMAT_NV12) { has_nv12 = 1; break; }
+                    }
+                }
+                if (blob) drmModeFreePropertyBlob(blob);
+            }
+
+            const char *type_str = "Unknown";
+            if (type_val == 0) type_str = "Overlay(0)";
+            else if (type_val == 1) type_str = "Primary(1)";
+            else if (type_val == 2) type_str = "Cursor(2)";
+
+            printf("  plane %u: type=%s compat=%d nv12=%d possible_crtcs=0x%x\n",
+                   plane_id, type_str, compatible, has_nv12, p->possible_crtcs);
+
+            /* Select overlay plane with NV12 support */
+            if (type_val == 2 && has_nv12 && compatible && !overlay_plane_id) {
+                overlay_plane_id = plane_id;
+                printf("  => Selected overlay plane %u (NV12, compatible)\n", plane_id);
+            }
+
+            if (props) drmModeFreeObjectProperties(props);
+            drmModeFreePlane(p);
+        }
+
+        /* If no compatible overlay, try any NV12 overlay */
+        if (!overlay_plane_id) {
+            printf("\nNo compatible NV12 overlay. Trying all NV12 overlays:\n");
+            for (uint32_t i = 0; i < planes->count_planes; i++) {
+                uint32_t plane_id = planes->planes[i];
+                drmModeObjectProperties *props =
+                    drmModeObjectGetProperties(drm_fd, plane_id, DRM_MODE_OBJECT_PLANE);
+                if (!props) continue;
+                uint64_t type_val = 0, in_fmt_blob = 0;
+                for (uint32_t j = 0; j < props->count_props; j++) {
+                    drmModePropertyRes *prop = drmModeGetProperty(drm_fd, props->props[j]);
+                    if (!prop) continue;
+                    if (strcmp(prop->name, "type") == 0) type_val = props->prop_values[j];
+                    if (strcmp(prop->name, "IN_FORMATS") == 0) in_fmt_blob = props->prop_values[j];
+                    drmModeFreeProperty(prop);
+                }
+                int has_nv12 = 0;
+                if (in_fmt_blob) {
+                    drmModePropertyBlobRes *blob = drmModeGetPropertyBlob(drm_fd, in_fmt_blob);
+                    if (blob && blob->length > 8) {
+                        uint32_t *data = (uint32_t *)blob->data;
+                        for (uint32_t f = 0; f < data[0]; f++) {
+                            if (data[2 + f] == DRM_FORMAT_NV12) { has_nv12 = 1; break; }
+                        }
+                    }
+                    if (blob) drmModeFreePropertyBlob(blob);
+                }
+                if (type_val == 2 && has_nv12 && !overlay_plane_id) {
+                    overlay_plane_id = plane_id;
+                    printf("  => Using plane %u (may not be compatible)\n", plane_id);
+                }
+                drmModeFreeObjectProperties(props);
+            }
+        }
+
+        drmModeFreePlaneResources(planes);
+    }
+
+    printf("\nOverlay plane for display: %u\n", overlay_plane_id);
+
+    /* --- Allocate DMA buffer and test FB creation --- */
+    printf("\n--- FB creation test ---\n");
+    int buf_fd = -1;
+    size_t buf_sz = 1920 * 1080 * 3 / 2;
+    if (dma_heap_alloc("reserved", buf_sz, &buf_fd) < 0) {
+        if (dma_heap_alloc("system-uncached", buf_sz, &buf_fd) < 0) {
+            fprintf(stderr, "Cannot allocate DMA buffer\n");
+            return 1;
+        }
+    }
+    void *map = mmap(NULL, buf_sz, PROT_READ | PROT_WRITE, MAP_SHARED, buf_fd, 0);
+    if (map != MAP_FAILED) {
+        memset(map, 0x10, 1920 * 1080);         /* Y = black */
+        memset((uint8_t*)map + 1920 * 1080, 0x80, buf_sz - 1920 * 1080); /* UV */
+    }
+
+    uint32_t fb_id = 0;
+    int ret;
+
+    /* Test 1: drmModeSetCrtc on primary plane (expected to fail with NV12) */
+    printf("\nTest 1: drmModeSetCrtc with NV12...\n");
+    ret = create_fb(drm_fd, buf_fd, 1920, 1080, 1920, DRM_FORMAT_NV12, &fb_id);
+    if (ret == 0) {
+        drmModeConnector *tc = drmModeGetConnector(drm_fd, conn_id);
+        drmModeModeInfo mode_info;
+        if (tc && tc->count_modes > 0) mode_info = tc->modes[0];
+        else memset(&mode_info, 0, sizeof(mode_info));
+        if (tc) drmModeFreeConnector(tc);
+
+        ret = drmModeSetCrtc(drm_fd, crtc_id, fb_id, 0, 0, &conn_id, 1, &mode_info);
+        if (ret) {
+            printf("  FAIL: drmModeSetCrtc: %s (errno=%d)\n", strerror(errno), errno);
+        } else {
+            printf("  OK: drmModeSetCrtc succeeded\n");
+        }
+        drmModeRmFB(drm_fd, fb_id);
+        fb_id = 0;
+    }
+
+    /* Test 2: Try Esmart1 (plane 107) on CRTC 121 via drmModeSetPlane */
+    printf("\nTest 2: Force Esmart1 plane 107 on CRTC %u (ignoring possible_crtcs)...\n", crtc_id);
+    fb_id = 0;
+    ret = create_fb(drm_fd, buf_fd, 1920, 1080, 1920, DRM_FORMAT_NV12, &fb_id);
+    if (ret == 0) {
+        ret = drmModeSetPlane(drm_fd, 107, crtc_id, fb_id,
+                              0, 0, 0, 1920, 1080,
+                              0, 0, 1920 << 16, 1080 << 16);
+        if (ret) {
+            printf("  FAIL: drmModeSetPlane(107): %s (errno=%d)\n", strerror(errno), errno);
+        } else {
+            printf("  OK: Esmart1 plane 107 on CRTC %u succeeded!\n", crtc_id);
+            sleep(2);
+        }
+        drmModeRmFB(drm_fd, fb_id);
+        fb_id = 0;
+    }
+
+    /* Test 2b: Try atomic modesetting to force plane 107 on CRTC 121 */
+    printf("\nTest 2b: Atomic modesetting - force plane 107 on CRTC %u...\n", crtc_id);
+    fb_id = 0;
+    ret = create_fb(drm_fd, buf_fd, 1920, 1080, 1920, DRM_FORMAT_NV12, &fb_id);
+    if (ret == 0) {
+        drmModeAtomicReq *req = drmModeAtomicAlloc();
+        if (req) {
+            /* Set the FB_ID on the plane */
+            drmModeAtomicAddProperty(req, 107,
+                /* We need property IDs, not names for atomic. Find them first. */
+                0, fb_id);
+
+            /* Find property IDs for plane 107 */
+            drmModeObjectProperties *props =
+                drmModeObjectGetProperties(drm_fd, 107, DRM_MODE_OBJECT_PLANE);
+            if (props) {
+                uint32_t fb_prop_id = 0, crtc_prop_id = 0, src_prop_id = 0, dst_prop_id = 0;
+                uint32_t src_x_prop = 0, src_y_prop = 0;
+                for (uint32_t j = 0; j < props->count_props; j++) {
+                    drmModePropertyRes *prop = drmModeGetProperty(drm_fd, props->props[j]);
+                    if (!prop) continue;
+                    if (strcmp(prop->name, "FB_ID") == 0) fb_prop_id = props->props[j];
+                    if (strcmp(prop->name, "CRTC_ID") == 0) crtc_prop_id = props->props[j];
+                    if (strcmp(prop->name, "SRC_X") == 0) src_x_prop = props->props[j];
+                    if (strcmp(prop->name, "SRC_Y") == 0) src_y_prop = props->props[j];
+                    if (strcmp(prop->name, "SRC_W") == 0) src_prop_id = props->props[j];
+                    if (strcmp(prop->name, "SRC_H") == 0) dst_prop_id = props->props[j];
+                    drmModeFreeProperty(prop);
+                }
+                printf("  plane 107 props: FB_ID=%u CRTC_ID=%u\n", fb_prop_id, crtc_prop_id);
+
+                if (fb_prop_id && crtc_prop_id) {
+                    drmModeAtomicFree(req);
+                    req = drmModeAtomicAlloc();
+                    drmModeAtomicAddProperty(req, 107, fb_prop_id, fb_id);
+                    drmModeAtomicAddProperty(req, 107, crtc_prop_id, crtc_id);
+
+                    /* Set source/dest rectangles via SRC_W/SRC_H/DST_W/DST_H properties */
+                    uint32_t dst_w_prop = 0, dst_h_prop = 0;
+                    for (uint32_t j = 0; j < props->count_props; j++) {
+                        drmModePropertyRes *prop = drmModeGetProperty(drm_fd, props->props[j]);
+                        if (!prop) continue;
+                        if (strcmp(prop->name, "DST_W") == 0) dst_w_prop = props->props[j];
+                        if (strcmp(prop->name, "DST_H") == 0) dst_h_prop = props->props[j];
+                        drmModeFreeProperty(prop);
+                    }
+                    if (src_x_prop) drmModeAtomicAddProperty(req, 107, src_x_prop, 0);
+                    if (src_y_prop) drmModeAtomicAddProperty(req, 107, src_y_prop, 0);
+                    if (src_prop_id) drmModeAtomicAddProperty(req, 107, src_prop_id, 1920 << 16);
+                    if (dst_prop_id) drmModeAtomicAddProperty(req, 107, dst_prop_id, 1080 << 16);
+                    if (dst_w_prop) drmModeAtomicAddProperty(req, 107, dst_w_prop, 1920);
+                    if (dst_h_prop) drmModeAtomicAddProperty(req, 107, dst_h_prop, 1080);
+
+                    uint32_t cursor_x_prop = 0, cursor_y_prop = 0;
+                    for (uint32_t j = 0; j < props->count_props; j++) {
+                        drmModePropertyRes *prop = drmModeGetProperty(drm_fd, props->props[j]);
+                        if (!prop) continue;
+                        if (strcmp(prop->name, "CRTC_X") == 0) cursor_x_prop = props->props[j];
+                        if (strcmp(prop->name, "CRTC_Y") == 0) cursor_y_prop = props->props[j];
+                        drmModeFreeProperty(prop);
+                    }
+                    if (cursor_x_prop) drmModeAtomicAddProperty(req, 107, cursor_x_prop, 0);
+                    if (cursor_y_prop) drmModeAtomicAddProperty(req, 107, cursor_y_prop, 0);
+
+                    printf("  Submitting atomic commit...\n");
+                    ret = drmModeAtomicCommit(drm_fd, req,
+                        DRM_MODE_ATOMIC_ALLOW_MODESET, NULL);
+                    if (ret) {
+                        printf("  FAIL: atomic commit: %s (errno=%d)\n", strerror(errno), errno);
+                    } else {
+                        printf("  OK: atomic commit succeeded!\n");
+                        sleep(2);
+                    }
+                }
+                drmModeFreeObjectProperties(props);
+            }
+            drmModeAtomicFree(req);
+        }
+        drmModeRmFB(drm_fd, fb_id);
+        fb_id = 0;
+    }
+
+    /* Test 3: Use primary plane with XRGB8888 (expected to work) */
+    printf("\nTest 3: drmModeSetCrtc with XRGB8888...\n");
+    int xrgb_buf_fd = -1;
+    {
+        uint32_t handles2[4] = {0}, pitches2[4] = {0}, offsets2[4] = {0};
+        size_t xrgb_sz = 1920 * 1080 * 4;
+        if (dma_heap_alloc("system-uncached", xrgb_sz, &xrgb_buf_fd) == 0) {
+            void *xrgb_map = mmap(NULL, xrgb_sz, PROT_READ | PROT_WRITE, MAP_SHARED, xrgb_buf_fd, 0);
+            if (xrgb_map != MAP_FAILED) {
+                memset(xrgb_map, 0x00, xrgb_sz);
+                munmap(xrgb_map, xrgb_sz);
+            }
+            drmPrimeFDToHandle(drm_fd, xrgb_buf_fd, &handles2[0]);
+            pitches2[0] = 1920 * 4;
+            offsets2[0] = 0;
+            ret = drmModeAddFB2(drm_fd, 1920, 1080, DRM_FORMAT_XRGB8888,
+                                handles2, pitches2, offsets2, &fb_id, 0);
+            if (ret) {
+                printf("  FAIL: drmModeAddFB2 XRGB8888: %s\n", strerror(errno));
+            } else {
+                drmModeConnector *tc2 = drmModeGetConnector(drm_fd, conn_id);
+                drmModeModeInfo mode2;
+                if (tc2 && tc2->count_modes > 0) mode2 = tc2->modes[0];
+                else memset(&mode2, 0, sizeof(mode2));
+                if (tc2) drmModeFreeConnector(tc2);
+
+                ret = drmModeSetCrtc(drm_fd, crtc_id, fb_id, 0, 0, &conn_id, 1, &mode2);
+                if (ret) {
+                    printf("  FAIL: drmModeSetCrtc XRGB8888: %s\n", strerror(errno));
+                } else {
+                    printf("  OK: XRGB8888 modeset works!\n");
+                }
+                drmModeRmFB(drm_fd, fb_id);
+                fb_id = 0;
+            }
+            close(xrgb_buf_fd);
+        }
+    }
+
+    /* Test 4: Page flip test */
+    if (overlay_plane_id) {
+        printf("\nTest 4: Page flip via overlay plane...\n");
+        fb_id = 0;
+        ret = create_fb(drm_fd, buf_fd, 1920, 1080, 1920, DRM_FORMAT_NV12, &fb_id);
+        if (ret == 0) {
+            ret = drmModeSetPlane(drm_fd, overlay_plane_id, crtc_id, fb_id,
+                                  0, 0, 0, 1920, 1080,
+                                  0, 0, 1920 << 16, 1080 << 16);
+            printf("  drmModeSetPlane: %s\n", ret ? "FAIL" : "OK");
+            if (!ret) {
+                sleep(1);
+                drmEventContext ev = {.version = 2, .page_flip_handler = page_flip_handler};
+                drmHandleEvent(drm_fd, &ev);
+                printf("  OK: page flip handler returned\n");
+            }
+            drmModeRmFB(drm_fd, fb_id);
+        }
+    }
+
+    if (map != MAP_FAILED) munmap(map, buf_sz);
+    close(buf_fd);
+    drmModeFreeResources(res);
+    drmDropMaster(drm_fd);
+    close(drm_fd);
+
+    printf("\n=== Done ===\n");
+    return 0;
+}

+ 20 - 0
test_mpp.c

@@ -0,0 +1,20 @@
+#include "decoder.h"
+#include <stdio.h>
+#include <unistd.h>
+
+int main(int argc, char **argv) {
+    if (argc < 2) { fprintf(stderr, "usage: %s <rtsp-url>\n", argv[0]); return 1; }
+    Decoder *dec = decoder_create(argv[1], 15);
+    if (!dec) { fprintf(stderr, "decoder_create failed\n"); return 1; }
+    fprintf(stderr, "OK decoder %dx%d stride=%d\n",
+            decoder_get_width(dec), decoder_get_height(dec), decoder_get_stride(dec));
+    DecodedFrame f;
+    for (int i = 0; i < 100; i++) {
+        int r = decoder_get_frame(dec, &f);
+        fprintf(stderr, "frame %d: r=%d fd=%d %dx%d\n", i, r, r==0?f.fd:-1, f.width, f.height);
+        if (r == 0) close(f.fd);
+        usleep(40000);
+    }
+    decoder_destroy(dec);
+    return 0;
+}

+ 313 - 0
test_plc.c

@@ -0,0 +1,313 @@
+/*
+ * test_plc.c - local mock S7 server + client round-trip self test.
+ *
+ * Verifies byte-exact S7comm frames (COTP CR, setup, read request/response)
+ * against the python-snap7 reference implementation layout.
+ */
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include "plc.h"
+
+#define TEST_PORT 12022
+
+#define COTP_CR 0xE0
+#define COTP_CC 0xD0
+#define COTP_DT 0xF0
+
+static int g_server_fail = 0;
+static int g_server_done = 0;
+
+static int recv_exact(int fd, void *buf, size_t len) {
+    size_t got = 0;
+    while (got < len) {
+        ssize_t r = recv(fd, (uint8_t *)buf + got, len - got, 0);
+        if (r <= 0) return -1;
+        got += (size_t)r;
+    }
+    return 0;
+}
+
+static int send_all(int fd, const void *buf, size_t len) {
+    size_t sent = 0;
+    while (sent < len) {
+        ssize_t r = send(fd, (const uint8_t *)buf + sent, len - sent, MSG_NOSIGNAL);
+        if (r <= 0) return -1;
+        sent += (size_t)r;
+    }
+    return 0;
+}
+
+static void put_u16(uint8_t *p, uint16_t v) {
+    p[0] = (uint8_t)(v >> 8);
+    p[1] = (uint8_t)(v & 0xFF);
+}
+
+static uint16_t get_u16(const uint8_t *p) {
+    return (uint16_t)(((uint16_t)p[0] << 8) | p[1]);
+}
+
+/* Read one TPKT frame; returns COTP/S7 payload after the 3-byte DT header. */
+static int read_frame(int fd, uint8_t *out, int maxout, int *out_len) {
+    uint8_t hdr[4];
+    if (recv_exact(fd, hdr, 4) < 0) return -1;
+    int total = ((int)hdr[2] << 8) | hdr[3];
+    if (total < 7 || total > 1200) return -1;
+    uint8_t payload[1200];
+    if (recv_exact(fd, payload, total - 4) < 0) return -1;
+    if (payload[0] != 2 || payload[1] != COTP_DT) return -1;
+    int n = total - 4 - 3;
+    if (n > maxout) return -1;
+    memcpy(out, payload + 3, n);
+    *out_len = n;
+    return 0;
+}
+
+static void send_s7(int fd, const uint8_t *s7, int len) {
+    uint8_t frame[4 + 3 + 1200];
+    int total = 4 + 3 + len;
+    frame[0] = 3; frame[1] = 0;
+    frame[2] = (uint8_t)(total >> 8);
+    frame[3] = (uint8_t)(total & 0xFF);
+    frame[4] = 2; frame[5] = COTP_DT; frame[6] = 0x80;
+    memcpy(frame + 7, s7, len);
+    send_all(fd, frame, total);
+}
+
+static void fail(const char *msg, const uint8_t *got, int glen,
+                 const uint8_t *exp, int elen) {
+    g_server_fail = 1;
+    fprintf(stderr, "MOCK FAIL: %s (got %d bytes, want %d)\n", msg, glen, elen);
+    fprintf(stderr, "  got: ");
+    for (int i = 0; i < glen; i++) fprintf(stderr, "%02x ", got[i]);
+    fprintf(stderr, "\n  exp: ");
+    for (int i = 0; i < elen; i++) fprintf(stderr, "%02x ", exp[i]);
+    fprintf(stderr, "\n");
+}
+
+static void serve_connection(int cfd) {
+    /* --- COTP Connection Request --- */
+    uint8_t hdr[4], payload[1200];
+    if (recv_exact(cfd, hdr, 4) < 0) goto close;
+    int total = ((int)hdr[2] << 8) | hdr[3];
+    if (recv_exact(cfd, payload, total - 4) < 0) goto close;
+    {
+        uint8_t exp[18] = {
+            0x11, COTP_CR, 0x00, 0x00, 0x00, 0x01, 0x00,
+            0xC1, 0x02, 0x01, 0x00,          /* calling TSAP 0x0100 */
+            0xC2, 0x02, 0x01, 0x02,          /* called TSAP 0x0102 */
+            0xC0, 0x01, 0x0A,
+        };
+        if (total - 4 != 18 || memcmp(payload, exp, sizeof(exp)) != 0)
+            fail("COTP CR", payload, total - 4, exp, sizeof(exp));
+    }
+    /* --- COTP Connection Confirm --- */
+    {
+        uint8_t cc[10] = {0x09, COTP_CC, 0x00, 0x01, 0x00, 0x01, 0x00,
+                          0xC0, 0x01, 0x0A};
+        uint8_t frame[4 + 10];
+        int t = 4 + 10;
+        frame[0] = 3; frame[1] = 0;
+        frame[2] = (uint8_t)(t >> 8); frame[3] = (uint8_t)(t & 0xFF);
+        memcpy(frame + 4, cc, 10);
+        send_all(cfd, frame, t);
+    }
+
+    /* --- S7 Setup Communication --- */
+    {
+        uint8_t data[1200];
+        int n;
+        if (read_frame(cfd, data, sizeof(data), &n) < 0) goto close;
+        uint8_t exp[18] = {
+            0x32, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00,
+            0xF0, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0xE0,
+        };
+        exp[4] = data[4];
+        exp[5] = data[5];
+        if (n != 18 || memcmp(data, exp, sizeof(exp)) != 0)
+            fail("S7 setup", data, n, exp, sizeof(exp));
+        uint8_t resp[20] = {
+            0x32, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00,
+            0x00, 0x00,
+            0xF0, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0xE0,
+        };
+        send_s7(cfd, resp, sizeof(resp));
+    }
+
+    /* --- Read requests (loop) --- */
+    {
+        const uint8_t *data_pattern = (const uint8_t *)"\x10\x11\x12\x13";
+        int read_len = 4;
+        uint8_t data[1200];
+        int n;
+        while (recv_exact(cfd, hdr, 4) >= 0) {
+            total = ((int)hdr[2] << 8) | hdr[3];
+            if (recv_exact(cfd, data, total - 4) < 0) break;
+            if (data[0] != 2 || data[1] != COTP_DT) break;
+            n = total - 4 - 3;
+            uint8_t *s7 = data + 3;
+            uint8_t exp[24] = {
+                0x32, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00,
+                0x04, 0x01, 0x12, 0x0A, 0x10, 0x02, /* READ VAR + spec */
+                0x00, 0x04,                         /* count = 4 bytes */
+                0x00, 0x00,                         /* db = 0 */
+                0x83,                               /* area = M */
+                0x00, 0x00, 0x10,                   /* addr = 2*8 */
+            };
+            exp[4] = s7[4];
+            exp[5] = s7[5];
+            if (n != 24 || memcmp(s7, exp, sizeof(exp)) != 0)
+                fail("S7 read request", s7, n, exp, sizeof(exp));
+
+            uint16_t seq = get_u16(s7 + 4);
+            uint8_t resp[12 + 2 + 4 + read_len];
+            int r = 0;
+            resp[r++] = 0x32; resp[r++] = 0x03;
+            resp[r++] = 0x00; resp[r++] = 0x00;
+            put_u16(resp + r, seq); r += 2;
+            put_u16(resp + r, 0x0002); r += 2;      /* param len */
+            put_u16(resp + r, 0x0004 + read_len); r += 2; /* data len */
+            resp[r++] = 0x00; resp[r++] = 0x00;     /* error class/code */
+            resp[r++] = 0x04; resp[r++] = 0x01;     /* read response */
+            resp[r++] = 0xFF;                        /* return code OK */
+            resp[r++] = 0x04;                        /* transport size */
+            put_u16(resp + r, (uint16_t)(read_len * 8)); r += 2;
+            memcpy(resp + r, data_pattern, read_len); r += read_len;
+            send_s7(cfd, resp, r);
+        }
+    }
+
+close:
+    close(cfd);
+    g_server_done = 1;
+}
+
+static void *mock_server(void *arg) {
+    int persistent = (arg != NULL);
+    int lfd = socket(AF_INET, SOCK_STREAM, 0);
+    if (lfd < 0) { g_server_fail = 1; g_server_done = 1; return NULL; }
+    int one = 1;
+    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+    struct sockaddr_in sa;
+    memset(&sa, 0, sizeof(sa));
+    sa.sin_family = AF_INET;
+    sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+    sa.sin_port = htons(TEST_PORT);
+    if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) < 0 ||
+        listen(lfd, 4) < 0) {
+        perror("bind/listen");
+        g_server_fail = 1;
+        g_server_done = 1;
+        return NULL;
+    }
+
+    if (persistent) {
+        for (;;) {
+            int cfd = accept(lfd, NULL, NULL);
+            if (cfd < 0) {
+                if (errno == EINTR) continue;
+                break;
+            }
+            serve_connection(cfd);
+        }
+    } else {
+        int cfd = accept(lfd, NULL, NULL);
+        if (cfd >= 0) serve_connection(cfd);
+    }
+    close(lfd);
+    return NULL;
+}
+
+int main(int argc, char **argv) {
+    /* Standalone mock PLC server mode: ./test_plc server
+     * Handy for on-device testing against the real rtsp_multi_view. */
+    if (argc > 1 && strcmp(argv[1], "server") == 0) {
+        mock_server((void *)1);
+        return 0;
+    }
+
+    pthread_t server;
+    pthread_create(&server, NULL, mock_server, NULL);
+
+    PlcConfig cfg;
+    memset(&cfg, 0, sizeof(cfg));
+    strcpy(cfg.ip, "127.0.0.1");
+    cfg.port = TEST_PORT;
+    cfg.area = PLC_AREA_M;
+    cfg.read_start = 2;
+    cfg.read_len = 4;
+    cfg.poll_ms = 50;
+    cfg.reconnect_ms = 200;
+    cfg.tsap_local = 0x0100;
+    cfg.tsap_remote = 0x0102;
+
+    int failures = 0;
+    if (plc_init(&cfg) < 0) {
+        fprintf(stderr, "plc_init failed\n");
+        return 1;
+    }
+
+    /* Wait for connection */
+    int ok = 0;
+    for (int i = 0; i < 100; i++) {
+        if (plc_is_connected()) { ok = 1; break; }
+        usleep(30000);
+    }
+    if (!ok) {
+        fprintf(stderr, "FAIL: never connected (%s)\n", plc_last_error());
+        failures++;
+    } else {
+        uint8_t b;
+        if (plc_get_byte(0, &b) == 0 && b == 0x10) {
+            fprintf(stderr, "PASS: byte0 = 0x%02x\n", b);
+        } else {
+            fprintf(stderr, "FAIL: byte0 = %02x\n", b);
+            failures++;
+        }
+        if (plc_get_byte(3, &b) == 0 && b == 0x13) {
+            fprintf(stderr, "PASS: byte3 = 0x%02x\n", b);
+        } else {
+            fprintf(stderr, "FAIL: byte3 = %02x\n", b);
+            failures++;
+        }
+        /* 0x10 = bit4 set, bit0 clear */
+        if (plc_get_bit(0, 4) == 1 && plc_get_bit(0, 0) == 0) {
+            fprintf(stderr, "PASS: bit4=1 bit0=0\n");
+        } else {
+            fprintf(stderr, "FAIL: bit checks %d %d\n",
+                    plc_get_bit(0, 4), plc_get_bit(0, 0));
+            failures++;
+        }
+        /* out-of-range access must fail gracefully */
+        if (plc_get_bit(7, 0) == -1 && plc_get_byte(9, &b) == -1) {
+            fprintf(stderr, "PASS: out-of-range rejected\n");
+        } else {
+            fprintf(stderr, "FAIL: out-of-range not rejected\n");
+            failures++;
+        }
+    }
+
+    plc_shutdown();
+    pthread_join(server, NULL);
+
+    if (g_server_fail) {
+        fprintf(stderr, "FAIL: mock server reported frame mismatches\n");
+        failures++;
+    } else {
+        fprintf(stderr, "PASS: all S7 frames byte-exact\n");
+    }
+
+    if (failures == 0) {
+        fprintf(stderr, "ALL TESTS PASSED\n");
+        return 0;
+    }
+    fprintf(stderr, "%d FAILURE(S)\n", failures);
+    return 1;
+}

+ 131 - 0
test_rga.c

@@ -0,0 +1,131 @@
+/*
+ * Unit test: RGA composer - tests importbuffer_fd + wrapbuffer_handle_t
+ * Since wrapbuffer_fd_t returns handle=0 on this platform,
+ * we use importbuffer_fd to get a handle, then wrapbuffer_handle_t.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <linux/dma-heap.h>
+#include <rga/im2d.h>
+#include <rga/rga.h>
+
+static int dma_heap_alloc(const char *heap_name, size_t size, int *fd) {
+    char path[64];
+    snprintf(path, sizeof(path), "/dev/dma_heap/%s", heap_name);
+    int heap_fd = open(path, O_RDWR | O_CLOEXEC);
+    if (heap_fd < 0) return -1;
+    struct dma_heap_allocation_data data = { .len = size, .fd_flags = O_RDWR | O_CLOEXEC };
+    int ret = ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &data);
+    close(heap_fd);
+    if (ret < 0) return -1;
+    *fd = data.fd;
+    return 0;
+}
+
+static rga_buffer_t make_rga_from_fd(int fd, int w, int h, int format) {
+    rga_buffer_t buf;
+    memset(&buf, 0, sizeof(buf));
+
+    /* Try wrapbuffer_fd_t first */
+    buf = wrapbuffer_fd_t(fd, w, h, w, h, format);
+    if (buf.handle != 0) return buf;
+
+    /* Fallback: importbuffer_fd + wrapbuffer_handle_t */
+    im_handle_param_t param;
+    memset(&param, 0, sizeof(param));
+    param.width = w;
+    param.height = h;
+    param.format = format;
+
+    rga_buffer_handle_t handle = importbuffer_fd(fd, &param);
+    if (handle == 0) {
+        fprintf(stderr, "  importbuffer_fd failed for fd=%d\n", fd);
+        return buf;
+    }
+
+    buf = wrapbuffer_handle_t(handle, w, h, w, h, format);
+    fprintf(stderr, "  imported fd=%d => handle=%u (via importbuffer_fd)\n", fd, handle);
+    return buf;
+}
+
+static void test_blit(const char *heap_name, int src_w, int src_h, int dst_w, int dst_h) {
+    printf("=== RGA blit: %s, src=%dx%d, dst=%dx%d ===\n",
+           heap_name, src_w, src_h, dst_w, dst_h);
+
+    size_t src_sz = src_w * src_h * 3 / 2;
+    size_t dst_sz = dst_w * dst_h * 3 / 2;
+    int src_fd = -1, dst_fd = -1;
+
+    if (dma_heap_alloc(heap_name, src_sz, &src_fd) < 0) {
+        printf("  FAIL: src alloc\n\n"); return;
+    }
+    if (dma_heap_alloc(heap_name, dst_sz, &dst_fd) < 0) {
+        printf("  FAIL: dst alloc\n"); close(src_fd); return;
+    }
+
+    void *src_map = mmap(NULL, src_sz, PROT_READ | PROT_WRITE, MAP_SHARED, src_fd, 0);
+    void *dst_map = mmap(NULL, dst_sz, PROT_READ | PROT_WRITE, MAP_SHARED, dst_fd, 0);
+    if (src_map == MAP_FAILED || dst_map == MAP_FAILED) {
+        printf("  FAIL: mmap\n");
+        close(src_fd); close(dst_fd); return;
+    }
+
+    /* Fill src with yellow (Y=0xFF, UV=0x40) */
+    memset(src_map, 0xFF, src_w * src_h);
+    memset((uint8_t*)src_map + src_w * src_h, 0x40, src_w * src_h / 2);
+    memset(dst_map, 0x00, dst_sz);
+
+    rga_buffer_t src_buf = make_rga_from_fd(src_fd, src_w, src_h, RK_FORMAT_YCbCr_420_SP);
+    rga_buffer_t dst_buf = make_rga_from_fd(dst_fd, dst_w, dst_h, RK_FORMAT_YCbCr_420_SP);
+
+    printf("  src: handle=%u\n", src_buf.handle);
+    printf("  dst: handle=%u\n", dst_buf.handle);
+
+    if (src_buf.handle == 0 || dst_buf.handle == 0) {
+        printf("  SKIP blit (handle=0)\n");
+        goto cleanup;
+    }
+
+    im_rect src_rect = {0, 0, src_w, src_h};
+    im_rect dst_rect = {0, 0, dst_w, dst_h};
+    rga_buffer_t pat;
+    memset(&pat, 0, sizeof(pat));
+    im_rect prect;
+    memset(&prect, 0, sizeof(prect));
+
+    printf("  Running improcess...\n");
+    int ret = improcess(src_buf, dst_buf, pat, src_rect, dst_rect, prect, IM_SYNC);
+    printf("  improcess => %d (%s)\n", ret, ret == IM_STATUS_SUCCESS ? "OK" : "FAIL");
+
+    if (ret == IM_STATUS_SUCCESS) {
+        uint8_t *p = (uint8_t*)dst_map;
+        int nonzero = 0;
+        for (int i = 0; i < dst_w * dst_h && i < 1024; i++)
+            if (p[i] != 0) nonzero++;
+        printf("  dst Y first 1024 bytes: %d non-zero (expect ~1024 for 0xFF fill)\n", nonzero);
+    }
+
+cleanup:
+    munmap(src_map, src_sz);
+    munmap(dst_map, dst_sz);
+    close(src_fd);
+    close(dst_fd);
+    printf("\n");
+}
+
+int main(void) {
+    printf("=== RGA Composer Unit Test v2 ===\n\n");
+    test_blit("system-uncached", 1920, 1080, 1920, 1080);
+    test_blit("system-uncached", 640, 360, 640, 360);
+    test_blit("system-uncached", 1920, 1080, 640, 360);  /* scale down */
+    test_blit("cma", 1920, 1080, 1920, 1080);
+    printf("=== Done ===\n");
+    return 0;
+}

+ 190 - 0
test_rga_color.c

@@ -0,0 +1,190 @@
+/*
+ * Unit test: RGA NV12->RGBX color conversion
+ * Tests the specific conversion that display.c needs.
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <errno.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <linux/dma-heap.h>
+#include <rga/im2d.h>
+#include <rga/rga.h>
+
+static int dma_heap_alloc(const char *heap_name, size_t size, int *fd) {
+    char path[64];
+    snprintf(path, sizeof(path), "/dev/dma_heap/%s", heap_name);
+    int heap_fd = open(path, O_RDWR | O_CLOEXEC);
+    if (heap_fd < 0) return -1;
+    struct dma_heap_allocation_data data = { .len = size, .fd_flags = O_RDWR | O_CLOEXEC };
+    int ret = ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &data);
+    close(heap_fd);
+    if (ret < 0) return -1;
+    *fd = data.fd;
+    return 0;
+}
+
+static rga_buffer_t import_rga_buf(int fd, int w, int h, int stride_w, int stride_h, int format) {
+    im_handle_param_t param;
+    memset(&param, 0, sizeof(param));
+    param.width = w;
+    param.height = h;
+    param.format = format;
+    rga_buffer_handle_t handle = importbuffer_fd(fd, &param);
+    if (handle == 0) {
+        fprintf(stderr, "  importbuffer_fd failed for fd=%d w=%d h=%d fmt=%d\n", fd, w, h, format);
+        rga_buffer_t empty; memset(&empty, 0, sizeof(empty));
+        return empty;
+    }
+    return wrapbuffer_handle_t(handle, w, h, stride_w, stride_h, format);
+}
+
+static void test_nv12_to_rgbx(const char *heap, int w, int h, int rgbx_rga_fmt,
+                                int rgbx_pixel_stride, int rgbx_byte_stride) {
+    printf("=== NV12->RGBX test: heap=%s %dx%d, rgbx_fmt=%d, pixel_stride=%d, byte_stride=%d ===\n",
+           heap, w, h, rgbx_rga_fmt, rgbx_pixel_stride, rgbx_byte_stride);
+
+    size_t nv12_sz = (size_t)w * h * 3 / 2;
+    size_t rgbx_sz = (size_t)rgbx_byte_stride * h;
+    int nv12_fd = -1, rgbx_fd = -1;
+
+    if (dma_heap_alloc(heap, nv12_sz, &nv12_fd) < 0) {
+        printf("  FAIL: nv12 alloc\n\n"); return;
+    }
+    if (dma_heap_alloc(heap, rgbx_sz, &rgbx_fd) < 0) {
+        printf("  FAIL: rgbx alloc\n"); close(nv12_fd); return;
+    }
+
+    void *nv12_map = mmap(NULL, nv12_sz, PROT_READ | PROT_WRITE, MAP_SHARED, nv12_fd, 0);
+    void *rgbx_map = mmap(NULL, rgbx_sz, PROT_READ | PROT_WRITE, MAP_SHARED, rgbx_fd, 0);
+    if (nv12_map == MAP_FAILED || rgbx_map == MAP_FAILED) {
+        printf("  FAIL: mmap\n"); close(nv12_fd); close(rgbx_fd); return;
+    }
+
+    /* Fill NV12 with white (Y=0xFF, UV=0x80) */
+    memset(nv12_map, 0xFF, w * h);
+    memset((uint8_t*)nv12_map + w * h, 0x80, w * h / 2);
+    memset(rgbx_map, 0x00, rgbx_sz);
+
+    /* Test 1: wrapbuffer_fd_t */
+    printf("  Test 1: wrapbuffer_fd_t (NV12) + wrapbuffer_fd_t (RGBX)...\n");
+    {
+        rga_buffer_t src = wrapbuffer_fd_t(0, w, h, w, h, RK_FORMAT_YCbCr_420_SP);
+        rga_buffer_t dst = wrapbuffer_fd_t(0, w, h, rgbx_byte_stride, h, rgbx_rga_fmt);
+        printf("    src.handle=%u dst.handle=%u\n", src.handle, dst.handle);
+    }
+
+    /* Test 2: import NV12, wrapbuffer_fd_t RGBX */
+    printf("  Test 2: import NV12 + wrapbuffer_fd_t RGBX (byte_stride=%d)...\n", rgbx_byte_stride);
+    {
+        rga_buffer_t src = import_rga_buf(nv12_fd, w, h, w, h, RK_FORMAT_YCbCr_420_SP);
+        rga_buffer_t dst = wrapbuffer_fd_t(0, w, h, rgbx_byte_stride, h, rgbx_rga_fmt);
+        printf("    src.handle=%u dst.handle=%u\n", src.handle, dst.handle);
+        if (src.handle && dst.handle) {
+            im_rect src_rect = {0, 0, w, h};
+            im_rect dst_rect = {0, 0, w, h};
+            rga_buffer_t pat; memset(&pat, 0, sizeof(pat));
+            im_rect prect; memset(&prect, 0, sizeof(prect));
+            int ret = improcess(src, dst, pat, src_rect, dst_rect, prect, IM_SYNC);
+            printf("    improcess => %d (%s)\n", ret, ret == IM_STATUS_SUCCESS ? "OK" : "FAIL");
+            if (ret == IM_STATUS_SUCCESS) {
+                uint8_t *p = (uint8_t*)rgbx_map;
+                printf("    dst[0..3] = %02x %02x %02x %02x (expect ~FF FF FF 00 for white)\n",
+                       p[0], p[1], p[2], p[3]);
+            }
+        }
+    }
+
+    /* Test 3: import both, pixel stride for RGBX */
+    printf("  Test 3: import both, RGBX pixel_stride=%d...\n", rgbx_pixel_stride);
+    {
+        rga_buffer_t src = import_rga_buf(nv12_fd, w, h, w, h, RK_FORMAT_YCbCr_420_SP);
+        rga_buffer_t dst = import_rga_buf(rgbx_fd, w, h, rgbx_pixel_stride, h, rgbx_rga_fmt);
+        printf("    src.handle=%u dst.handle=%u\n", src.handle, dst.handle);
+        if (src.handle && dst.handle) {
+            im_rect src_rect = {0, 0, w, h};
+            im_rect dst_rect = {0, 0, w, h};
+            rga_buffer_t pat; memset(&pat, 0, sizeof(pat));
+            im_rect prect; memset(&prect, 0, sizeof(prect));
+            int ret = improcess(src, dst, pat, src_rect, dst_rect, prect, IM_SYNC);
+            printf("    improcess => %d (%s)\n", ret, ret == IM_STATUS_SUCCESS ? "OK" : "FAIL");
+            if (ret == IM_STATUS_SUCCESS) {
+                uint8_t *p = (uint8_t*)rgbx_map;
+                printf("    dst[0..3] = %02x %02x %02x %02x\n", p[0], p[1], p[2], p[3]);
+            }
+        }
+    }
+
+    /* Test 4: import both, byte stride for RGBX */
+    printf("  Test 4: import both, RGBX byte_stride=%d...\n", rgbx_byte_stride);
+    {
+        rga_buffer_t src = import_rga_buf(nv12_fd, w, h, w, h, RK_FORMAT_YCbCr_420_SP);
+        rga_buffer_t dst = import_rga_buf(rgbx_fd, w, h, rgbx_byte_stride, h, rgbx_rga_fmt);
+        printf("    src.handle=%u dst.handle=%u\n", src.handle, dst.handle);
+        if (src.handle && dst.handle) {
+            im_rect src_rect = {0, 0, w, h};
+            im_rect dst_rect = {0, 0, w, h};
+            rga_buffer_t pat; memset(&pat, 0, sizeof(pat));
+            im_rect prect; memset(&prect, 0, sizeof(prect));
+            int ret = improcess(src, dst, pat, src_rect, dst_rect, prect, IM_SYNC);
+            printf("    improcess => %d (%s)\n", ret, ret == IM_STATUS_SUCCESS ? "OK" : "FAIL");
+            if (ret == IM_STATUS_SUCCESS) {
+                uint8_t *p = (uint8_t*)rgbx_map;
+                printf("    dst[0..3] = %02x %02x %02x %02x\n", p[0], p[1], p[2], p[3]);
+            }
+        }
+    }
+
+    /* Test 5: use rkformat_t - try RGB888 */
+    printf("  Test 5: NV12->RK_FORMAT_RGB_888...\n");
+    {
+        size_t rgb888_sz = (size_t)w * 3 * h;
+        int rgb888_fd = -1;
+        if (dma_heap_alloc(heap, rgb888_sz, &rgb888_fd) == 0) {
+            void *rgb888_map = mmap(NULL, rgb888_sz, PROT_READ | PROT_WRITE, MAP_SHARED, rgb888_fd, 0);
+            if (rgb888_map != MAP_FAILED) {
+                memset(rgb888_map, 0, rgb888_sz);
+                rga_buffer_t src = import_rga_buf(nv12_fd, w, h, w, h, RK_FORMAT_YCbCr_420_SP);
+                rga_buffer_t dst = import_rga_buf(rgb888_fd, w, h, w * 3, h, RK_FORMAT_RGB_888);
+                printf("    src.handle=%u dst.handle=%u\n", src.handle, dst.handle);
+                if (src.handle && dst.handle) {
+                    im_rect src_rect = {0, 0, w, h};
+                    im_rect dst_rect = {0, 0, w, h};
+                    rga_buffer_t pat; memset(&pat, 0, sizeof(pat));
+                    im_rect prect; memset(&prect, 0, sizeof(prect));
+                    int ret = improcess(src, dst, pat, src_rect, dst_rect, prect, IM_SYNC);
+                    printf("    improcess => %d (%s)\n", ret, ret == IM_STATUS_SUCCESS ? "OK" : "FAIL");
+                }
+                munmap(rgb888_map, rgb888_sz);
+            }
+            close(rgb888_fd);
+        }
+    }
+
+    munmap(nv12_map, nv12_sz);
+    munmap(rgbx_map, rgbx_sz);
+    close(nv12_fd);
+    close(rgbx_fd);
+    printf("\n");
+}
+
+int main(void) {
+    printf("=== RGA NV12->RGBX Color Conversion Test ===\n\n");
+
+    /* Small test first */
+    test_nv12_to_rgbx("system-uncached", 320, 240, RK_FORMAT_RGBX_8888, 320, 320*4);
+    test_nv12_to_rgbx("system-uncached", 320, 240, RK_FORMAT_BGRA_8888, 320, 320*4);
+    test_nv12_to_rgbx("system-uncached", 320, 240, RK_FORMAT_ARGB_8888, 320, 320*4);
+
+    /* Full-size */
+    test_nv12_to_rgbx("system-uncached", 1920, 1080, RK_FORMAT_RGBX_8888, 1920, 1920*4);
+    test_nv12_to_rgbx("system-uncached", 1920, 1080, RK_FORMAT_BGRA_8888, 1920, 1920*4);
+    test_nv12_to_rgbx("system-uncached", 1920, 1080, RK_FORMAT_ARGB_8888, 1920, 1920*4);
+
+    printf("=== Done ===\n");
+    return 0;
+}

+ 744 - 0
title.c

@@ -0,0 +1,744 @@
+#include "title.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <setjmp.h>
+#include <math.h>
+
+#include <png.h>
+#include <jpeglib.h>
+
+#include <ft2build.h>
+#include FT_FREETYPE_H
+
+#define MAX_IMG 16384
+
+/* ---------- helpers ---------- */
+
+struct my_error_mgr {
+    struct jpeg_error_mgr pub;
+    jmp_buf setjmp_buffer;
+};
+
+static void my_error_exit(j_common_ptr cinfo) {
+    struct my_error_mgr *m = (struct my_error_mgr *)cinfo->err;
+    longjmp(m->setjmp_buffer, 1);
+}
+
+static void rgb2yuv(int r, int g, int b, int *Y, int *Cb, int *Cr) {
+    *Y  = (int)(0.299f * r + 0.587f * g + 0.114f * b);
+    *Cb = (int)(128.0f - 0.169f * r - 0.331f * g + 0.500f * b);
+    *Cr = (int)(128.0f + 0.500f * r - 0.419f * g - 0.081f * b);
+    if (*Y  < 0) *Y  = 0;
+    if (*Y  > 255) *Y  = 255;
+    if (*Cb < 0) *Cb = 0;
+    if (*Cb > 255) *Cb = 255;
+    if (*Cr < 0) *Cr = 0;
+    if (*Cr > 255) *Cr = 255;
+}
+
+static int png_is(const unsigned char m[8]) {
+    static const unsigned char sig[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
+    return memcmp(m, sig, 8) == 0;
+}
+static int jpg_is(const unsigned char m[3]) {
+    return m[0] == 0xFF && m[1] == 0xD8;
+}
+
+/* Decode PNG or JPEG (by magic bytes) into RGBA8 (straight alpha).
+ * Caller frees *rgba with free(). Returns 0 on success. */
+static int decode_image(const char *path, int *w, int *h, uint8_t **rgba) {
+    FILE *fp = fopen(path, "rb");
+    if (!fp) { fprintf(stderr, "title: cannot open %s\n", path); return -1; }
+
+    unsigned char magic[8];
+    if (fread(magic, 1, 8, fp) != 8) { fclose(fp); return -1; }
+    fseek(fp, 0, SEEK_SET);
+
+    if (png_is(magic)) {
+        png_structp pp = png_create_read_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
+        png_infop ip = pp ? png_create_info_struct(pp) : NULL;
+        if (!pp || !ip) { if (pp) png_destroy_read_struct(&pp, NULL, NULL); fclose(fp); return -1; }
+        if (setjmp(png_jmpbuf(pp))) {
+            png_destroy_read_struct(&pp, &ip, NULL);
+            fclose(fp);
+            return -1;
+        }
+        png_init_io(pp, fp);
+        png_read_info(pp, ip);
+        png_set_strip_16(pp);
+        png_set_expand(pp);
+        png_set_tRNS_to_alpha(pp);
+        if (png_get_color_type(pp, ip) == PNG_COLOR_TYPE_RGB)
+            png_set_filler(pp, 0xFF, PNG_FILLER_AFTER);
+        png_read_update_info(pp, ip);
+
+        int W = (int)png_get_image_width(pp, ip);
+        int H = (int)png_get_image_height(pp, ip);
+        if (W <= 0 || H <= 0 || W > MAX_IMG || H > MAX_IMG) {
+            png_destroy_read_struct(&pp, &ip, NULL); fclose(fp); return -1;
+        }
+        size_t stride = png_get_rowbytes(pp, ip);
+        uint8_t *buf = malloc((size_t)W * H * 4);
+        if (!buf) { png_destroy_read_struct(&pp, &ip, NULL); fclose(fp); return -1; }
+        png_bytep *rows = malloc(sizeof(png_bytep) * H);
+        if (!rows) { free(buf); png_destroy_read_struct(&pp, &ip, NULL); fclose(fp); return -1; }
+        for (int y = 0; y < H; y++) rows[y] = buf + (size_t)y * stride;
+        png_read_image(pp, rows);
+        png_read_end(pp, NULL);
+        free(rows);
+        png_destroy_read_struct(&pp, &ip, NULL);
+        fclose(fp);
+        *w = W; *h = H; *rgba = buf;
+        return 0;
+    }
+
+    if (jpg_is(magic)) {
+        struct my_error_mgr jerr;
+        struct jpeg_decompress_struct cinfo;
+        cinfo.err = jpeg_std_error(&jerr.pub);
+        jerr.pub.error_exit = my_error_exit;
+        if (setjmp(jerr.setjmp_buffer)) {
+            jpeg_destroy_decompress(&cinfo);
+            fclose(fp);
+            return -1;
+        }
+        jpeg_create_decompress(&cinfo);
+        jpeg_stdio_src(&cinfo, fp);
+        if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) {
+            jpeg_destroy_decompress(&cinfo); fclose(fp); return -1;
+        }
+        cinfo.out_color_space = JCS_RGB;
+        jpeg_start_decompress(&cinfo);
+        int W = cinfo.output_width, H = cinfo.output_height;
+        if (W <= 0 || H <= 0 || W > MAX_IMG || H > MAX_IMG) {
+            jpeg_destroy_decompress(&cinfo); fclose(fp); return -1;
+        }
+        uint8_t *buf = malloc((size_t)W * H * 4);
+        if (!buf) { jpeg_destroy_decompress(&cinfo); fclose(fp); return -1; }
+        JSAMPROW row = malloc(W * 3);
+        if (!row) { free(buf); jpeg_destroy_decompress(&cinfo); fclose(fp); return -1; }
+        for (int y = 0; y < H; y++) {
+            jpeg_read_scanlines(&cinfo, &row, 1);
+            for (int x = 0; x < W; x++) {
+                uint8_t *p = buf + ((size_t)y * W + x) * 4;
+                p[0] = row[x * 3 + 0];
+                p[1] = row[x * 3 + 1];
+                p[2] = row[x * 3 + 2];
+                p[3] = 255;
+            }
+        }
+        free(row);
+        jpeg_finish_decompress(&cinfo);
+        jpeg_destroy_decompress(&cinfo);
+        fclose(fp);
+        *w = W; *h = H; *rgba = buf;
+        return 0;
+    }
+
+    fclose(fp);
+    fprintf(stderr, "title: unsupported image format: %s\n", path);
+    return -1;
+}
+
+/* Bilinear scale RGBA (premultiplied to avoid fringes) into dst. */
+static void scale_rgba(const uint8_t *src, int sw, int sh,
+                       uint8_t *dst, int dw, int dh) {
+    int xs = (dw > 1) ? dw - 1 : 1;
+    int ys = (dh > 1) ? dh - 1 : 1;
+    for (int y = 0; y < dh; y++) {
+        float fy = ((float)y + 0.5f) * sh / (float)ys - 0.5f;
+        if (fy < 0) fy = 0;
+        int y0 = (int)fy;
+        int y1 = y0 + 1;
+        if (y1 >= sh) y1 = y0;
+        float wy = fy - y0;
+        for (int x = 0; x < dw; x++) {
+            float fx = ((float)x + 0.5f) * sw / (float)xs - 0.5f;
+            if (fx < 0) fx = 0;
+            int x0 = (int)fx;
+            int x1 = x0 + 1;
+            if (x1 >= sw) x1 = x0;
+            float wx = fx - x0;
+            float pr = 0, pg = 0, pb = 0, pa = 0;
+            const uint8_t *p00 = src + ((size_t)y0 * sw + x0) * 4;
+            const uint8_t *p01 = src + ((size_t)y0 * sw + x1) * 4;
+            const uint8_t *p10 = src + ((size_t)y1 * sw + x0) * 4;
+            const uint8_t *p11 = src + ((size_t)y1 * sw + x1) * 4;
+            float w00 = (1 - wx) * (1 - wy), w01 = wx * (1 - wy);
+            float w10 = (1 - wx) * wy,     w11 = wx * wy;
+            float a00 = p00[3] / 255.0f, a01 = p01[3] / 255.0f;
+            float a10 = p10[3] / 255.0f, a11 = p11[3] / 255.0f;
+            pr = p00[0] * a00 * w00 + p01[0] * a01 * w01 + p10[0] * a10 * w10 + p11[0] * a11 * w11;
+            pg = p00[1] * a00 * w00 + p01[1] * a01 * w01 + p10[1] * a10 * w10 + p11[1] * a11 * w11;
+            pb = p00[2] * a00 * w00 + p01[2] * a01 * w01 + p10[2] * a10 * w10 + p11[2] * a11 * w11;
+            pa = a00 * w00 + a01 * w01 + a10 * w10 + a11 * w11;
+            uint8_t *o = dst + ((size_t)y * dw + x) * 4;
+            if (pa > 0.001f) {
+                o[0] = (uint8_t)(pr / pa);
+                o[1] = (uint8_t)(pg / pa);
+                o[2] = (uint8_t)(pb / pa);
+            } else {
+                o[0] = o[1] = o[2] = 0;
+            }
+            o[3] = (uint8_t)(pa * 255.0f);
+        }
+    }
+}
+
+/* Blend one RGBA image (premultiplied inside) onto the NV12 bar at (x0,y0).
+ * opacity 0-255 scales the whole logo's translucency. */
+static void blend_logo(uint8_t *map, int out_w, int out_h, int bar_h,
+                       const uint8_t *rgba, int w, int h, int x0, int y0,
+                       int opacity) {
+    uint8_t *Y = map;
+    uint8_t *UV = map + (size_t)out_w * out_h;
+    float op = (opacity > 255) ? 255.0f : (opacity < 0 ? 0.0f : (float)opacity);
+    for (int j = 0; j < h; j++) {
+        int y = y0 + j;
+        if (y < 0 || y >= bar_h) continue;
+        for (int i = 0; i < w; i++) {
+            int x = x0 + i;
+            if (x < 0 || x >= out_w) continue;
+            const uint8_t *p = rgba + ((size_t)j * w + i) * 4;
+            float a = (p[3] / 255.0f) * (op / 255.0f);
+            int r = p[0], g = p[1], b = p[2];
+            int yy, cb, cr;
+            rgb2yuv(r, g, b, &yy, &cb, &cr);
+            int wa = (int)(a * 255.0f);
+            uint8_t *yp = &Y[(size_t)y * out_w + x];
+            *yp = (uint8_t)(*yp + ((yy - *yp) * wa) / 255);
+            if ((x & 1) == 0 && (y & 1) == 0) {
+                uint8_t *uvp = &UV[(size_t)y / 2 * out_w + x];
+                *uvp = (uint8_t)(*uvp + ((cb - *uvp) * wa) / 255);
+                if (x + 1 < out_w) {
+                    uint8_t *uvp2 = uvp + 1;
+                    *uvp2 = (uint8_t)(*uvp2 + ((cr - *uvp2) * wa) / 255);
+                }
+            }
+        }
+    }
+}
+
+/* ---------- UTF-8 ---------- */
+
+static int utf8_decode(const uint8_t *s, uint32_t *cp) {
+    if (s[0] < 0x80) { *cp = s[0]; return 1; }
+    if ((s[0] & 0xE0) == 0xC0 && (s[1] & 0xC0) == 0x80) {
+        *cp = ((s[0] & 0x1F) << 6) | (s[1] & 0x3F); return 2;
+    }
+    if ((s[0] & 0xF0) == 0xE0 && (s[1] & 0xC0) == 0x80 && (s[2] & 0xC0) == 0x80) {
+        *cp = ((s[0] & 0x0F) << 12) | ((s[1] & 0x3F) << 6) | (s[2] & 0x3F); return 3;
+    }
+    if ((s[0] & 0xF8) == 0xF0 && (s[1] & 0xC0) == 0x80 &&
+        (s[2] & 0xC0) == 0x80 && (s[3] & 0xC0) == 0x80) {
+        *cp = ((s[0] & 0x07) << 18) | ((s[1] & 0x3F) << 12) |
+              ((s[2] & 0x3F) << 6) | (s[3] & 0x3F); return 4;
+    }
+    return 0;
+}
+
+/* ---------- FreeType with process-wide caching ----------
+ * FT_Init_FreeType + FT_New_Face parse the (multi-MB) font file on every
+ * call; rendering used to happen per frame for the title strip and NO
+ * SIGNAL text. Cache the library and one face per font path, and cache
+ * rasterized text bitmaps so per-frame cost is a single alpha blit.
+ * All title_* calls come from the main thread only -> no locking needed. */
+
+static FT_Library g_ft_lib = NULL;
+
+typedef struct {
+    char path[512];
+    FT_Face face;
+    int size;           /* currently set pixel size, -1 = unset */
+} FaceCache;
+static FaceCache g_face = { .path = "", .face = NULL, .size = -1 };
+
+static FT_Face get_face(const char *font, int size) {
+    if (!g_ft_lib) {
+        if (FT_Init_FreeType(&g_ft_lib) != 0) return NULL;
+    }
+    if (!g_face.face || strcmp(g_face.path, font) != 0) {
+        if (g_face.face) { FT_Done_Face(g_face.face); g_face.face = NULL; }
+        if (FT_New_Face(g_ft_lib, font, 0, &g_face.face) != 0) {
+            /* Configured font missing on this board (e.g. wqy-microhei not
+             * installed): fall back to the first available default font so
+             * the title strip still renders text. */
+            const char *fb = title_default_font();
+            int fb_ok = fb && strcmp(fb, font) != 0 &&
+                        FT_New_Face(g_ft_lib, fb, 0, &g_face.face) == 0;
+            if (!fb_ok) {
+                g_face.face = NULL;
+                return NULL;
+            }
+            fprintf(stderr, "title: font %s not found, using %s\n", font, fb);
+            font = fb;
+        }
+        snprintf(g_face.path, sizeof(g_face.path), "%s", font);
+        g_face.size = -1;
+    }
+    if (g_face.size != size) {
+        if (FT_Set_Pixel_Sizes(g_face.face, 0, size) != 0) return NULL;
+        g_face.size = size;
+    }
+    return g_face.face;
+}
+
+/* ---------- rasterized text bitmap cache ---------- */
+
+#define TEXT_CACHE_SLOTS 6
+
+typedef struct {
+    /* key */
+    char text[256];
+    char font[64];      /* short tag: last path component */
+    int size;
+    unsigned color;
+    int opacity;
+    /* bitmap: A8 coverage, origin at (ox, oy); baseline sits at y = oy */
+    uint8_t *cov;
+    int w, h, ox, oy;
+    long advance;       /* total advance width in px */
+    uint64_t last_use;
+} TextBitmap;
+
+static TextBitmap g_tcache[TEXT_CACHE_SLOTS];
+static uint64_t g_tcache_clock = 0;
+
+static TextBitmap *text_cache_find(const char *font, const char *text, int size,
+                                    unsigned color, int opacity) {
+    const char *tag = strrchr(font, '/');
+    tag = tag ? tag + 1 : font;
+    for (int i = 0; i < TEXT_CACHE_SLOTS; i++) {
+        TextBitmap *t = &g_tcache[i];
+        if (t->cov && t->size == size && t->color == color &&
+            t->opacity == opacity && t->advance >= 0 &&
+            strcmp(t->text, text) == 0 && strcmp(t->font, tag) == 0)
+            return t;
+    }
+    return NULL;
+}
+
+/* Rasterize `text` into a fresh cache slot. Returns slot or NULL. */
+static TextBitmap *text_cache_rasterize(const char *font, const char *text,
+                                         int size, unsigned color, int opacity) {
+    FT_Face face = get_face(font, size);
+    if (!face) return NULL;
+
+    long total = 0;
+    const uint8_t *s = (const uint8_t *)text;
+    while (*s) {
+        uint32_t cp;
+        int n = utf8_decode(s, &cp);
+        if (n <= 0) { s++; continue; }
+        if (FT_Load_Char(face, cp, FT_LOAD_NO_BITMAP) == 0)
+            total += face->glyph->advance.x >> 6;
+        s += n;
+    }
+    if (total <= 0) return NULL;
+
+    /* find a free slot (or evict least recently used) */
+    TextBitmap *t = &g_tcache[0];
+    for (int i = 0; i < TEXT_CACHE_SLOTS; i++) {
+        if (!g_tcache[i].cov) { t = &g_tcache[i]; break; }
+        if (g_tcache[i].last_use < t->last_use) t = &g_tcache[i];
+    }
+    free(t->cov);
+    memset(t, 0, sizeof(*t));
+
+    /* generous box: pad covers negative bearings/overhangs */
+    int pad = size;
+    t->w = (int)total + 2 * pad;
+    t->h = 2 * size + 8;
+    t->ox = pad;
+    t->oy = size + 4;
+    t->cov = calloc((size_t)t->w * t->h, 1);
+    if (!t->cov) { memset(t, 0, sizeof(*t)); return NULL; }
+    snprintf(t->text, sizeof(t->text), "%s", text);
+    const char *tag = strrchr(font, '/');
+    tag = tag ? tag + 1 : font;
+    snprintf(t->font, sizeof(t->font), "%s", tag);
+    t->size = size;
+    t->color = color;
+    t->opacity = opacity < 0 ? 0 : (opacity > 255 ? 255 : opacity);
+    t->advance = total;
+
+    s = (const uint8_t *)text;
+    int pen_x = t->ox;
+    while (*s) {
+        uint32_t cp;
+        int n = utf8_decode(s, &cp);
+        if (n <= 0) { s++; continue; }
+        if (FT_Load_Char(face, cp, FT_LOAD_RENDER) == 0 && face->glyph->bitmap.buffer) {
+            FT_Bitmap *bm = &face->glyph->bitmap;
+            int gx = pen_x + face->glyph->bitmap_left;
+            int gy = t->oy - face->glyph->bitmap_top;
+            for (unsigned j = 0; j < bm->rows; j++) {
+                int yy = gy + (int)j;
+                if (yy < 0 || yy >= t->h) continue;
+                const uint8_t *row = bm->buffer + (size_t)j * bm->pitch;
+                for (unsigned i = 0; i < bm->width; i++) {
+                    int xx = gx + (int)i;
+                    if (xx < 0 || xx >= t->w) continue;
+                    int v = row[i];
+                    if (v > t->cov[(size_t)yy * t->w + xx])
+                        t->cov[(size_t)yy * t->w + xx] = (uint8_t)v;
+                }
+            }
+            pen_x += face->glyph->advance.x >> 6;
+        }
+        s += n;
+    }
+    return t;
+}
+
+/* Blend a cached A8 text bitmap onto NV12 at baseline (bx, by),
+ * clipped to [y_top, y_bot). Same Y/UV blending as draw_glyph. */
+static void blit_text_bitmap(uint8_t *map, int out_w, int out_h,
+                              int y_top, int y_bot,
+                              const TextBitmap *t, int bx, int by,
+                              unsigned color, int opacity) {
+    if (!t || !t->cov) return;
+    uint8_t *Y = map;
+    uint8_t *UV = map + (size_t)out_w * out_h;
+    int Yw, Cbw, Crw;
+    rgb2yuv((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF,
+            &Yw, &Cbw, &Crw);
+    int x0 = bx - t->ox;
+    int y0 = by - t->oy;
+    for (int j = 0; j < t->h; j++) {
+        int y = y0 + j;
+        if (y < y_top || y >= y_bot || y >= out_h) continue;
+        const uint8_t *crow = t->cov + (size_t)j * t->w;
+        uint8_t *yrow = Y + (size_t)y * out_w;
+        uint8_t *uvrow = UV + (size_t)(y / 2) * out_w;
+        for (int i = 0; i < t->w; i++) {
+            int cov = (crow[i] * opacity) / 255;
+            if (cov <= 0) continue;
+            int x = x0 + i;
+            if (x < 0 || x >= out_w) continue;
+            uint8_t *dst = &yrow[x];
+            *dst = (uint8_t)(*dst + ((Yw - *dst) * cov) / 255);
+            if ((x & 1) == 0 && (y & 1) == 0) {
+                uint8_t *uvp = &uvrow[x];
+                *uvp = (uint8_t)(*uvp + ((Cbw - *uvp) * cov) / 255);
+                if (x + 1 < out_w)
+                    uvp[1] = (uint8_t)(uvp[1] + ((Crw - uvp[1]) * cov) / 255);
+            }
+        }
+    }
+}
+
+static void text_cache_touch(TextBitmap *t) {
+    t->last_use = ++g_tcache_clock;
+}
+
+/* Measure the advance width (px) of a UTF-8 text line. Returns width or -1. */
+static long measure_text(const char *font, const char *text, int size) {
+    FT_Face face = get_face(font, size);
+    if (!face) return -1;
+    const uint8_t *s = (const uint8_t *)text;
+    long total = 0;
+    while (*s) {
+        uint32_t cp;
+        int n = utf8_decode(s, &cp);
+        if (n <= 0) { s++; continue; }
+        if (FT_Load_Char(face, cp, FT_LOAD_NO_BITMAP) == 0)
+            total += face->glyph->advance.x >> 6;
+        s += n;
+    }
+    return total;
+}
+
+/* Render a UTF-8 text line into the NV12 buffer, clipped to [y_top, y_bot).
+ * Text is left-aligned at left_x (<=0: centered on the whole width) and its
+ * vertical center sits at center_y. opacity 0-255 scales translucency.
+ * Uses the rasterized-text cache: repeated draws are a single blit.
+ * Returns the total advance width (px) or -1 on error. */
+static long render_text(uint8_t *map, int out_w, int out_h, int y_top, int y_bot,
+                        const char *font, const char *text, int size,
+                        unsigned color, int left_x, int center_y, int opacity) {
+    if (opacity < 0) opacity = 0;
+    if (opacity > 255) opacity = 255;
+
+    TextBitmap *t = text_cache_find(font, text, size, color, opacity);
+    if (!t) t = text_cache_rasterize(font, text, size, color, opacity);
+    if (!t || !t->cov) return -1;
+    text_cache_touch(t);
+
+    if (left_x <= 0) {
+        left_x = out_w / 2 - (int)t->advance / 2;
+        if (left_x < 0) left_x = 0;
+    }
+    if (center_y <= 0) center_y = y_top + (y_bot - y_top) / 2;
+
+    FT_Face face = get_face(font, size);
+    if (!face) return -1;
+    long asc = face->size->metrics.ascender >> 6;
+    long desc = face->size->metrics.descender >> 6;
+    int baseline_y = center_y + (int)((asc + desc) / 2);
+
+    blit_text_bitmap(map, out_w, out_h, y_top, y_bot,
+                     t, left_x, baseline_y, color, opacity);
+    return t->advance;
+}
+
+/* Return the first available WQY font (microhei preferred, zenhei fallback). */
+const char *title_default_font(void) {
+    static const char *const candidates[] = {
+        "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
+        "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
+    };
+    for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++)
+        if (access(candidates[i], R_OK) == 0) return candidates[i];
+    return candidates[0];
+}
+
+/* ---------- public ---------- */
+
+/* Draw logo + text as one centered group into the bar (bar_h rows). */
+static int render_logo_text(uint8_t *map, int out_w, int out_h, int bar_h,
+                            const TitleCfg *cfg) {
+    int ok = 0;
+    int w = 0, h = 0, logo_dw = 0, logo_dh = 0;
+    uint8_t *rgba = NULL, *scaled = NULL;
+
+    if (cfg->logo[0]) {
+        if (decode_image(cfg->logo, &w, &h, &rgba) == 0) {
+            int box = bar_h - 2 * cfg->logo_pad;
+            if (box < 4) box = 4;
+            float sc = (float)box / (w > h ? w : h);
+            logo_dw = (int)(w * sc + 0.5f);
+            logo_dh = (int)(h * sc + 0.5f);
+            if (logo_dw < 1) logo_dw = 1;
+            if (logo_dh < 1) logo_dh = 1;
+            scaled = malloc((size_t)logo_dw * logo_dh * 4);
+            if (scaled) scale_rgba(rgba, w, h, scaled, logo_dw, logo_dh);
+        }
+    }
+
+    const char *font = cfg->font[0] ? cfg->font
+                                   : title_default_font();
+    long tw = 0;
+    if (cfg->text[0]) {
+        tw = measure_text(font, cfg->text, cfg->font_size);
+        if (tw < 0) tw = 0;
+    }
+
+    int pad = cfg->logo_pad > 0 ? cfg->logo_pad : 0;
+    int gap = pad;                       /* space between logo and text */
+    long group_w = (logo_dw > 0 ? (long)pad + logo_dw + gap : 0) + tw;
+    long start_x = (out_w - group_w) / 2;
+    if (start_x < 0) start_x = 0;
+
+    int op = cfg->opacity > 0 ? cfg->opacity : 255;
+    if (scaled) {
+        blend_logo(map, out_w, out_h, bar_h, scaled, logo_dw, logo_dh,
+                   (int)start_x + pad, (bar_h - logo_dh) / 2, op);
+        ok = 1;
+    }
+    if (cfg->text[0] && tw > 0) {
+        long r = render_text(map, out_w, out_h, 0, bar_h, font, cfg->text, cfg->font_size,
+                             cfg->color, (int)start_x + pad + logo_dw + gap,
+                             bar_h / 2, op);
+        if (r >= 0) ok = 1;
+        else fprintf(stderr, "title: text render failed\n");
+    }
+
+    free(scaled);
+    free(rgba);
+    return ok;
+}
+
+int title_render(uint8_t *map, int out_w, int out_h, const TitleCfg *cfg) {
+    if (!map || !cfg) return -1;
+    int bar_h = cfg->height;
+    if (bar_h <= 0 || bar_h > out_h) bar_h = out_h;
+    if (bar_h & 1) bar_h--;   /* keep UV rows aligned */
+    if (bar_h < 2) bar_h = 2;
+
+    uint8_t *Y = map;
+    uint8_t *UV = map + (size_t)out_w * out_h;
+
+    /* dark bar background */
+    memset(Y, 0x10, (size_t)out_w * bar_h);
+    memset(UV, 0x80, (size_t)out_w * (bar_h / 2));
+
+    int ok = render_logo_text(map, out_w, out_h, bar_h, cfg);
+    if (!ok && !cfg->logo[0] && !cfg->text[0]) return -1;
+    return ok ? 0 : -1;
+}
+
+int title_render_overlay(uint8_t *map, int out_w, int out_h, const TitleCfg *cfg) {
+    if (!map || !cfg) return -1;
+    int bar_h = cfg->height;
+    if (bar_h <= 0 || bar_h > out_h) bar_h = out_h;
+    if (bar_h & 1) bar_h--;   /* keep UV rows aligned */
+    if (bar_h < 2) bar_h = 2;
+
+    /* Semi-transparent dark strip over the live frame: blend each channel
+     * halfway toward dark (Y=0x10) / neutral (UV=0x80). */
+    uint8_t *Y = map;
+    uint8_t *UV = map + (size_t)out_w * out_h;
+    for (int y = 0; y < bar_h; y++) {
+        uint8_t *row = Y + (size_t)y * out_w;
+        for (int x = 0; x < out_w; x++)
+            row[x] = (uint8_t)((row[x] >> 1) + 8);
+    }
+    int uvh = bar_h / 2;
+    for (int y = 0; y < uvh; y++) {
+        uint8_t *row = UV + (size_t)y * out_w;
+        for (int x = 0; x < out_w; x++)
+            row[x] = (uint8_t)((row[x] >> 1) + 0x40);
+    }
+
+    int ok = render_logo_text(map, out_w, out_h, bar_h, cfg);
+    if (!ok && !cfg->logo[0] && !cfg->text[0]) return -1;
+    return ok ? 0 : -1;
+}
+
+int title_render_text(uint8_t *map, int out_w, int out_h,
+                      const char *font, const char *text, int size,
+                      unsigned color, int center_x, int center_y) {
+    if (!map || !text || !text[0]) return -1;
+    const char *f = font && font[0] ? font : title_default_font();
+    int left_x = center_x;
+    if (center_x > 0) {                     /* center on an explicit x */
+        long tw = measure_text(f, text, size);
+        if (tw < 0) tw = 0;
+        left_x = center_x - (int)(tw / 2);
+    }
+    long r = render_text(map, out_w, out_h, 0, out_h, f, text, size, color,
+                         left_x, center_y, 255);
+    return r >= 0 ? 0 : -1;
+}
+
+long title_measure_text(const char *font, const char *text, int size) {
+    const char *f = font && font[0] ? font : title_default_font();
+    return measure_text(f, text, size);
+}
+
+/* ---------- PLC connection status badge ---------- */
+
+/* Draw a small translucent badge in the top-right corner of an NV12 buffer:
+ * a colored status dot (green = connected, red = disconnected) plus a label.
+ * Returns 0 on success, -1 on error. */
+int title_render_plc_status(uint8_t *map, int out_w, int out_h,
+                            const char *font, int connected) {
+    if (!map || out_w < 80 || out_h < 50) return -1;
+
+    const char *st = connected ? "PLC 已连接" : "PLC 断开";
+    int text_size = 18;
+    long tw = title_measure_text(font, st, text_size);
+    if (tw < 0) tw = 0;
+
+    int dot_r = 6;
+    int pad_x = 10, pad_y = 6;
+    int dot_zone = dot_r * 2 + 8;              /* dot + gap to label */
+    int bad_w = pad_x + dot_zone + pad_x + (int)tw + pad_x;
+    int bad_h = pad_y + text_size + pad_y + 4;
+    if (bad_h < dot_zone + pad_y + 4) bad_h = dot_zone + pad_y + 4;
+
+    int x0 = out_w - 12 - bad_w;               /* right margin 12 */
+    int y0 = 12;
+    if (x0 < 0) { x0 = 0; bad_w = out_w - 12; }
+
+    /* Build the RGBA badge canvas: dark rounded pill + status dot. */
+    uint8_t *rgba = malloc((size_t)bad_w * bad_h * 4);
+    if (!rgba) return -1;
+    for (int j = 0; j < bad_h; j++) {
+        for (int i = 0; i < bad_w; i++) {
+            int rc = 0;
+            /* distance to the nearest of the 4 rounded corners (r=8) */
+            int cx = (i < 8) ? 7 : (i >= bad_w - 8 ? bad_w - 8 - 1 : i);
+            int cy = (j < 8) ? 7 : (j >= bad_h - 8 ? bad_h - 8 - 1 : j);
+            int dx = i - cx, dy = j - cy;
+            if (i < 8 || i >= bad_w - 8 || j < 8 || j >= bad_h - 8)
+                if (dx * dx + dy * dy > 8 * 8) rc = 1;
+            if (rc) {                             /* outside pill: transparent */
+                rgba[(size_t)(j * bad_w + i) * 4 + 3] = 0;
+                continue;
+            }
+            rgba[(size_t)(j * bad_w + i) * 4 + 0] = 0x24;
+            rgba[(size_t)(j * bad_w + i) * 4 + 1] = 0x24;
+            rgba[(size_t)(j * bad_w + i) * 4 + 2] = 0x24;
+            rgba[(size_t)(j * bad_w + i) * 4 + 3] = 190;   /* ~75% */
+        }
+    }
+
+    /* status dot (1px ring in light gray, fill in status color) */
+    int dot_cx = pad_x + dot_r + 1;
+    int dot_cy = bad_h / 2;
+    int dcr, dcg, dcb;
+    if (connected) { dcr = 60;  dcg = 205; dcb = 64;  /* green */ }
+    else           { dcr = 224; dcg = 48;  dcb = 40;  /* red   */ }
+    for (int j = dot_cy - dot_r - 1; j <= dot_cy + dot_r + 1; j++) {
+        for (int i = dot_cx - dot_r - 1; i <= dot_cx + dot_r + 1; i++) {
+            if (i < 0 || i >= bad_w || j < 0 || j >= bad_h) continue;
+            int d2 = (i - dot_cx) * (i - dot_cx) + (j - dot_cy) * (j - dot_cy);
+            if (d2 <= dot_r * dot_r) {              /* fill */
+                uint8_t *px = rgba + (size_t)(j * bad_w + i) * 4;
+                px[0] = (uint8_t)dcr; px[1] = (uint8_t)dcg; px[2] = (uint8_t)dcb;
+                px[3] = 255;
+            } else if (d2 <= (dot_r + 1) * (dot_r + 1)) {   /* ring */
+                uint8_t *px = rgba + (size_t)(j * bad_w + i) * 4;
+                px[0] = 0xE8; px[1] = 0xE8; px[2] = 0xE8; px[3] = 230;
+            }
+        }
+    }
+
+    blend_logo(map, out_w, out_h, out_h, rgba, bad_w, bad_h, x0, y0, 255);
+    free(rgba);
+
+    int text_cx = x0 + pad_x + dot_zone + pad_x - 2 + (int)(tw / 2);
+    int text_cy = y0 + bad_h / 2;
+    return title_render_text(map, out_w, out_h, font, st, text_size,
+                             0xFFFFFF, text_cx, text_cy);
+}
+
+/* Draw a translucent pill with text, bottom-left corner. */
+int title_render_ip(uint8_t *map, int out_w, int out_h,
+                    const char *font, const char *text) {
+    if (!map || out_w < 80 || out_h < 50) return -1;
+
+    const char *st = (text && text[0]) ? text : "IP 无地址";
+    int text_size = 18;
+    long tw = title_measure_text(font, st, text_size);
+    if (tw < 0) tw = 0;
+
+    int pad_x = 10, pad_y = 6;
+    int bad_w = pad_x + (int)tw + pad_x;
+    int bad_h = pad_y + text_size + pad_y + 4;
+    int x0 = 12;                          /* left margin 12 */
+    int y0 = out_h - 12 - bad_h;          /* bottom-left corner */
+    if (y0 < 0) y0 = 0;
+    if (x0 + bad_w > out_w) bad_w = out_w - x0;
+    if (bad_w <= 0) return -1;
+
+    uint8_t *rgba = malloc((size_t)bad_w * bad_h * 4);
+    if (!rgba) return -1;
+    for (int j = 0; j < bad_h; j++) {
+        for (int i = 0; i < bad_w; i++) {
+            int rc = 0;
+            /* distance to the nearest of the 4 rounded corners (r=8) */
+            int cx = (i < 8) ? 7 : (i >= bad_w - 8 ? bad_w - 8 - 1 : i);
+            int cy = (j < 8) ? 7 : (j >= bad_h - 8 ? bad_h - 8 - 1 : j);
+            int dx = i - cx, dy = j - cy;
+            if (i < 8 || i >= bad_w - 8 || j < 8 || j >= bad_h - 8)
+                if (dx * dx + dy * dy > 8 * 8) rc = 1;
+            uint8_t *px = rgba + (size_t)(j * bad_w + i) * 4;
+            if (rc) { px[3] = 0; continue; }      /* outside pill: transparent */
+            px[0] = 0x24; px[1] = 0x24; px[2] = 0x24; px[3] = 190;
+        }
+    }
+
+    blend_logo(map, out_w, out_h, out_h, rgba, bad_w, bad_h, x0, y0, 255);
+    free(rgba);
+
+    int text_cx = x0 + bad_w / 2;
+    int text_cy = y0 + bad_h / 2;
+    return title_render_text(map, out_w, out_h, font, st, text_size,
+                             0xFFFFFF, text_cx, text_cy);
+}

+ 54 - 0
title.h

@@ -0,0 +1,54 @@
+#ifndef __TITLE_H__
+#define __TITLE_H__
+
+#include <stdint.h>
+
+typedef struct {
+    int enable;
+    int type;           /* 0 = bar (solid, reserves space), 1 = overlay (translucent) */
+    int height;         /* bar height, rounded down to even */
+    char text[256];
+    char logo[256];
+    char font[256];
+    int font_size;
+    unsigned color;     /* RGB, e.g. 0xFFFFFF = white */
+    int logo_pad;
+    int opacity;        /* logo/text global opacity 0-255, 255 = opaque */
+} TitleCfg;
+
+/* Render the title bar (logo + text) into the top of an NV12 buffer.
+ * map must point to out_w*out_h*3/2 bytes. Returns 0 on success, -1 on error. */
+int title_render(uint8_t *map, int out_w, int out_h, const TitleCfg *cfg);
+
+/* Return the first available default font path (WQY microhei, else zenhei). */
+const char *title_default_font(void);
+
+/* Render a translucent title strip over existing frame content (overlay mode):
+ * darkens the top cfg->height rows, then draws logo + text on top. The video
+ * below/under it is left intact. Returns 0 on success, -1 on error. */
+int title_render_overlay(uint8_t *map, int out_w, int out_h, const TitleCfg *cfg);
+
+/* Render one text line centered on (center_x, center_y) into the NV12 buffer.
+ * center_x <= 0 centers over the whole width; center_y <= 0 uses out_h/2.
+ * Font falls back to the WQY micro hei path if empty. Returns 0 on success. */
+int title_render_text(uint8_t *map, int out_w, int out_h,
+                      const char *font, const char *text, int size,
+                      unsigned color, int center_x, int center_y);
+
+/* Advance width (px) of a UTF-8 text line, or -1 on error.
+ * Empty font falls back to the WQY default. Used for right-aligned text. */
+long title_measure_text(const char *font, const char *text, int size);
+
+/* Draw a small translucent PLC connection status badge in the top-right
+ * corner of an NV12 buffer: colored dot (green = connected, red = offline)
+ * plus a label. Returns 0 on success, -1 on error. */
+int title_render_plc_status(uint8_t *map, int out_w, int out_h,
+                            const char *font, int connected);
+
+/* Draw a translucent pill in the bottom-left corner showing the given text
+ * (used for the board's IP address(es)) so the operator can read it on screen.
+ * Returns 0 on success, -1 on error. */
+int title_render_ip(uint8_t *map, int out_w, int out_h,
+                    const char *font, const char *text);
+
+#endif

+ 21 - 0
uninstall.sh

@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -e
+
+if [ "$EUID" -ne 0 ]; then
+    echo "Please run as root"
+    exit 1
+fi
+
+SERVICE="/etc/systemd/system/rtsp_multi_view.service"
+TARGET="/usr/local/bin/rtsp_multi_view"
+
+systemctl stop rtsp_multi_view
+systemctl disable rtsp_multi_view
+
+rm -f "$SERVICE"
+rm -f "$TARGET"
+rm -rf /etc/rtsp_multi_view
+
+systemctl daemon-reload
+
+echo "Uninstalled rtsp_multi_view."

+ 1267 - 0
web_server.c

@@ -0,0 +1,1267 @@
+#include "web_server.h"
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <errno.h>
+#include <pthread.h>
+#include <time.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+#include <ifaddrs.h>
+#include <net/if.h>
+
+#define BUF_SIZE 8192
+#define MAX_CAMS 8
+
+static const char *g_config_path;
+static const char *g_password;
+static int g_port;
+
+/* Optional NIC whitelist ([web] ifaces = eth0 eth1 in channels.conf).
+ * Empty = show/configure every non-loopback interface. */
+static char g_iface_wl[8][IFNAMSIZ];
+static int g_iface_wn = 0;
+
+static int iface_allowed(const char *name);
+
+/* ---- MD5-like simple hash for session cookie (not crypto-secure, fine for LAN) ---- */
+static unsigned simple_hash(const char *s) {
+    unsigned h = 0x811c9dc5;
+    for (; *s; s++) {
+        h ^= (unsigned char)*s;
+        h *= 0x01000193;
+    }
+    return h;
+}
+
+/* ---- HTTP helpers ---- */
+static void send_str(int fd, const char *s) {
+    size_t len = strlen(s);
+    size_t sent = 0;
+    while (sent < len) {
+        ssize_t r = write(fd, s + sent, len - sent);
+        if (r <= 0) return;
+        sent += r;
+    }
+}
+
+static void send_bytes(int fd, const char *buf, size_t len) {
+    size_t sent = 0;
+    while (sent < len) {
+        ssize_t r = write(fd, buf + sent, len - sent);
+        if (r <= 0) return;
+        sent += r;
+    }
+}
+
+static int recv_line(int fd, char *buf, int maxlen) {
+    int n = 0;
+    while (n < maxlen - 1) {
+        char c;
+        ssize_t r = read(fd, &c, 1);
+        if (r <= 0) return -1;
+        buf[n++] = c;
+        if (c == '\n') break;
+    }
+    buf[n] = '\0';
+    return n;
+}
+
+/* ---- URL decode ---- */
+static void url_decode(char *dst, size_t dstsz, const char *src) {
+    size_t i = 0;
+    while (*src && i < dstsz - 1) {
+        if (*src == '%' && src[1] && src[2]) {
+            char hex[3] = {src[1], src[2], 0};
+            dst[i++] = (char)strtol(hex, NULL, 16);
+            src += 3;
+        } else if (*src == '+') {
+            dst[i++] = ' ';
+            src++;
+        } else {
+            dst[i++] = *src++;
+        }
+    }
+    dst[i] = '\0';
+}
+
+/* ---- Extract value from POST body key=value&key2=value2 ---- */
+static int post_value(const char *body, const char *key, char *val, size_t valsz) {
+    char pattern[64];
+    snprintf(pattern, sizeof(pattern), "%s=", key);
+    const char *p = strstr(body, pattern);
+    if (!p) return -1;
+    p += strlen(pattern);
+    size_t i = 0;
+    while (*p && *p != '&' && i < valsz - 1) val[i++] = *p++;
+    val[i] = '\0';
+    url_decode(val, valsz, val);
+    return 0;
+}
+
+/* ---- Local network info (all interfaces / segments) ---- */
+struct net_if { char iface[IFNAMSIZ]; char ip[64]; char mask[64]; };
+
+static int collect_nets(struct net_if *out, int maxn) {
+    struct ifaddrs *ifa0 = NULL;
+    if (getifaddrs(&ifa0) != 0) return 0;
+    int n = 0;
+    for (struct ifaddrs *ifp = ifa0; ifp && n < maxn; ifp = ifp->ifa_next) {
+        if (!ifp->ifa_addr || ifp->ifa_addr->sa_family != AF_INET) continue;
+        if (strcmp(ifp->ifa_name, "lo") == 0) continue;
+        if (!iface_allowed(ifp->ifa_name)) continue;
+        struct sockaddr_in *a = (struct sockaddr_in *)ifp->ifa_addr;
+        struct sockaddr_in *m = (struct sockaddr_in *)ifp->ifa_netmask;
+        snprintf(out[n].iface, sizeof(out[n].iface), "%s", ifp->ifa_name);
+        if (!inet_ntop(AF_INET, &a->sin_addr, out[n].ip, sizeof(out[n].ip))) continue;
+        if (m) inet_ntop(AF_INET, &m->sin_addr, out[n].mask, sizeof(out[n].mask));
+        else snprintf(out[n].mask, sizeof(out[n].mask), "255.255.255.0");
+        n++;
+    }
+    freeifaddrs(ifa0);
+    return n;
+}
+
+/* All physical interfaces (any family), loopback excluded. Used so the page
+ * can also configure a NIC that currently has no IPv4 address at all. */
+static int collect_ifaces(char (*out)[IFNAMSIZ], int maxn) {
+    struct ifaddrs *ifa0 = NULL;
+    if (getifaddrs(&ifa0) != 0) return 0;
+    int n = 0;
+    for (struct ifaddrs *ifp = ifa0; ifp && n < maxn; ifp = ifp->ifa_next) {
+        if (!ifp->ifa_name || strcmp(ifp->ifa_name, "lo") == 0) continue;
+        if (!iface_allowed(ifp->ifa_name)) continue;
+        int dup = 0;
+        for (int i = 0; i < n; i++)
+            if (strcmp(out[i], ifp->ifa_name) == 0) { dup = 1; break; }
+        if (dup) continue;
+        snprintf(out[n], IFNAMSIZ, "%s", ifp->ifa_name);
+        n++;
+    }
+    freeifaddrs(ifa0);
+    return n;
+}
+
+static int is_ipv4(const char *s) {
+    struct in_addr a;
+    return s && inet_pton(AF_INET, s, &a) == 1;
+}
+
+static int iface_exists(const char *name) {
+    char tmp[16][IFNAMSIZ];
+    int n = collect_ifaces(tmp, 16);
+    for (int i = 0; i < n; i++)
+        if (strcmp(tmp[i], name) == 0) return 1;
+    return 0;
+}
+
+/* First IPv4 address currently on <iface> (the primary), if any. */
+static int primary_of(const char *iface, char *ip, size_t ipsz,
+                      char *mask, size_t masksz) {
+    struct net_if nets[16];
+    int n = collect_nets(nets, 16);
+    for (int i = 0; i < n; i++) {
+        if (strcmp(nets[i].iface, iface) == 0) {
+            snprintf(ip, ipsz, "%s", nets[i].ip);
+            snprintf(mask, masksz, "%s",
+                     (nets[i].mask[0] && strcmp(nets[i].mask, "0.0.0.0") != 0)
+                         ? nets[i].mask : "255.255.255.0");
+            return 1;
+        }
+    }
+    return 0;
+}
+
+/* Safe append that grows the heap buffer (avoids strcat overflow). */
+static char *buf_append(char *buf, const char *s) {
+    size_t a = buf ? strlen(buf) : 0;
+    size_t b = strlen(s);
+    char *n = realloc(buf, a + b + 1);
+    if (!n) return buf;
+    memcpy(n + a, s, b + 1);
+    return n;
+}
+
+/* Only [0-9A-Za-z./:] allowed: used to build the `ip` command safely. */
+static int is_safe_net_arg(const char *s) {
+    if (!s || !s[0]) return 0;
+    for (const char *p = s; *p; p++) {
+        char c = *p;
+        if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') ||
+              (c >= 'A' && c <= 'Z') || c == '.' || c == ':' || c == '/'))
+            return 0;
+    }
+    return 1;
+}
+
+static int netmask_to_prefix(const char *mask) {
+    struct in_addr a;
+    if (!mask || inet_pton(AF_INET, mask, &a) != 1) return 24;
+    unsigned v = ntohl(a.s_addr);
+    int p = 0;
+    for (int b = 31; b >= 0 && (v & (1u << b)); b--) p++;
+    return p;
+}
+
+/* Some vendor rootfs images drive the NIC with NetworkManager (ifupdown is
+ * installed but no longer manages the device), so /etc/network/interfaces is
+ * ignored at boot. Detect that case and persist through nmcli instead. */
+static int nm_running(void) {
+    FILE *p = popen("nmcli -t -g RUNNING general 2>/dev/null", "r");
+    if (!p) return 0;
+    char b[32] = "";
+    size_t rd = fread(b, 1, sizeof(b) - 1, p);
+    b[rd] = '\0';
+    pclose(p);
+    return strstr(b, "running") != NULL;
+}
+
+static int nm_managed(const char *iface) {
+    char cmd[128], line[192];
+    snprintf(cmd, sizeof(cmd),
+             "nmcli -t -f GENERAL.STATE dev show %s 2>/dev/null", iface);
+    FILE *p = popen(cmd, "r");
+    if (!p) return 0;
+    int managed = 0;
+    while (fgets(line, sizeof(line), p)) {
+        if (strstr(line, "unmanaged")) { managed = 0; break; }
+        if (strstr(line, "GENERAL.STATE:")) managed = 1;
+    }
+    pclose(p);
+    return managed;
+}
+
+static int nm_conn_name(const char *iface, char *out, size_t outsz) {
+    char cmd[128], line[192];
+    snprintf(cmd, sizeof(cmd),
+             "nmcli -t -f GENERAL.CONNECTION dev show %s 2>/dev/null", iface);
+    FILE *p = popen(cmd, "r");
+    if (!p) return 0;
+    int got = 0;
+    while (fgets(line, sizeof(line), p)) {
+        char *c = strchr(line, ':');
+        if (!c) continue;
+        *c = '\0';
+        char *v = c + 1;
+        v[strcspn(v, "\r\n")] = '\0';
+        if (strcmp(line, "GENERAL.CONNECTION") == 0 && v[0]) {
+            snprintf(out, outsz, "%s", v);
+            got = 1;
+        }
+    }
+    pclose(p);
+    return got;
+}
+
+static int nm_safe_name(const char *s) {
+    if (!s || !s[0] || strlen(s) >= 96) return 0;
+    for (const char *p = s; *p; p++)
+        if (strchr("'\"`\\$;&|<>()\n\r", *p)) return 0;
+    return 1;
+}
+
+/* Persist an inet-static stanza for <iface> in /etc/network/interfaces.
+ * Edits the matching block in place; appends one if absent. */
+static void strip_up_addr_lines(char *buf, const char *iface);
+static int persist_interfaces(const char *iface, const char *ip,
+                              const char *mask, const char *gw) {
+    const char *path = "/etc/network/interfaces";
+    long sz = 0;
+    FILE *fp = fopen(path, "r");
+    char *buf = NULL;
+    if (fp) {
+        fseek(fp, 0, SEEK_END);
+        sz = ftell(fp);
+        fseek(fp, 0, SEEK_SET);
+        buf = malloc(sz + 8192);
+        if (buf) { size_t rd = fread(buf, 1, sz, fp); buf[rd] = '\0'; }
+        fclose(fp);
+    }
+    if (!buf) {
+        buf = strdup("# generated by rtsp_multi_view web (no prior file)\n"
+                     "source /etc/network/interfaces.d/*\n\nauto lo\n"
+                     "iface lo inet loopback\n\n");
+    }
+
+    char pat[80];
+    snprintf(pat, sizeof(pat), "iface %s inet", iface);
+    char *sec = strstr(buf, pat);
+    int nwritten = 0;
+
+    if (sec) {
+        /* walk the stanza lines until the next 'iface'/'auto'/'source' */
+        char *line = sec + strlen(pat);
+        char *end = strchr(line, '\n');
+        while (line && *line && nwritten < 3) {
+            char *next = end ? end + 1 : NULL;
+            const char *p = line;
+            while (*p == ' ' || *p == '\t') p++;
+            if (strncmp(p, "iface ", 6) == 0 || strncmp(p, "auto ", 5) == 0 ||
+                strncmp(p, "source", 6) == 0 || p[0] == '[') {
+                break;
+            }
+            if (*p != '#' && *p != '\n' && *p != '\r' && *p) {
+                char key[32], val[256] = {0};
+                if (sscanf(p, "%31s %255[^\n\r]", key, val) >= 1) {
+                    char *repl = NULL;
+                    if (strcmp(key, "address") == 0) repl = (char *)ip;
+                    else if (strcmp(key, "netmask") == 0) repl = (char *)mask;
+                    else if (strcmp(key, "gateway") == 0) repl = (char *)gw;
+                    if (repl) {
+                        size_t oldlen = (end ? (size_t)(end - line) : strlen(line)) + (end ? 1 : 0);
+                        char newl[256];
+                        int nl = snprintf(newl, sizeof(newl), "    %s %s", key, repl);
+                        newl[nl++] = '\n';
+                        size_t off = (size_t)(line - buf);
+                        size_t total = strlen(buf) + 1;
+                        memmove(line + nl, line + oldlen, total - off - oldlen);
+                        memcpy(line, newl, nl);
+                        nwritten++;
+                        line += nl;
+                        end = strchr(line, '\n');
+                        if (end) { next = end + 1; }
+                        else next = NULL;
+                        continue;
+                    }
+                }
+            }
+            line = next;
+            end = line ? strchr(line, '\n') : NULL;
+        }
+        /* append missing keys at the end of the stanza */
+        char *stanza_end = line ? line : (buf + strlen(buf));
+        if (line && (*line == '\n' || *line == 0)) { /* nothing special */ }
+        if (nwritten < 3) {
+            char add[384]; int ao = 0;
+            if (nwritten == 0) { /* no address key yet */
+                int need_line = (stanza_end > buf && stanza_end[-1] != '\n') ? 1 : 0;
+                if (need_line) add[ao++] = '\n';
+                ao += snprintf(add + ao, sizeof(add) - ao,
+                               "    address %s\n    netmask %s\n", ip, mask);
+                if (gw && is_ipv4(gw))
+                    ao += snprintf(add + ao, sizeof(add) - ao, "    gateway %s\n", gw);
+            } else {
+                if (!strstr(sec, "gateway") && gw && is_ipv4(gw))
+                    ao += snprintf(add + ao, sizeof(add) - ao, "    gateway %s\n", gw);
+            }
+            size_t off = (size_t)(stanza_end - buf);
+            size_t total = strlen(buf) + 1;
+            memmove(stanza_end + ao, stanza_end, total - off);
+            memcpy(stanza_end, add, ao);
+            nwritten = 3;
+        }
+    } else {
+        if (buf[strlen(buf) - 1] != '\n') buf = buf_append(buf, "\n");
+        char seg[512];
+        int so = snprintf(seg, sizeof(seg),
+                          "\nauto %s\niface %s inet static\n    address %s\n    netmask %s\n",
+                          iface, iface, ip, mask);
+        if (gw && is_ipv4(gw))
+            so += snprintf(seg + so, sizeof(seg) - so, "    gateway %s\n", gw);
+        buf = buf_append(buf, seg);
+    }
+
+    strip_up_addr_lines(buf, iface);
+
+    char tmp[128];
+    snprintf(tmp, sizeof(tmp), "%s.tmp", path);
+    fp = fopen(tmp, "w");
+    if (!fp) { free(buf); return -1; }
+    fwrite(buf, 1, strlen(buf), fp);
+    fclose(fp);
+    rename(tmp, path);
+    free(buf);
+    return 0;
+}
+
+/* Remove any "up ip addr add ... dev <iface>" line from the whole file.
+ * Used by replace-mode so extra addresses don't come back after reboot. */
+static void strip_up_addr_lines(char *buf, const char *iface) {
+    char *line = buf;
+    while (line && *line) {
+        char *nl = strchr(line, '\n');
+        char *end = nl ? nl + 1 : line + strlen(line);
+        const char *p = line;
+        while (*p == ' ' || *p == '\t' || *p == '\r') p++;
+        if (strncmp(p, "up ip addr add", 14) == 0 && strstr(p, iface)) {
+            size_t off = (size_t)(line - buf);
+            size_t len = (size_t)(end - line);
+            size_t total = strlen(buf) + 1;
+            memmove(line, end, total - off - len);
+            continue;
+        }
+        line = end;
+    }
+}
+
+/* Append "up ip addr add <cidr> dev <iface>" inside the iface stanza
+ * (before the next iface/auto/source block, or at EOF). Idempotent.
+ * Grows the buffer itself (buf_append may have sized it exactly). */
+static void append_up_addr_line(char **pbuf, const char *cidr, const char *iface) {
+    char *buf = *pbuf;
+    if (strstr(buf, cidr)) return;
+    char add[256];
+    int n = snprintf(add, sizeof(add),
+                     "    up ip addr add %s dev %s 2>/dev/null || true\n", cidr, iface);
+
+    char pat[80];
+    snprintf(pat, sizeof(pat), "iface %s inet", iface);
+    char *sec = strstr(buf, pat);
+    if (!sec) {            /* no stanza: append at EOF */
+        *pbuf = buf_append(buf, add);
+        return;
+    }
+
+    /* scan from AFTER the "iface X inet ..." header until the next
+     * iface/auto/source block boundary (or EOF) */
+    char *ins = strchr(sec, '\n');
+    ins = ins ? ins + 1 : (buf + strlen(buf));
+    while (*ins) {
+        const char *p = ins;
+        while (*p == ' ' || *p == '\t') p++;
+        if (strncmp(p, "iface ", 6) == 0 || strncmp(p, "auto ", 5) == 0 ||
+            strncmp(p, "source", 6) == 0 || p[0] == '[') break;
+        char *nl = strchr(ins, '\n');
+        if (!nl) break;
+        ins = nl + 1;
+    }
+
+    size_t off = (size_t)(ins - buf);
+    size_t oldlen = strlen(buf);
+    char *nbuf = realloc(buf, oldlen + (size_t)n + 64);
+    if (!nbuf) return;
+    *pbuf = nbuf;
+    memmove(nbuf + off + n, nbuf + off, oldlen - off + 1);
+    memcpy(nbuf + off, add, n);
+}
+
+/* Persist an ADDED address: keep the existing stanza untouched, just append
+ * the extra "up ip addr add" line. If the iface has no stanza yet (NIC with
+ * no IP), create one with the new address as primary. */
+static int persist_add_addr(const char *iface, const char *ip,
+                            const char *mask, const char *cidr) {
+    const char *path = "/etc/network/interfaces";
+    long sz = 0;
+    FILE *fp = fopen(path, "r");
+    char *buf = NULL;
+    if (fp) {
+        fseek(fp, 0, SEEK_END);
+        sz = ftell(fp);
+        fseek(fp, 0, SEEK_SET);
+        buf = malloc(sz + 8192);
+        if (buf) { size_t rd = fread(buf, 1, sz, fp); buf[rd] = '\0'; }
+        fclose(fp);
+    }
+    if (!buf) {
+        buf = strdup("# generated by rtsp_multi_view web (no prior file)\n"
+                     "source /etc/network/interfaces.d/*\n\nauto lo\n"
+                     "iface lo inet loopback\n\n");
+    }
+
+    char pat[80];
+    snprintf(pat, sizeof(pat), "iface %s inet", iface);
+    char *sec = strstr(buf, pat);
+    if (!sec) {
+        /* No stanza yet: use the interface's CURRENT primary address (if it
+         * has one) so a reboot restores it; otherwise the added one is the
+         * only address and becomes primary. */
+        char prim_ip[64] = "", prim_mask[64] = "255.255.255.0";
+        if (!primary_of(iface, prim_ip, sizeof(prim_ip), prim_mask, sizeof(prim_mask))) {
+            snprintf(prim_ip, sizeof(prim_ip), "%s", ip);
+            snprintf(prim_mask, sizeof(prim_mask), "%s", mask);
+        }
+        if (buf[strlen(buf) - 1] != '\n') buf = buf_append(buf, "\n");
+        char seg[512];
+        snprintf(seg, sizeof(seg),
+                 "\nauto %s\niface %s inet static\n    address %s\n    netmask %s\n",
+                 iface, iface, prim_ip, prim_mask);
+        buf = buf_append(buf, seg);
+        sec = strstr(buf, pat);
+    }
+    append_up_addr_line(&buf, cidr, iface);
+
+    char tmp[128];
+    snprintf(tmp, sizeof(tmp), "%s.tmp", path);
+    fp = fopen(tmp, "w");
+    if (!fp) { free(buf); return -1; }
+    fwrite(buf, 1, strlen(buf), fp);
+    fclose(fp);
+    rename(tmp, path);
+    free(buf);
+    return 0;
+}
+
+/* ---- Read entire config file into buffer ---- */
+static char *read_config(long *out_size) {
+    FILE *fp = fopen(g_config_path, "r");
+    if (!fp) return NULL;
+    fseek(fp, 0, SEEK_END);
+    long sz = ftell(fp);
+    fseek(fp, 0, SEEK_SET);
+    char *buf = malloc(sz + 1);
+    if (!buf) { fclose(fp); return NULL; }
+    size_t rd = fread(buf, 1, sz, fp);
+    buf[rd] = '\0';
+    fclose(fp);
+    *out_size = rd;
+    return buf;
+}
+
+/* Find "[section]" only at the start of a line (skips comments that mention it). */
+static char *find_section(const char *buf, const char *section) {
+    char pat[64];
+    snprintf(pat, sizeof(pat), "[%s]", section);
+    const char *p = buf;
+    while ((p = strstr(p, pat)) != NULL) {
+        /* must be at line start (or after leading spaces) */
+        const char *ls = p;
+        while (ls > buf && ls[-1] != '\n') ls--;
+        const char *q = ls;
+        while (*q == ' ' || *q == '\t') q++;
+        if (q == p && (ls == buf || ls[-1] == '\n'))
+            return (char *)p;
+        p += strlen(pat);
+    }
+    return NULL;
+}
+
+/* Parse "[web] ifaces = eth0 eth1 ..." from the config file into the
+ * whitelist. Missing/empty = all non-loopback interfaces allowed. */
+static void load_iface_whitelist(void) {
+    g_iface_wn = 0;
+    long sz;
+    char *cfg = g_config_path ? read_config(&sz) : NULL;
+    if (!cfg) return;
+    char *sec = find_section(cfg, "web");
+    if (sec) {
+        const char *line = strchr(sec, '\n');
+        if (line) line += 1;             /* skip the "[web]" header itself */
+        while (line && *line) {
+            const char *nl = strchr(line, '\n');
+            size_t len = nl ? (size_t)(nl - line) : strlen(line);
+            if (len > 0) {
+                char lb[128];
+                size_t cp = len < sizeof(lb) - 1 ? len : sizeof(lb) - 1;
+                memcpy(lb, line, cp);
+                lb[cp] = '\0';
+                char *p = lb;
+                while (*p == ' ' || *p == '\t') p++;
+                if (p[0] == '[') break;                 /* next section */
+                char *eq = strchr(p, '=');
+                if (eq) {
+                    char *k = p;
+                    *eq = '\0';
+                    char *ke = eq - 1;
+                    while (ke >= k && (*ke == ' ' || *ke == '\t')) *ke-- = '\0';
+                    if (strcmp(k, "ifaces") == 0) {
+                        char *v = eq + 1;
+                        char *tok = strtok(v, " ,\t\r\n");
+                        while (tok && g_iface_wn < 8) {
+                            snprintf(g_iface_wl[g_iface_wn], IFNAMSIZ, "%s", tok);
+                            g_iface_wn++;
+                            tok = strtok(NULL, " ,\t\r\n");
+                        }
+                    }
+                }
+            }
+            if (!nl) break;
+            line = nl + 1;
+        }
+    }
+    free(cfg);
+}
+
+static int iface_allowed(const char *name) {
+    if (g_iface_wn == 0) return 1;
+    for (int i = 0; i < g_iface_wn; i++)
+        if (strcmp(g_iface_wl[i], name) == 0) return 1;
+    return 0;
+}
+
+/* ---- Write config file ---- */
+static int write_config(const char *buf, size_t len) {
+    (void)len;
+    FILE *fp = fopen(g_config_path, "w");
+    if (!fp) return -1;
+    fwrite(buf, 1, strlen(buf), fp);
+    fclose(fp);
+    return 0;
+}
+
+/* ---- Replace a key=value line within a [section] ---- */
+static void replace_key(char *buf, const char *section,
+                        const char *key, const char *newval) {
+    char *sec = find_section(buf, section);
+    if (!sec) return;
+
+    char *line = sec;
+    while (line && *line) {
+        if (*line == '[' && line != sec) break;
+        char *eol = strchr(line, '\n');
+        size_t linelen = eol ? (size_t)(eol - line) : strlen(line);
+        const char *p = line;
+        while (*p == ' ' || *p == '\t') p++;
+        if (*p == '#' || *p == '[' || *p == '\n' || *p == '\r' || !*p) {
+            line = eol ? eol + 1 : NULL;
+            continue;
+        }
+        size_t klen = strlen(key);
+        if (strncmp(p, key, klen) == 0 &&
+            (p[klen] == ' ' || p[klen] == '\t' || p[klen] == '=')) {
+            size_t indent = (size_t)(p - line);
+            char newline[512];
+            int n = snprintf(newline, sizeof(newline), "%.*s%s = %s",
+                     (int)indent, line, key, newval);
+            if (eol) newline[n++] = '\n';
+            newline[n] = '\0';
+            size_t newlen = (size_t)n;
+            size_t oldlen = linelen + (eol ? 1 : 0);
+            size_t off = (size_t)(line - buf);
+            size_t total = strlen(buf) + 1;
+            memmove(line + newlen, line + oldlen, total - off - oldlen);
+            memcpy(line, newline, newlen);
+            return;
+        }
+        line = eol ? eol + 1 : NULL;
+    }
+
+    /* Key not present: insert it right after the section header so new
+     * options (e.g. debounce_ms) can be added via the web page too. */
+    {
+        char *nl = strchr(sec, '\n');
+        if (!nl) return;
+        size_t insert_off = (size_t)(nl - buf) + 1;
+        char addline[512];
+        int n = snprintf(addline, sizeof(addline), "%s = %s\n", key, newval);
+        if (n < 0 || (size_t)n >= sizeof(addline)) return;
+        size_t total = strlen(buf) + 1;
+        memmove(buf + insert_off + n, buf + insert_off, total - insert_off);
+        memcpy(buf + insert_off, addline, (size_t)n);
+    }
+}
+
+/* ---- Replace a line in [camera_list] by camera ID ---- */
+static void replace_camera_line(char *buf, int cam_id, const char *new_ip) {
+    char *sec = find_section(buf, "camera_list");
+    if (!sec) return;
+
+    char idpat[32];
+    snprintf(idpat, sizeof(idpat), "%d ", cam_id);
+
+    char *line = sec;
+    while (line && *line) {
+        if (*line == '[' && line != sec) break;
+        char *eol = strchr(line, '\n');
+        size_t linelen = eol ? (size_t)(eol - line) : strlen(line);
+        const char *p = line;
+        while (*p == ' ' || *p == '\t') p++;
+        if (*p == '#' || *p == '\n' || *p == '\r' || !*p) {
+            line = eol ? eol + 1 : NULL;
+            continue;
+        }
+        if (strncmp(p, idpat, strlen(idpat)) == 0) {
+            const char *fields = p + strlen(idpat);
+            char user[64] = {0}, pass[64] = {0};
+            int chan = 1, port = 554, httpport = 80;
+            char dummy_ip[64];
+            sscanf(fields, "%63s %63s %63s %d %d %d",
+                   dummy_ip, user, pass, &chan, &port, &httpport);
+            char newline[512];
+            int n = snprintf(newline, sizeof(newline), "%d %s %s %s %d %d %d",
+                     cam_id, new_ip, user, pass, chan, port, httpport);
+            if (eol) newline[n++] = '\n';
+            newline[n] = '\0';
+            size_t newlen = (size_t)n;
+            size_t oldlen = linelen + (eol ? 1 : 0);
+            size_t off = (size_t)(line - buf);
+            size_t total = strlen(buf) + 1;
+            memmove(line + newlen, line + oldlen, total - off - oldlen);
+            memcpy(line, newline, newlen);
+            return;
+        }
+        line = eol ? eol + 1 : NULL;
+    }
+}
+
+/* ---- HTML page ---- */
+static const char *HTML_HEAD =
+"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n"
+"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
+"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+"<title>&#x591A;&#x8DEF;&#x89C6;&#x9891;&#x914D;&#x7F6E;</title>"
+"<style>"
+"body{font-family:system-ui,sans-serif;margin:0;padding:0;background:#1a1a2e;color:#eee}"
+".box{max-width:700px;margin:40px auto;background:#16213e;border-radius:12px;padding:30px;box-shadow:0 4px 24px rgba(0,0,0,.5)}"
+"h1{text-align:center;color:#e94560;margin-bottom:8px;font-size:1.5em}"
+"h2{color:#0f3460;background:#e94560;padding:8px 12px;border-radius:6px;margin:24px 0 12px;font-size:1em}"
+"table{width:100%;border-collapse:collapse;margin-bottom:16px}"
+"td{padding:6px 8px;border-bottom:1px solid #0f3460}"
+"td:first-child{width:80px;color:#a0a0b0;font-size:.9em}"
+"input[type=text],input[type=password]{width:100%;padding:8px;border:1px solid #0f3460;"
+"border-radius:6px;background:#1a1a2e;color:#eee;font-size:.95em;box-sizing:border-box}"
+"input:focus{outline:none;border-color:#e94560}"
+".btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;"
+"background:#e94560;color:#fff;font-size:1.1em;cursor:pointer;margin-top:20px}"
+".btn:hover{background:#c73550}"
+".ok{background:#27ae60}.ok:hover{background:#219a52}"
+".info{text-align:center;color:#a0a0b0;font-size:.85em;margin-top:16px}"
+"a{color:#e94560}"
+"</style></head><body>";
+
+/* ---- Login page ---- */
+static const char *LOGIN_PAGE =
+"<div class=\"box\">"
+"<h1>&#x591A;&#x8DEF;&#x89C6;&#x9891;&#x914D;&#x7F6E;</h1>"
+"<form method=POST action=\"/login\">"
+"<table><tr><td><label for=pwd>&#x5BC6;&#x7801;</label></td>"
+"<td><input type=password id=pwd name=pwd autofocus></td></tr></table>"
+"<button class=btn type=submit>&#x767B;&#x5F55;</button>"
+"</form></div>";
+
+/* ---- Config page ---- */
+static void send_config_page(int fd, const char *body, const char *query) {
+    char *cfg = NULL;
+    long cfglen = 0;
+
+    /* Handle POST (save) */
+    int saved = 0;
+    if (body && body[0]) {
+        cfg = read_config(&cfglen);
+        if (cfg) {
+            char val[256];
+
+            /* Camera IPs: cam0..cam7 */
+            for (int i = 0; i < MAX_CAMS; i++) {
+                char key[32];
+                snprintf(key, sizeof(key), "cam%d_ip", i);
+                if (post_value(body, key, val, sizeof(val)) == 0 && val[0]) {
+                    replace_camera_line(cfg, i, val);
+                }
+            }
+            /* PLC settings */
+            if (post_value(body, "plc_ip", val, sizeof(val)) == 0 && val[0])
+                replace_key(cfg, "plc", "ip", val);
+            if (post_value(body, "plc_port", val, sizeof(val)) == 0 && val[0])
+                replace_key(cfg, "plc", "port", val);
+            if (post_value(body, "plc_db", val, sizeof(val)) == 0 && val[0])
+                replace_key(cfg, "plc", "db", val);
+            if (post_value(body, "plc_read_len", val, sizeof(val)) == 0 && val[0])
+                replace_key(cfg, "plc", "read_len", val);
+            if (post_value(body, "plc_tsap_local", val, sizeof(val)) == 0 && val[0])
+                replace_key(cfg, "plc", "tsap_local", val);
+            if (post_value(body, "plc_tsap_remote", val, sizeof(val)) == 0 && val[0])
+                replace_key(cfg, "plc", "tsap_remote", val);
+            if (post_value(body, "plc_debounce_ms", val, sizeof(val)) == 0 && val[0])
+                replace_key(cfg, "plc", "debounce_ms", val);
+            if (post_value(body, "plc_enable", val, sizeof(val)) == 0)
+                replace_key(cfg, "plc", "enable", val);
+
+            /* Write config */
+            if (write_config(cfg, cfglen) == 0) saved = 1;
+            free(cfg);
+            cfg = NULL;
+        }
+    }
+
+    /* Read current config for display */
+    cfg = read_config(&cfglen);
+    char cam_ips[MAX_CAMS][64];
+    char cam_user[MAX_CAMS][64];
+    char cam_pass[MAX_CAMS][64];
+    int cam_port[MAX_CAMS];
+    int cam_httpport[MAX_CAMS];
+    int cam_chan[MAX_CAMS];
+    int cam_count = 0;
+    char plc_ip[64] = {0};
+    int plc_port_val = 102;
+    int plc_db = 1000;
+    int plc_read_len = 10;
+    int plc_enabled = 0;
+    char plc_tsap_local[32] = {0};
+    char plc_tsap_remote[32] = {0};
+    int plc_debounce_ms = 300;
+
+    if (cfg) {
+        /* parse [camera_list] */
+        char *sec = find_section(cfg, "camera_list");
+        if (sec) {
+            char *ns = strchr(sec + 1, '[');
+            char *line = sec;
+            while (line && (!ns || line < ns)) {
+                char *eol = strchr(line, '\n');
+                const char *p = line;
+                while (*p == ' ' || *p == '\t') p++;
+                if (*p != '#' && *p != '[' && *p != '\n' && *p != '\r' && *p) {
+                int id;
+                char ip[64] = {0}, user[64] = {0}, pass[64] = {0};
+                int chan = 1, port = 554, httpport = 80;
+                if (sscanf(p, "%d %63s %63s %63s %d %d %d",
+                           &id, ip, user, pass, &chan, &port, &httpport) >= 4) {
+                    if (id >= 0 && id < MAX_CAMS) {
+                        strncpy(cam_ips[id], ip, 63);
+                        strncpy(cam_user[id], user, 63);
+                        strncpy(cam_pass[id], pass, 63);
+                        cam_port[id] = port;
+                        cam_httpport[id] = httpport;
+                        cam_chan[id] = chan;
+                        if (id + 1 > cam_count) cam_count = id + 1;
+                    }
+                }
+                }
+                line = eol ? eol + 1 : NULL;
+            }
+        }
+        /* parse [plc] */
+        sec = find_section(cfg, "plc");
+        if (sec) {
+            char *ns = strchr(sec + 1, '[');
+            char *line = sec;
+            while (line && (!ns || line < ns)) {
+                char key[64], val[256] = {0};
+                const char *p = line;
+                while (*p == ' ' || *p == '\t') p++;
+                if (*p != '#' && *p != '[' && *p != '\n' && *p != '\r' && *p &&
+                    sscanf(p, "%63s = %255[^\n\r]", key, val) >= 2) {
+                    /* trim leading spaces in val */
+                    const char *v = val;
+                    while (*v == ' ' || *v == '\t') v++;
+                    if (strcmp(key, "ip") == 0) strncpy(plc_ip, v, 63);
+                    else if (strcmp(key, "port") == 0) plc_port_val = atoi(v);
+                    else if (strcmp(key, "db") == 0) plc_db = atoi(v);
+                    else if (strcmp(key, "read_len") == 0) plc_read_len = atoi(v);
+                    else if (strcmp(key, "enable") == 0) plc_enabled = atoi(v);
+                    else if (strcmp(key, "tsap_local") == 0) strncpy(plc_tsap_local, v, 31);
+                    else if (strcmp(key, "tsap_remote") == 0) strncpy(plc_tsap_remote, v, 31);
+                    else if (strcmp(key, "debounce_ms") == 0) plc_debounce_ms = atoi(v);
+                }
+                char *eol = strchr(line, '\n');
+                line = eol ? eol + 1 : NULL;
+            }
+        }
+        free(cfg);
+    }
+
+    /* Build response */
+    char resp[16384];
+    int off = 0;
+    off += snprintf(resp + off, sizeof(resp) - off, "%s", HTML_HEAD);
+
+    off += snprintf(resp + off, sizeof(resp) - off,
+        "<div class=\"box\">"
+        "<h1>&#x591A;&#x8DEF;&#x89C6;&#x9891;&#x914D;&#x7F6E;</h1>"
+        "<p style=\"text-align:center\"><a href=\"/net\">&#x672C;&#x673A;&#x7F51;&#x7EDC; IP &#x8BBE;&#x7F6E;</a></p>");
+
+    if (saved) {
+        off += snprintf(resp + off, sizeof(resp) - off,
+            "<p style=\"background:#27ae60;padding:10px;border-radius:6px;text-align:center\">"
+            "&#x914D;&#x7F6E;&#x5DF2;&#x4FDD;&#x5B58;&#xFF01;&#x670D;&#x52A1;&#x5C06;&#x5728; 2 &#x79D2;&#x540E;&#x91CD;&#x542F;...</p>");
+    }
+
+    off += snprintf(resp + off, sizeof(resp) - off,
+        "<form method=POST action=\"/config\">"
+        "<h2>&#x6444;&#x50CF;&#x673A;&#x8BBE;&#x7F6E;</h2>"
+        "<table>"
+        "<tr><td>&#x7F16;&#x53F7;</td><td>IP &#x5730;&#x5740;</td></tr>");
+
+    for (int i = 0; i < cam_count && i < MAX_CAMS; i++) {
+        char key_ip[32], key_port[32], key_http[32], key_user[32], key_pass[32], key_chan[32];
+        snprintf(key_ip, sizeof(key_ip), "cam%d_ip", i);
+        off += snprintf(resp + off, sizeof(resp) - off,
+            "<tr><td>%d</td><td>"
+            "<input type=text name=%s value=\"%s\" placeholder=\"IP &#x5730;&#x5740;\">"
+            "</td></tr>",
+            i, key_ip, cam_ips[i]);
+    }
+
+    off += snprintf(resp + off, sizeof(resp) - off,
+        "</table>"
+        "<h2>PLC &#x8BBE;&#x7F6E;</h2>"
+        "<table>"
+        "<tr><td>&#x542F;&#x7528;</td><td><input type=text name=plc_enable value=\"%d\" placeholder=\"1=&#x5F00; 0=&#x5173;\"></td></tr>"
+        "<tr><td>IP &#x5730;&#x5740;</td><td><input type=text name=plc_ip value=\"%s\"></td></tr>"
+        "<tr><td>&#x7AEF;&#x53E3;</td><td><input type=text name=plc_port value=\"%d\"></td></tr>"
+"<tr><td>&#x6570;&#x636E;&#x5757;</td><td><input type=text name=plc_db value=\"%d\"></td></tr>"
+         "<tr><td>&#x8BFB;&#x53D6;&#x957F;&#x5EA6;</td><td><input type=text name=plc_read_len value=\"%d\"></td></tr>"
+         "<tr><td>TSAP &#x672C;&#x5730;</td><td><input type=text name=plc_tsap_local value=\"%s\" placeholder=\"0x0100\"></td></tr>"
+         "<tr><td>TSAP &#x8FDC;&#x7AEF;</td><td><input type=text name=plc_tsap_remote value=\"%s\" placeholder=\"0x0101\"></td></tr>"
+         "<tr><td>&#x53BB;&#x6296; (ms)</td><td><input type=text name=plc_debounce_ms value=\"%d\" placeholder=\"300 (0=&#x5173;&#x95ED;)\"></td></tr>"
+         "</table>"
+        "<button class=btn type=submit>&#x4FDD;&#x5B58;&#x5E76;&#x91CD;&#x542F;</button>"
+        "</form>"
+        "<p class=info>&#x4FEE;&#x6539;&#x5185;&#x5BB9;&#x9700;&#x91CD;&#x542F;&#x670D;&#x52A1;&#x540E;&#x751F;&#x6548;&#x3002;</p>"
+        "</div></body></html>",
+        plc_enabled, plc_ip, plc_port_val, plc_db, plc_read_len,
+        plc_tsap_local, plc_tsap_remote, plc_debounce_ms);
+
+    send_bytes(fd, resp, off);
+}
+
+/* ---- Local network page: show all IPs, change device IP ---- */
+static void send_net_page(int fd, const char *body) {
+    char resp[16384];
+    int off = 0;
+    off += snprintf(resp + off, sizeof(resp) - off, "%s", HTML_HEAD);
+
+    struct net_if nets[16];
+    int nn = collect_nets(nets, 16);
+
+    const char *applied_if = NULL, *applied_ip = NULL;
+    int applied = 0;
+    char msg[256] = "";
+
+    if (body && body[0]) {
+        char iface[32] = {0}, ip[64] = {0}, mask[64] = {0}, gw[64] = {0}, mode[8] = {0};
+        post_value(body, "iface", iface, sizeof(iface));
+        post_value(body, "ip", ip, sizeof(ip));
+        post_value(body, "mask", mask, sizeof(mask));
+        post_value(body, "gw", gw, sizeof(gw));
+        post_value(body, "mode", mode, sizeof(mode));
+        int add_mode = (strcmp(mode, "add") == 0);
+
+        if (!iface[0] || !iface_exists(iface)) {
+            snprintf(msg, sizeof(msg), "\u9519\u8BEF: \u7F51\u5361 %s \u4E0D\u5B58\u5728\u6216\u672A\u9009\u62E9",
+                     iface[0] ? iface : "?");
+        } else if (!is_ipv4(ip)) {
+            snprintf(msg, sizeof(msg), "\u9519\u8BEF: IP \u5730\u5740 %s \u4E0D\u5408\u6CD5", ip);
+        } else if (!mask[0] || !is_ipv4(mask)) {
+            snprintf(msg, sizeof(msg), "\u9519\u8BEF: \u5B50\u7F51\u63A9\u7801 %s \u4E0D\u5408\u6CD5", mask[0] ? mask : "?");
+        } else if (!add_mode && gw[0] && !is_ipv4(gw)) {
+            snprintf(msg, sizeof(msg), "\u9519\u8BEF: \u7F51\u5173 %s \u4E0D\u5408\u6CD5", gw);
+        } else if (!is_safe_net_arg(iface) || !is_safe_net_arg(ip) ||
+                   !is_safe_net_arg(mask) || (gw[0] && !is_safe_net_arg(gw))) {
+            snprintf(msg, sizeof(msg), "\u9519\u8BEF: \u53C2\u6570\u5305\u542B\u4E0D\u5141\u8BB8\u5B57\u7B26");
+        } else {
+            int pfx = netmask_to_prefix(mask);
+            char cmd[768];
+            char conname[128] = "";
+            int use_nm = nm_running() && nm_managed(iface) &&
+                         nm_conn_name(iface, conname, sizeof(conname)) &&
+                         nm_safe_name(conname);
+            if (use_nm && add_mode) {
+                /* keep every existing address, add the segment via NetworkManager */
+                snprintf(cmd, sizeof(cmd),
+                         "nmcli con mod '%s' +ipv4.addresses %s/%d 2>/dev/null; "
+                         "nmcli dev reapply %s 2>/dev/null",
+                         conname, ip, pfx, iface);
+                system(cmd);
+                applied = 1;
+                applied_if = iface;
+                applied_ip = ip;
+                nn = collect_nets(nets, 16);
+                snprintf(msg, sizeof(msg),
+                         "\u5DF2\u6DFB\u52A0\u65B0\u7F51\u6BB5: %s + %s/%d\u3010\u4FDD\u7559\u73B0\u6709 "
+                         "IP\u3011\uFF0C\u5DF2\u901A\u8FC7 NetworkManager \u6301\u4E45\u5316 "
+                         "(\u8FDE\u63A5 \"%s\")\u3002",
+                         iface, ip, pfx, conname);
+            } else if (use_nm) {
+                if (gw[0] && is_ipv4(gw))
+                    snprintf(cmd, sizeof(cmd),
+                             "nmcli con mod '%s' ipv4.method manual ipv4.addresses %s/%d "
+                             "ipv4.gateway %s 2>/dev/null; nmcli con up '%s' 2>/dev/null",
+                             conname, ip, pfx, gw, conname);
+                else
+                    snprintf(cmd, sizeof(cmd),
+                             "nmcli con mod '%s' ipv4.method manual ipv4.addresses %s/%d "
+                             "2>/dev/null; nmcli con up '%s' 2>/dev/null",
+                             conname, ip, pfx, conname);
+                system(cmd);
+                applied = 1;
+                applied_if = iface;
+                applied_ip = ip;
+                nn = collect_nets(nets, 16);
+                snprintf(msg, sizeof(msg),
+                         "\u5DF2\u66FF\u6362: %s \u2192 %s/%s (CIDR /%d); \u5DF2\u901A\u8FC7 "
+                         "NetworkManager \u6301\u4E45\u5316 (\u8FDE\u63A5 \"%s\")\u3002"
+                         "\u540E\u7EED\u8BBF\u95EE\u8BF7\u7528\u65B0 \u5730\u5740\u3002",
+                         iface, ip, mask, pfx, conname);
+            } else if (add_mode) {
+                /* keep every existing address, just add the new segment */
+                snprintf(cmd, sizeof(cmd),
+                         "ip link set %s up 2>/dev/null; ip addr add %s/%d dev %s 2>/dev/null",
+                         iface, ip, pfx, iface);
+                system(cmd);
+                char cidr[96];
+                snprintf(cidr, sizeof(cidr), "%s/%d", ip, pfx);
+                persist_add_addr(iface, ip, mask, cidr);
+                applied = 1;
+                applied_if = iface;
+                applied_ip = ip;
+                nn = collect_nets(nets, 16);
+                snprintf(msg, sizeof(msg),
+                         "\u5DF2\u6DFB\u52A0\u65B0\u7F51\u6BB5: %s + %s/%d\u3010\u4FDD\u7559\u73B0\u6709 "
+                         "IP\u3011\uFF0C\u5DF2\u5199\u5165 /etc/network/interfaces\u3002",
+                         iface, ip, pfx);
+            } else {
+                snprintf(cmd, sizeof(cmd),
+                         "ip addr flush dev %s 2>/dev/null; ip link set %s up 2>/dev/null; "
+                         "ip addr add %s/%d dev %s 2>/dev/null",
+                         iface, iface, ip, pfx, iface);
+                system(cmd);
+                if (gw[0] && is_ipv4(gw)) {
+                    snprintf(cmd, sizeof(cmd), "ip route replace default via %s 2>/dev/null", gw);
+                    system(cmd);
+                }
+                persist_interfaces(iface, ip, mask, gw[0] ? gw : "");
+                applied = 1;
+                applied_if = iface;
+                applied_ip = ip;
+                nn = collect_nets(nets, 16);
+                snprintf(msg, sizeof(msg),
+                         "\u5DF2\u66FF\u6362: %s \u2192 %s/%s (CIDR /%d); \u5DF2\u5199\u5165 "
+                         "/etc/network/interfaces \u3002\u540E\u7EED\u8BBF\u95EE\u8BF7\u7528\u65B0 "
+                         "\u5730\u5740\u3002", iface, ip, mask, pfx);
+            }
+        }
+    }
+
+    off += snprintf(resp + off, sizeof(resp) - off,
+        "<div class=\"box\">"
+        "<h1>&#x591A;&#x8DEF;&#x89C6;&#x9891;&#x914D;&#x7F6E; - &#x672C;&#x673A;&#x7F51;&#x7EDC;</h1>"
+        "<p style=\"text-align:center\"><a href=\"/config\">&larr; &#x8FD4;&#x56DE;&#x914D;&#x7F6E;&#x9875;&#x9762;</a></p>");
+
+    if (applied) {
+        off += snprintf(resp + off, sizeof(resp) - off,
+            "<p style=\"background:#27ae60;padding:10px;border-radius:6px;text-align:center\">%s</p>",
+            msg);
+    } else if (msg[0]) {
+        off += snprintf(resp + off, sizeof(resp) - off,
+            "<p style=\"background:#c0392b;padding:10px;border-radius:6px;text-align:center\">%s</p>",
+            msg);
+    }
+
+    off += snprintf(resp + off, sizeof(resp) - off,
+        "<h2>&#x5F53;&#x524D;&#x7F51;&#x5361; IP (&#x591A;&#x7F51;&#x6BB5;&#x5168;&#x90E8;&#x5217;&#x51FA;)</h2>"
+        "<table><tr><td>&#x7F51;&#x5361;</td><td>IP &#x5730;&#x5740;</td><td>&#x5B50;&#x7F51;&#x63A9;&#x7801;</td></tr>");
+    for (int i = 0; i < nn; i++) {
+        off += snprintf(resp + off, sizeof(resp) - off,
+            "<tr><td>%s</td><td>%s</td><td>%s</td></tr>",
+            nets[i].iface, nets[i].ip, nets[i].mask);
+    }
+    off += snprintf(resp + off, sizeof(resp) - off, "</table>");
+
+    /* default values: first interface with an address, current ip/mask */
+    char def_if[IFNAMSIZ] = "", def_ip[64] = "", def_mask[64] = "255.255.255.0";
+    if (nn > 0) {
+        snprintf(def_if, sizeof(def_if), "%s", nets[0].iface);
+        snprintf(def_ip, sizeof(def_ip), "%s", nets[0].ip);
+        if (nets[0].mask[0] && strcmp(nets[0].mask, "0.0.0.0") != 0)
+            snprintf(def_mask, sizeof(def_mask), "%s", nets[0].mask);
+    }
+
+    /* all interfaces (incl. ones with no IPv4 yet) so a NIC can be seeded */
+    char ifaces[16][IFNAMSIZ];
+    int nif = collect_ifaces(ifaces, 16);
+    if (nif > 0 && !def_if[0]) snprintf(def_if, sizeof(def_if), "%s", ifaces[0]);
+
+    off += snprintf(resp + off, sizeof(resp) - off,
+        "<h2>&#x8BBE;&#x7F6E;&#x672C;&#x673A; IP</h2>"
+        "<form method=POST action=\"/net\">"
+        "<table>"
+        "<tr><td>&#x7F51;&#x5361;</td><td><select name=iface>");
+    for (int i = 0; i < nif; i++) {
+        const char *curip = "\u65E0 IP";
+        char has[5] = "";
+        for (int j = 0; j < nn; j++) {
+            if (strcmp(nets[j].iface, ifaces[i]) == 0) { curip = nets[j].ip; break; }
+        }
+        snprintf(has, sizeof(has), "%s", strcmp(ifaces[i], def_if) == 0 ? " selected" : "");
+        off += snprintf(resp + off, sizeof(resp) - off,
+            "<option value=\"%s\"%s>%s (%s)</option>", ifaces[i], has, ifaces[i], curip);
+    }
+    off += snprintf(resp + off, sizeof(resp) - off,
+        "</select></td></tr>"
+        "<tr><td>&#x65B0; IP</td><td><input type=text name=ip value=\"%s\"></td></tr>"
+        "<tr><td>&#x5B50;&#x7F51;&#x63A9;&#x7801;</td><td><input type=text name=mask value=\"%s\"></td></tr>"
+        "<tr><td>&#x7F51;&#x5173;</td><td><input type=text name=gw placeholder=\"&#x5982; 192.168.1.1\"></td></tr>"
+        "</table>"
+        "<button class=btn type=submit name=mode value=replace>&#x66FF;&#x6362; / &#x4FEE;&#x6539; IP (&#x53BB;&#x6389;&#x5176;&#x4ED6; IP)</button>"
+        "<button class=btn type=submit name=mode value=add style=\"background:#27ae60;margin-top:8px\">"
+        "&#x4FDD;&#x7559;&#x73B0;&#x6709; IP\uFF0C;&#x6DFB;&#x52A0;&#x65B0;&#x7F51;&#x6BB5; IP (&#x53EF;&#x591A;&#x7F51;&#x6BB5;)</button>"
+        "</form>"
+        "<p class=info>&#x8B66;&#x544A;: 1) [&#x66FF;&#x6362;] \u4F1A;&#x5148;&#x6E05;&#x9664;&#x8BE5;&#x7F51;&#x5361;&#x6240;&#x6709; IP \u518D;&#x6DFB;&#x52A0;&#xFF0C;&#x4FEE;&#x6539;&#x5F53;&#x524D;&#x8BBF;&#x95EE;&#x7684; IP \u4F1A;&#x65AD;&#x5F00;&#x8FDE;&#x63A5;&#x3002; "
+        "2) [&#x6DFB;&#x52A0;] \u4FDD;&#x7559;&#x73B0;&#x6709; IP\uFF0C;&#x53EA;&#x65B0;&#x589E;&#x4E00;&#x4E2A;&#x7F51;&#x6BB5; IP\uFF0C;&#x4E0D;&#x6539;&#x7F51;&#x5173;&#x3002; "
+        "3) &#x5DF2;&#x5199;&#x5165; /etc/network/interfaces \u6301;&#x4E45;&#x5316;&#xFF0C;&#x91CD;&#x542F;&#x540E;&#x4ECD;&#x751F;&#x6548;&#x3002;</p>"
+        "</div></body></html>", def_ip, def_mask);
+
+    send_bytes(fd, resp, off);
+}
+
+/* ---- HTTP request handler ---- */
+static void handle_client(int cfd) {
+    /* Bound each connection so a slow/half-open client cannot stall the
+     * other clients (read timeout on the socket). */
+    struct timeval rcv_tv = { .tv_sec = 8, .tv_usec = 0 };
+    setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &rcv_tv, sizeof(rcv_tv));
+    char method[8], path[256], ver[16];
+    char line[1024];
+    if (recv_line(cfd, line, sizeof(line)) < 0) goto done;
+    if (sscanf(line, "%7s %255s %15s", method, path, ver) != 3) goto done;
+
+    /* Read headers (and detect Cookie) */
+    char cookie[256] = {0};
+    char content_len_hdr[32] = {0};
+    while (1) {
+        if (recv_line(cfd, line, sizeof(line)) < 0) break;
+        if (line[0] == '\n' || line[0] == '\r') break;
+        if (strncasecmp(line, "Cookie:", 7) == 0) {
+            char *p = line + 7;
+            while (*p == ' ') p++;
+            strncpy(cookie, p, sizeof(cookie) - 1);
+            cookie[strcspn(cookie, "\r\n")] = '\0';
+        }
+        if (strncasecmp(line, "Content-Length:", 15) == 0) {
+            char *p = line + 15;
+            while (*p == ' ') p++;
+            strncpy(content_len_hdr, p, sizeof(content_len_hdr) - 1);
+            content_len_hdr[strcspn(content_len_hdr, "\r\n")] = '\0';
+        }
+    }
+
+    /* Auth check */
+    char sesshash[64];
+    snprintf(sesshash, sizeof(sesshash), "sess=%u", simple_hash(g_password));
+    int authed = (strstr(cookie, sesshash) != NULL);
+
+    if (strcmp(method, "POST") == 0 && strcmp(path, "/login") == 0) {
+        /* Read POST body */
+        int clen = atoi(content_len_hdr);
+        char body[512] = {0};
+        if (clen > 0 && clen < (int)sizeof(body)) {
+            int got = 0;
+            while (got < clen) {
+                ssize_t r = read(cfd, body + got, clen - got);
+                if (r <= 0) break;
+                got += r;
+            }
+        }
+        char pwd[64] = {0};
+        post_value(body, "pwd", pwd, sizeof(pwd));
+        if (strcmp(pwd, g_password) == 0) {
+            char hdr[256];
+            snprintf(hdr, sizeof(hdr),
+                "HTTP/1.1 302 Found\r\nSet-Cookie: sess=%u; Path=/\r\n"
+                "Location: /config\r\nConnection: close\r\n\r\n",
+                simple_hash(g_password));
+            send_str(cfd, hdr);
+        } else {
+            send_str(cfd, "HTTP/1.1 302 Found\r\nLocation: /\r\nConnection: close\r\n\r\n");
+        }
+        goto done;
+    }
+
+    if (!authed) {
+        send_str(cfd, HTML_HEAD);
+        send_str(cfd, LOGIN_PAGE);
+        send_str(cfd, "</body></html>");
+        goto done;
+    }
+
+    /* Authed requests */
+    if (strcmp(path, "/") == 0 && strcmp(method, "GET") == 0) {
+        send_str(cfd, "HTTP/1.1 302 Found\r\nLocation: /config\r\nConnection: close\r\n\r\n");
+    } else if (strcmp(path, "/config") == 0 && strcmp(method, "GET") == 0) {
+        send_config_page(cfd, NULL, NULL);
+    } else if (strcmp(path, "/config") == 0 && strcmp(method, "POST") == 0) {
+        int clen = atoi(content_len_hdr);
+        char body[4096] = {0};
+        if (clen > 0 && clen < (int)sizeof(body)) {
+            int got = 0;
+            while (got < clen) {
+                ssize_t r = read(cfd, body + got, clen - got);
+                if (r <= 0) break;
+                got += r;
+            }
+        }
+        send_config_page(cfd, body, NULL);
+
+        /* Restart service after response is sent */
+        usleep(500000);
+        system("systemctl restart rtsp_multi_view &");
+    } else if (strcmp(path, "/net") == 0 && strcmp(method, "GET") == 0) {
+        send_net_page(cfd, NULL);
+    } else if (strcmp(path, "/net") == 0 && strcmp(method, "POST") == 0) {
+        int clen = atoi(content_len_hdr);
+        char body[1024] = {0};
+        if (clen > 0 && clen < (int)sizeof(body)) {
+            int got = 0;
+            while (got < clen) {
+                ssize_t r = read(cfd, body + got, clen - got);
+                if (r <= 0) break;
+                got += r;
+            }
+        }
+        send_net_page(cfd, body);
+    } else if (strcmp(path, "/restart") == 0) {
+        send_str(cfd, HTML_HEAD);
+        send_str(cfd, "<div class=\"box\"><h1>&#x6B63;&#x5728;&#x91CD;&#x542F;...</h1>"
+                       "<p class=info>&#x670D;&#x52A1;&#x6B63;&#x5728;&#x91CD;&#x542F;&#xFF0C;&#x8BF7;&#x7A0D;&#x5019;&#x3002;</p></div></body></html>");
+        usleep(500000);
+        system("systemctl restart rtsp_multi_view &");
+    } else {
+        send_str(cfd, HTML_HEAD);
+        send_str(cfd, "<div class=\"box\"><h1>&#x9875;&#x9762;&#x4E0D;&#x5B58;&#x5728;</h1>"
+                       "<p class=info>&#x8BF7;&#x68C0;&#x67E5;&#x8BBF;&#x95EE;&#x5730;&#x5740;&#x662F;&#x5426;&#x6B63;&#x786E;&#x3002;</p></div></body></html>");
+    }
+
+done:
+    close(cfd);
+}
+
+/* ---- One thread per connection: a slow client cannot stall the others ---- */
+static void *client_thread(void *arg) {
+    int cfd = (int)(long)arg;
+    handle_client(cfd);
+    return NULL;
+}
+
+/* ---- Server thread ---- */
+static void *server_thread(void *arg) {
+    (void)arg;
+    int lfd = socket(AF_INET, SOCK_STREAM, 0);
+    if (lfd < 0) return NULL;
+
+    int one = 1;
+    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
+
+    struct sockaddr_in sa = {0};
+    sa.sin_family = AF_INET;
+    sa.sin_addr.s_addr = INADDR_ANY;
+    sa.sin_port = htons(g_port);
+
+    if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
+        fprintf(stderr, "web: bind port %d failed: %s\n", g_port, strerror(errno));
+        close(lfd);
+        return NULL;
+    }
+    listen(lfd, 8);
+    fprintf(stderr, "web: listening on port %d\n", g_port);
+
+    while (1) {
+        struct sockaddr_in cli;
+        socklen_t clilen = sizeof(cli);
+        int cfd = accept(lfd, (struct sockaddr *)&cli, &clilen);
+        if (cfd < 0) continue;
+        pthread_t tid;
+        if (pthread_create(&tid, NULL, client_thread, (void *)(long)cfd) != 0)
+            close(cfd);
+        else
+            pthread_detach(tid);
+    }
+
+    close(lfd);
+    return NULL;
+}
+
+int web_server_start(const char *config_path, int port, const char *password) {
+    g_config_path = config_path;
+    g_port = port > 0 ? port : WEB_SERVER_PORT_DEFAULT;
+    g_password = password && password[0] ? password : WEB_SERVER_PASS_DEFAULT;
+    load_iface_whitelist();
+
+    pthread_t thr;
+    if (pthread_create(&thr, NULL, server_thread, NULL) != 0) return -1;
+    pthread_detach(thr);
+    return 0;
+}

+ 19 - 0
web_server.h

@@ -0,0 +1,19 @@
+#ifndef __WEB_SERVER_H__
+#define __WEB_SERVER_H__
+
+#include <stddef.h>
+
+/* Lightweight embedded web server for runtime configuration.
+ * Listens on 0.0.0.0:<port>, serves a single-page config UI and a tiny
+ * JSON API. Zero cost while no client is connected (blocking accept). */
+
+#define WEB_SERVER_PORT_DEFAULT 8080
+#define WEB_SERVER_PASS_DEFAULT "3730"
+
+/* Start the web server on a detached background thread.
+ * config_path is the channels.conf this process was started with; edits are
+ * applied by rewriting the file and restarting the service unit.
+ * Returns 0 on success (thread started), -1 on bind/creation error. */
+int web_server_start(const char *config_path, int port, const char *password);
+
+#endif