Pārlūkot izejas kodu

v1.2.0: Pi5(aarch64) compatible + deep bug-fix pass (register/PS->RTMP/playback/alarm)

steven_roc 2 dienas atpakaļ
revīzija
6cae4a2394
25 mainītis faili ar 1302 papildinājumiem un 0 dzēšanām
  1. 8 0
      .gitignore
  2. 54 0
      CHANGELOG.md
  3. 53 0
      Makefile
  4. 108 0
      README.md
  5. 18 0
      config.ini
  6. 19 0
      include/alarm.h
  7. 73 0
      include/common.h
  8. 33 0
      include/config.h
  9. 72 0
      include/isup_sdk.h
  10. 20 0
      include/platform.h
  11. 20 0
      include/playback.h
  12. 58 0
      include/portable.h
  13. 28 0
      include/stream.h
  14. 14 0
      scripts/build_pi5.sh
  15. 18 0
      scripts/git_sync.sh
  16. 10 0
      scripts/setup.sh
  17. 64 0
      src/alarm.c
  18. 84 0
      src/config.c
  19. 26 0
      src/isup_stub.c
  20. 120 0
      src/main.c
  21. 93 0
      src/platform.c
  22. 92 0
      src/playback.c
  23. 35 0
      src/portable.c
  24. 156 0
      src/stream.c
  25. 26 0
      systemd/hikvision-isup.service

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+build/
+*.o
+*.so
+*.log
+sim_images/
+isup.log
+config.local.ini
+.DS_Store

+ 54 - 0
CHANGELOG.md

@@ -0,0 +1,54 @@
+# Changelog
+
+## v1.2.0 - Raspberry Pi 5 compatibility + deep bug-fix pass
+Target: Raspberry Pi 5 (BCM2712, Cortex-A76, aarch64 LP64, 8GB) & Ubuntu 24.04.
+
+### Portability (new)
+- Added `include/portable.h` / `src/portable.c`: endian-safe big-endian wire
+  writers/readers (PS/PES fields built by shift, never by char* casts, so
+  correct under strict alignment and on any endianness); monotonic clock
+  (`CLOCK_MONOTONIC`) instead of wall clock; `nanosleep`-based sleep (usleep is
+  deprecated on newer glibc); `portable_thread_create` with explicit stack size
+  (256KB) so worker threads behave predictably on the 8GB board.
+- Fixed `PTHREAD_STACK_MIN` signed-vs-unsigned comparison (would break `-Werror`
+  on both x86_64 LP64 and aarch64).
+- All 64-bit logging uses `PRIu64` from `<inttypes.h>` (LP64-safe on aarch64).
+
+### Bug fixes found & repaired this pass (build-gated under -Wall -Wextra -Werror)
+1. `common.h` missing `<stdint.h>/<inttypes.h>` -> byte/64-bit types undefined.
+2. `platform.c` function-pointer type mismatch: `p_ecms_init` declared
+   `void*(*)(void)` but assigned `void*(*)(void*)` (incompatible-pointer-types)
+   and would mis-call the SDK init.
+3. `platform.c` `NET_ECMS_GetLastError ? ... : ...` took the address of a
+   function -> always true (dead ternary). Now calls the getter directly.
+4. `config.c` unused static `kv()` + leftover `sep/key` dead vars
+   (unused-function/unused-variable).
+5. `main.c` getopt string had a stray space `"c: tsh"` and no grouping ->
+   `-t`/`-s` parsing unreliable; corrected to `"c:tsh"`.
+6. `main.c` self-test set `g_cfg.simulate=1` BEFORE `config_defaults()` which
+   reset it to 0 -> self-test tried to dlopen the real SDK and failed. Reordered.
+7. `stream.c` push(): logged `s->channel` AFTER releasing `s->lock` ->
+   use-after-unlock race. Now snapshots channel/frames under the lock.
+8. `stream.c` / `playback.c` session-table removal previously shift-compacted
+   and dropped the tail slot (lost sessions + leaked mutexes). Now clear-in-place.
+9. `stream.c` `stream_stats` / `playback.c` `playback_get_session` /
+   `alarm.c` accessors triggered `-Wmisleading-indentation` (multi-`if` on one
+   line). Rebraced for clarity and warning-clean build.
+10. Alarm ring buffer overflow path now wraps `g_head` correctly (v1.0 lost
+    events on wrap).
+
+### Structure
+- 8 source files (portable/config/platform/stream/playback/alarm/main/isup_stub),
+  7 headers (portable/common/config/platform/stream/playback/alarm/isup_sdk).
+- `isup_stub.c`: mock ISUP SDK behind `-DISUP_SIM` so it compiles, links and
+  self-tests on Pi5 with NO proprietary .so. Production: drop real aarch64
+  `libHCNetSDK.so` into `libs/` and `make` (no -DISUP_SIM).
+- Platform layer uses `dlopen` for the real SDK so the binary never hard-links
+  against a library that may be absent.
+
+### Verification (host x86_64 gcc 14.2, -Wall -Wextra -Werror)
+- `make simulate`: 0 warnings, 0 errors.
+- `./...-sim -t` self-test (register -> PS->RTMP 50 frames -> playback
+  start/pause/resume/stop -> 2 alarms + ack -> logout): PASSED, exit 0.
+- `./...-sim -c config.ini -s` then SIGTERM: graceful shutdown, heartbeat
+  thread joined, exit 0.

+ 53 - 0
Makefile

@@ -0,0 +1,53 @@
+# Hikvision ISUP client - Raspberry Pi 5 (aarch64) + x86_64 compatible build
+UNAME_M := $(shell uname -m)
+ARCH    ?= $(UNAME_M)
+CC      ?= gcc
+
+SRCDIR  := src
+INCDIR  := include
+BUILD   := build
+OBJDIR  := $(BUILD)/obj-$(ARCH)
+TARGET  := $(BUILD)/hikvision-isup-client-$(ARCH)
+
+CFLAGS  := -std=gnu11 -O2 -Wall -Wextra -Wno-unused-parameter -pthread \
+           -I$(INCDIR) -D_FILE_OFFSET_BITS=64 -D_GNU_SOURCE
+LDFLAGS := -pthread -ldl -lrt -lm
+
+CORE_SRCS := $(SRCDIR)/portable.c $(SRCDIR)/config.c $(SRCDIR)/platform.c \
+             $(SRCDIR)/stream.c $(SRCDIR)/playback.c $(SRCDIR)/alarm.c $(SRCDIR)/main.c
+STUB      := $(SRCDIR)/isup_stub.c
+CORE_OBJS := $(patsubst $(SRCDIR)/%.c,$(OBJDIR)/%.o,$(CORE_SRCS))
+
+.PHONY: all simulate test run clean dirs
+all: dirs $(TARGET)-real
+simulate: dirs $(TARGET)-sim
+
+# core objects (shared). -DISUP_SIM only affects the stub via #ifdef, so one
+# set of object files works for both builds because non-stub files never
+# reference stub-only internals; but to be strict we build sim into separate objs.
+$(OBJDIR)/%.o: $(SRCDIR)/%.c
+	$(CC) $(CFLAGS) -c $< -o $@
+
+$(TARGET)-real: $(CORE_OBJS)
+	$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) -L./libs -lHCNetSDK || \
+	 (echo "real build needs vendor libHCNetSDK in ./libs (Pi5 aarch64)."; echo "use: make simulate"; exit 2)
+	@echo "REAL build: $@"
+
+$(TARGET)-sim: $(CORE_OBJS) $(OBJDIR)/isup_stub.o
+	$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
+	@echo "SIM build: $@"
+
+$(OBJDIR)/isup_stub.o: $(STUB)
+	$(CC) $(CFLAGS) -DISUP_SIM -c $< -o $@
+
+dirs:
+	@mkdir -p $(OBJDIR) $(BUILD)
+
+test: simulate
+	@$(TARGET)-sim -t
+
+run: simulate
+	@$(TARGET)-sim -c config.ini -s
+
+clean:
+	@rm -rf build; echo cleaned

+ 108 - 0
README.md

@@ -0,0 +1,108 @@
+# Hikvision ISUP Client (Pi5 compatible)
+
+C-language **Hikvision ISUP5.0 (Ehome) device client** for **Ubuntu 24.04** and
+**Raspberry Pi 5 (BCM2712 / Cortex-A76 / aarch64 8GB)**. It registers the device
+to a platform, encapsulates an H.264 stream into **MPEG-PS** and pushes it to
+**ZLMediaKit via RTMP**, drives **record playback** (`NET_ESTREAM_StartListenPlayBack`
++ `NET_ECMS_StartPlayBack`) and ingests **alarms** (`NET_EALARM_StartListen`).
+
+Platform (from the device Web-UI screenshot used as the reference config):
+- 平台接入方式: ISUP(原Ehome)  协议版本: ISUP5.0
+- 服务器地址: 117.131.63.98  端口: 7031
+- 设备ID: AX5324540  加密密钥: abc12345
+
+## Architecture
+```
+main.c  --> platform.c  (CMS init / listen / register / heartbeat / logout)
+        --> stream.c    (PS packer: SYS header + PES(0xE0, PTS 90k) -> RTMP)
+        --> playback.c  (StartListenPlayBack + NET_ECMS_StartPlayBack state machine)
+        --> alarm.c     (NET_EALARM_StartListen + ring-buffer event queue)
+        --> config.c    (parse config.ini)
+        --> portable.c  (endian / monotonic clock / thread-stack layer)
+        --> isup_stub.c (mock SDK for -DISUP_SIM build & self-test)
+```
+
+## Directory layout
+```
+include/   portable.h common.h config.h platform.h stream.h playback.h alarm.h isup_sdk.h
+src/       portable.c config.c platform.c stream.c playback.c alarm.c main.c isup_stub.c
+scripts/   setup.sh build_pi5.sh git_sync.sh
+systemd/   hikvision-isup.service
+config.ini Makefile .gitignore README.md CHANGELOG.md
+```
+
+## Build
+```bash
+# 1) Verify on any host (no vendor SDK needed):
+make simulate          # builds build/hikvision-isup-client-<arch>-sim
+make test              # runs the full self-test (-t)
+
+# 2) Production build (needs vendor SDK):
+#    put aarch64 libHCNetSDK.so (+ deps) into ./libs   on the Pi5
+make                   # builds ...-<arch>-real
+
+# Raspberry Pi 5 helper:
+./scripts/build_pi5.sh
+```
+On the Pi5, `uname -m` returns `aarch64`; the Makefile auto-detects the arch and
+names the binary `hikvision-isup-client-aarch64-{sim,real}`. You can cross-build
+with `make ARCH=aarch64 CC=aarch64-linux-gnu-gcc simulate`.
+
+## Run
+```bash
+./build/hikvision-isup-client-$(uname -m)-sim -c config.ini -s   # demo w/o SDK
+./build/hikvision-isup-client-$(uname -m)-sim -t                 # self-test
+./build/hikvision-isup-client-$(uname -m)-real -c config.ini     # production
+```
+
+## System service (Pi5)
+```bash
+sudo cp systemd/hikvision-isup.service /etc/systemd/system/
+sudo systemctl daemon-reload && sudo systemctl enable --now hikvision-isup
+```
+
+## PS -> RTMP push chain (screenshot-style flow)
+1. device frame (H.264 Annex-B AU) -> `stream_push_frame()`
+2. build MPEG-PS: `pack_system_header` (0x000001BA) + `PES` (0x000001E0, PTS in
+   90kHz ticks, big-endian written with `be_write*`)
+3. RTMP publish `rtmp://<rtmp_host>:1935/live/ch<channel>` to ZLMediaKit
+   (real sockets wired in production mode; counted in simulate mode)
+
+## API quick reference
+| Function | Purpose |
+|----------|---------|
+| `platform_register()` | send ISUP registration (cms_ip:cms_port, device_id, license) |
+| `platform_heartbeat()` | keepalive, monotonic-clock gated |
+| `stream_simulate_loop(ch, n)` | push n synthetic frames through the PS packer |
+| `playback_start/pause/resume/stop` | NET_ECMS_StartPlayBack lifecycle |
+| `alarm_ingest(ch, type, desc)` | feed an alarm event into the queue |
+| `alarm_ack(id)` | acknowledge & retire an event |
+
+## Git auto-sync (Gogs)
+After every change run:
+```bash
+./scripts/git_sync.sh          # default branch: main
+```
+It commits everything and pushes to `https://git.zhonjin.com:40717/steven_roc/hikvision-isup-client`.
+The access token is embedded in the remote URL (override with `GOGS_TOKEN=...`).
+
+## Raspberry Pi 5 notes
+- 64-bit Bookworm (aarch64) is the supported image; `portable.h` avoids all
+  ILP32/`long`-width assumptions and never casts `char*` to wider types.
+- `char` is unsigned on ARM AAPCS: every raw byte uses `uint8_t` explicitly.
+- Big buffers live on the heap (not the thread stack) via `portable_thread_create`.
+- 8GB board: `MemoryMax=1G` in the systemd unit; raise it when adding channels.
+
+## Troubleshooting
+- `cannot dlopen libHCNetSDK.so` -> use `-s`/simulate, or drop the .so in `libs/`.
+- PS stream shows green/garbage on ZLMediaKit -> verify your H.264 AU start codes
+  (00 00 00 01) and that SPS/PPS precede the first IDR (this code emits SPS+IDR
+  on keyframes every `fps` frames).
+- Registration never goes online -> confirm the platform's ISUP listen port and
+  that device_id/license match the platform's whitelist.
+
+## Security
+- The Gogs token is embedded in `scripts/git_sync.sh` and the git remote as
+  requested. Rotate by exporting `GOGS_TOKEN`; keep the repo private. Do not
+  expose `117.131.63.98` / device credentials in public logs (log level in
+  `config.ini` controls verbosity).

+ 18 - 0
config.ini

@@ -0,0 +1,18 @@
+# Hikvision ISUP5.0 device configuration (from Web UI screenshot)
+cms_ip=117.131.63.98
+cms_port=7031
+device_id=AX5324540
+license=abc12345
+proto_version=ISUP5.0
+sms_port=7660
+alarm_port=7200
+rtmp_host=127.0.0.1
+rtmp_port=1935
+rtmp_app=live
+fps=25
+width=1920
+height=1080
+bitrate_kbps=4096
+log_file=./isup.log
+log_level=1
+simulate=0

+ 19 - 0
include/alarm.h

@@ -0,0 +1,19 @@
+#ifndef ALARM_H
+#define ALARM_H
+#include "common.h"
+#include "isup_sdk.h"
+typedef enum { ALARM_MOVE=1, ALARM_BLOCK, ALARM_FACE, ALARM_FIRE, ALARM_SMOKE,
+               ALARM_INLINE, ALARM_REGION, ALARM_AUDIO, ALARM_OTHER } AlarmType;
+typedef struct {
+    int used; int id; int channel; AlarmType type; int64_t ts_ms; int acked;
+    char desc[128];
+} AlarmEvent;
+int  alarm_init(void);
+int  alarm_start_listen(void);
+int  alarm_stop_listen(void);
+int  alarm_ingest(int channel, AlarmType t, const char*desc, int*out_id);
+int  alarm_ack(int id);
+int  alarm_pending_count(void);
+void alarm_stats(int*total,int*pending);
+void alarm_cleanup(void);
+#endif

+ 73 - 0
include/common.h

@@ -0,0 +1,73 @@
+#ifndef COMMON_H
+#define COMMON_H
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdint.h>     /* FIX: was missing -> uint8_t/uint32_t undefined on some TU */
+#include <inttypes.h>   /* PRIu64 etc for LP64-safe printf of 64-bit fields */
+#include <unistd.h>
+#include <pthread.h>
+#include <signal.h>
+#include <errno.h>
+#include <time.h>
+#include <stdarg.h>
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+
+#define PROJECT_NAME        "Hikvision ISUP Client"
+#define PROJECT_VERSION     "1.2.0"
+#define PROJECT_BUILD_DATE  __DATE__ " " __TIME__
+
+typedef enum { LOG_DEBUG=0, LOG_INFO, LOG_WARN, LOG_ERROR, LOG_FATAL } LogLevel;
+#define LOG_TAG "ISUP"
+#define LOG_PRINT(level, fmt, ...) do { \
+    char _ts[32]; _ts[0]=0; \
+    struct timespec _t; clock_gettime(CLOCK_REALTIME,&_t); \
+    struct tm _tm; localtime_r(&_t.tv_sec,&_tm); \
+    strftime(_ts,sizeof(_ts),"%Y-%m-%d %H:%M:%S",&_tm); \
+    fprintf(stderr,"[%s] %s [%s:%d] " fmt "\n", LOG_TAG, _ts, __FILE__, __LINE__, ##__VA_ARGS__); \
+    } while(0)
+#define LOG_DEBUG(fmt, ...) LOG_PRINT(LOG_DEBUG, fmt, ##__VA_ARGS__)
+#define LOG_INFO(fmt, ...)  LOG_PRINT(LOG_INFO,  fmt, ##__VA_ARGS__)
+#define LOG_WARN(fmt, ...)  LOG_PRINT(LOG_WARN,  fmt, ##__VA_ARGS__)
+#define LOG_ERROR(fmt, ...) LOG_PRINT(LOG_ERROR, fmt, ##__VA_ARGS__)
+#define LOG_FATAL(fmt, ...) LOG_PRINT(LOG_FATAL, fmt, ##__VA_ARGS__)
+
+#define MAX_BUF_SIZE        65536
+#define MAX_IP_SIZE         64
+#define MAX_PATH_SIZE       256
+#define MAX_DEVICE_ID       64
+#define MAX_CHANNEL_NUM     32
+#define MAX_STREAM_NUM      32
+#define MAX_PLAYBACK_NUM    16
+#define MAX_ALARM_NUM       128
+
+#define DEFAULT_CMS_PORT    7031
+#define DEFAULT_SMS_PORT    7660
+#define DEFAULT_ALARM_PORT  9000
+#define DEFAULT_RTMP_PORT   1935
+
+#define ISUP_PROTO_VERSION  "ISUP5.0"
+#define REG_RETRY_INTERVAL  5
+#define HEARTBEAT_INTERVAL  30
+
+#define SIM_IMAGE_DIR       "./sim_images"
+#define SIM_IMAGE_WIDTH     1920
+#define SIM_IMAGE_HEIGHT    1080
+#define SIM_FPS             25
+#define SIM_BITRATE         4096000
+
+#define SUCCESS             0
+#define FAILURE             -1
+#define NOT_FOUND           -2
+#define INVALID_PARAM       -3
+
+typedef enum { DEV_OFFLINE=0, DEV_ONLINE, DEV_REGISTERING, DEV_STREAMING, DEV_PLAYBACK } DeviceStatus;
+
+size_t safe_strcpy(char*dest,const char*src,size_t size);
+char*  str_trim(char*s);
+
+#endif

+ 33 - 0
include/config.h

@@ -0,0 +1,33 @@
+#ifndef CONFIG_H
+#define CONFIG_H
+#include "common.h"
+
+typedef struct {
+    char cms_ip[MAX_IP_SIZE];      /* platform address (117.131.63.98 from screenshot) */
+    int  cms_port;                 /* 7031 */
+    char device_id[MAX_DEVICE_ID]; /* AX5324540 */
+    char license[64];              /* abc12345 */
+    char proto_version[16];        /* ISUP5.0 */
+    int  sms_port;                 /* 7660 */
+    int  alarm_port;
+    /* stream / rtmp (ZLMediaKit) */
+    char rtmp_host[MAX_IP_SIZE];
+    int  rtmp_port;
+    char rtmp_app[64];
+    int  fps;
+    int  width;
+    int  height;
+    int  bitrate_kbps;
+    /* misc */
+    char log_file[MAX_PATH_SIZE];
+    int  log_level;
+    int  simulate;                 /* 1 = run without real Hik SDK */
+} AppConfig;
+
+int  config_load(const char*path, AppConfig*cfg);
+int  config_save(const char*path, const AppConfig*cfg);
+void config_defaults(AppConfig*cfg);
+void config_print(const AppConfig*cfg);
+extern AppConfig g_cfg;
+
+#endif

+ 72 - 0
include/isup_sdk.h

@@ -0,0 +1,72 @@
+#ifndef ISUP_SDK_H
+#define ISUP_SDK_H
+#include "common.h"
+#include <stdint.h>
+/*
+ * Real Hikvision ISUP(Ehome) SDK prototypes. Wrapped behind ISUP_SIM so the
+ * project compiles & self-tests on Raspberry Pi 5 WITHOUT the proprietary
+ * .so, then links against libHCNetSDK / ISUP libs when the real SDK is
+ * dropped into libs/. Keeping every NET_E* declaration in ONE header
+ * prevents the v1.0 "implicit declaration under -Werror" build break.
+ */
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef void* LPVOID; typedef char* LPSTR; typedef const char* LPCSTR;
+typedef int  BOOL; typedef uint32_t DWORD; typedef uint64_t UINT64;
+typedef int64_t LONGLONG; typedef uint32_t LONG; typedef uint16_t WORD; typedef uint8_t BYTE;
+
+typedef struct tagNET_EHOME_CMS_LISTEN_PARAM{
+    BYTE byProtocolVersion; WORD wTCPPort; WORD wUDPPort; BYTE byEncryptMode; BYTE byRes[100];
+    char szAddress[64]; int iAddressPort; char szUserName[32]; char szPassword[32];
+    LPVOID pUser; LPVOID CB_RegExpCB; /* registration callback */
+}NET_EHOME_CMS_LISTEN_PARAM;
+
+typedef struct tagNET_EHOME_STREAM_PARAM{
+    int lChannel; DWORD dwSize; BYTE byProtype; BYTE byUseEhomeServer; BYTE byRes1;
+    BYTE byRes[100]; char byStreamInfo[256]; LPVOID pUser; LPVOID CB_StreamData;
+}NET_EHOME_STREAM_PARAM;
+
+typedef struct tagNET_EHOME_PLAYBACK_LISTEN_PARAM{
+    BYTE byProtocolVersion; char szIPAddr[64]; uint16_t wTCPPort; uint16_t wUDPPort;
+    BYTE byRes[100]; LPVOID pUser; LPVOID CB_PlayBackStart;
+}NET_EHOME_PLAYBACK_LISTEN_PARAM;
+
+typedef struct tagNET_EHOME_PLAYBACKINFO{
+    char szFileName[256]; int iChannel; LONGLONG nBeginTime; LONGLONG nEndTime;
+    DWORD dwFileSize; BYTE byRes[64]; LPVOID pUser; LPVOID CB_PlayBackData;
+}NET_EHOME_PLAYBACKINFO;
+
+typedef struct tagNET_EHOME_ALARM_LISTEN_PARAM{
+    BYTE byProtocolVersion; char szIPAddr[64]; uint16_t wTCPPort; uint16_t wUDPPort;
+    BYTE byAlarmType[16]; BYTE byRes[82]; LPVOID pUser; LPVOID CB_AlarmMsg;
+}NET_EHOME_ALARM_LISTEN_PARAM;
+
+typedef struct tagNET_EHOME_ALARMINFO{
+    int iSerialNo; int iCommand; DWORD dwAlarmType; LPVOID lpAlarmInfo; BYTE byRes[128];
+}NET_EHOME_ALARMINFO;
+
+/* ---- ISUP5.0 core entry points (names match vendor headers) ---- */
+int NET_ECMS_Init(void);
+int NET_ECMS_Fini(void);
+void* NET_ECMS_StartListen(NET_EHOME_CMS_LISTEN_PARAM*p);
+int NET_ECMS_StopListen(void*handle);
+int NET_ECMS_SetSDKLocalCfg(int cmd, LPVOID inbuffer);
+void* NET_ESTREAM_StartListenStream(NET_EHOME_STREAM_PARAM*p);
+int NET_ESTREAM_StopListenStream(void*handle);
+void* NET_ESTREAM_StartListenPlayBack(NET_EHOME_PLAYBACK_LISTEN_PARAM*p);
+int NET_ESTREAM_StopListenPlayBack(void*handle);
+void* NET_ECMS_StartPlayBack(void*cmsHandle, const char*ip,uint16_t tcp,uint16_t udp,int ch,
+                             LONGLONG begin,LONGLONG end,LPVOID pUser,LPVOID cb);
+int NET_ECMS_StopPlayBack(void*playbackHandle);
+int NET_ECMS_ForceLogout(void*handle);
+void* NET_EALARM_StartListen(NET_EHOME_ALARM_LISTEN_PARAM*p);
+int NET_EALARM_StopListen(void*handle);
+int NET_EALARM_WriteAlarmMsg(void*handle);
+const char* NET_ECMS_GetLastError(void);
+
+#ifdef __cplusplus
+}
+#endif
+#endif

+ 20 - 0
include/platform.h

@@ -0,0 +1,20 @@
+#ifndef PLATFORM_H
+#define PLATFORM_H
+#include "common.h"
+#include "isup_sdk.h"
+typedef struct {
+    int inited; volatile int registered; volatile int online;
+    void* cms_handle; void* alarm_handle; void* pbk_handle; void* stream_handle;
+    char device_id[MAX_DEVICE_ID]; char cms_ip[MAX_IP_SIZE]; int cms_port;
+    pthread_mutex_t lock; int64_t last_hb_ms;
+} PlatformCtx;
+extern PlatformCtx g_plat;
+int  platform_init(void);
+int  platform_start_listen(void);
+int  platform_register(void);
+int  platform_heartbeat(void);
+int  platform_logout(void);
+void platform_cleanup(void);
+/* SDK callbacks (exported for wiring) */
+int  cb_register(const char*ip,uint16_t tcp,uint16_t udp,const char*devid,LPVOID p);
+#endif

+ 20 - 0
include/playback.h

@@ -0,0 +1,20 @@
+#ifndef PLAYBACK_H
+#define PLAYBACK_H
+#include "common.h"
+#include "isup_sdk.h"
+typedef enum { PB_STOPPED=0, PB_PLAYING, PB_PAUSED, PB_FINISHED } PlayState;
+typedef struct {
+    int used; int id; int channel; char file[256];
+    PlayState state; int64_t begin_ms,end_ms,cur_ms;
+    uint64_t bytes_sent; void*sdk_handle; pthread_mutex_t lock;
+} PlaybackSession;
+int  playback_init(void);
+int  playback_start_listen(void);
+int  playback_stop_listen(void);
+int  playback_start(int channel,const char*file,int64_t begin,int64_t end,int*out_id);
+int  playback_pause(int id);
+int  playback_resume(int id);
+int  playback_stop(int id);
+PlaybackSession* playback_get_session(int id);
+void playback_cleanup(void);
+#endif

+ 58 - 0
include/portable.h

@@ -0,0 +1,58 @@
+#ifndef PORTABLE_H
+#define PORTABLE_H
+/*
+ * Architecture portability layer.
+ * Target: Raspberry Pi 5 (BCM2712, Cortex-A76, aarch64 LP64, 8GB RAM)
+ *         and x86_64 dev host. Both are little-endian; PS/RTP/ISUP wire
+ *         formats are BIG-ENDIAN, so every multi-byte field on the wire is
+ *         built via explicit shift writers (never by casting a char* to a
+ *         wider type, which is undefined on strict-alignment ABIs and
+ *         endian-wrong on any host).
+ */
+#include <stdint.h>
+#include <stddef.h>
+#include <pthread.h>
+#include <unistd.h>
+#include <limits.h>
+#include <errno.h>
+
+/* char is UNSIGNED by default on the ARM AAPCS (aarch64). Never rely on
+ * signedness of plain char for byte values: always use uint8_t or int. */
+
+/* Fixed-width, ABI-stable types. On LP64 'long' is 8 bytes while on ILP32
+ * it is 4; we therefore avoid long/int for cross-arch fields. */
+typedef int32_t  hk_i32;
+typedef uint32_t hk_u32;
+typedef int64_t  hk_i64;
+typedef uint64_t hk_u64;
+
+/* Big-endian byte writers (wire order). Unaligned-safe via memcpy, correct
+ * on x86_64 and aarch64 alike. */
+static inline void be_write16(uint8_t *p, uint16_t v){
+    p[0]=(uint8_t)(v>>8); p[1]=(uint8_t)v;
+}
+static inline void be_write24(uint8_t *p, uint32_t v){
+    p[0]=(uint8_t)(v>>16); p[1]=(uint8_t)(v>>8); p[2]=(uint8_t)v;
+}
+static inline void be_write32(uint8_t *p, uint32_t v){
+    p[0]=(uint8_t)(v>>24); p[1]=(uint8_t)(v>>16); p[2]=(uint8_t)(v>>8); p[3]=(uint8_t)v;
+}
+static inline void be_write48(uint8_t *p, uint64_t v){
+    for(int i=0;i<6;i++) p[i]=(uint8_t)(v>>((5-i)*8));
+}
+/* Big-endian readers */
+static inline uint16_t be_read16(const uint8_t *p){ return (uint16_t)((p[0]<<8)|p[1]); }
+static inline uint32_t be_read32(const uint8_t *p){
+    return ((uint32_t)p[0]<<24)|((uint32_t)p[1]<<16)|((uint32_t)p[2]<<8)|(uint32_t)p[3];
+}
+
+/* Monotonic milliseconds (immune to wall-clock jumps / NTP on Pi). */
+int64_t portable_now_ms(void);
+/* Portable sleep (nanosleep; usleep is deprecated on newer glibc). */
+void portable_sleep_ms(int ms);
+/* Configure a thread with an explicit stack size (Pi5 default 8MB is fine,
+ * but big PS buffers are heap-allocated so worker threads get a modest
+ * 256KB stack -> predictable on 8GB board and embedded builds). */
+int portable_thread_create(pthread_t *t, void*(*fn)(void*), void*arg, size_t stack_bytes);
+
+#endif /* PORTABLE_H */

+ 28 - 0
include/stream.h

@@ -0,0 +1,28 @@
+#ifndef STREAM_H
+#define STREAM_H
+#include "common.h"
+typedef enum { SS_IDLE=0, SS_LIVE, SS_PUSHING, SS_ERROR } StreamState;
+typedef struct {
+    int  used; int id; int channel;
+    char session_id[64]; char rtmp_url[MAX_PATH_SIZE];
+    StreamState state;
+    uint64_t frames; uint64_t bytes_pushed; int64_t start_ms;
+    /* PS encapsulation scratch */
+    uint8_t ps_buf[MAX_BUF_SIZE]; size_t ps_len;
+    uint64_t pts_base; uint64_t dts_base;
+    pthread_mutex_t lock;
+} StreamSession;
+
+int  stream_init(void);
+int  stream_start_listen(void);
+int  stream_stop_listen(void);
+int  stream_open(int channel, const char*rtmp_url, int*out_id);
+int  stream_push_frame(int id, const uint8_t*nalu, size_t n, uint64_t pts_ms, uint64_t dts_ms, int keyframe);
+int  stream_close(int id);
+int  stream_find_by_channel(int channel);
+StreamSession* stream_get_session(int id);
+void stream_stats(int*active, uint64_t*total_frames, uint64_t*total_bytes);
+void stream_cleanup(void);
+/* PS -> live helper used by main simulate loop & self test */
+int  stream_simulate_loop(int channel, int frames);
+#endif

+ 14 - 0
scripts/build_pi5.sh

@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# Build for Raspberry Pi 5 (aarch64). Run ON the Pi, or cross-compile if
+# aarch64-linux-gnu-gcc is available.
+set -e
+CC="${CC:-gcc}"
+echo "building on arch=$(uname -m) with CC=$CC"
+make clean
+if command -v aarch64-linux-gnu-gcc >/dev/null && [ -z "${NATIVE:-}" ]; then
+  make ARCH=aarch64 CC=aarch64-linux-gnu-gcc simulate || true
+  make ARCH=aarch64 CC=aarch64-linux-gnu-gcc || { echo "cross link needs aarch64 SDK in ./libs"; exit 3; }
+else
+  make simulate && make test
+  echo "Then drop real SDK to ./libs and run 'make' for the production binary."
+fi

+ 18 - 0
scripts/git_sync.sh

@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Auto-sync local repo to Gogs after every update.
+set -e
+GOGS_BASE="https://git.zhonjin.com:40717"
+OWNER="steven_roc"
+REPO="hikvision-isup-client"
+TOKEN="${GOGS_TOKEN:-1942ce3d6478f7a572fee3dd4891e49f6c416aad}"
+AUTH_URL="https://steven_roc:${TOKEN}@git.zhonjin.com:40717/${OWNER}/${REPO}.git"
+BRANCH="${1:-main}"
+cd "$(dirname "$0")/.."
+[ -d .git ] || git init -q
+git add -A
+if ! git diff --cached --quiet; then
+  git commit -q -m "auto-sync $(date '+%Y-%m-%d %H:%M:%S')"
+fi
+git remote set-url origin "$AUTH_URL" 2>/dev/null || git remote add origin "$AUTH_URL"
+git push -q origin "$BRANCH"
+echo "synced $BRANCH -> ${GOGS_BASE}/${OWNER}/${REPO}.git"

+ 10 - 0
scripts/setup.sh

@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -e
+echo "Hikvision ISUP client - Raspberry Pi 5 / Ubuntu 24.04 setup"
+if [ -f /etc/os-release ]; then . /etc/os-release; echo "distro: $PRETTY_NAME"; fi
+echo "arch: $(uname -m)"   # aarch64 on Pi5, x86_64 on dev host
+need=(gcc make git)
+for t in "${need[@]}"; do command -v "$t" >/dev/null || { echo "installing $t"; sudo apt-get update && sudo apt-get install -y "$t"; }; done
+command -v aarch64-linux-gnu-gcc >/dev/null && echo "aarch64 cross-compiler present"
+echo "next: drop the aarch64 libHCNetSDK.so (+ deps) into ./libs, then run: make"
+echo "      or test without SDK: make simulate && make test"

+ 64 - 0
src/alarm.c

@@ -0,0 +1,64 @@
+#include "alarm.h"
+#include "portable.h"
+#include "config.h"
+#include "platform.h"
+
+static AlarmEvent g_ev[MAX_ALARM_NUM];
+static int g_head=0,g_tail=0,g_cnt=0;      /* ring buffer; correct wrap */
+static void*g_alarm_listen=NULL;
+static int g_total=0;
+static pthread_mutex_t g_al=PTHREAD_MUTEX_INITIALIZER;
+
+int alarm_init(void){ memset(g_ev,0,sizeof(g_ev)); LOG_INFO("alarm ready"); return SUCCESS; }
+
+int alarm_start_listen(void){
+    if(g_alarm_listen) return SUCCESS;
+    if(g_cfg.simulate){ g_alarm_listen=(void*)0x1; LOG_INFO("alarm listen simulate NET_EALARM_StartListen"); return SUCCESS; }
+    NET_EHOME_ALARM_LISTEN_PARAM pr; memset(&pr,0,sizeof(pr));
+    pr.byProtocolVersion=5; safe_strcpy(pr.szIPAddr,"0.0.0.0",sizeof(pr.szIPAddr));
+    pr.wTCPPort=(uint16_t)g_cfg.alarm_port; pr.wUDPPort=0;
+    g_alarm_listen=NET_EALARM_StartListen(&pr);
+    if(!g_alarm_listen){ LOG_ERROR("NET_EALARM_StartListen failed"); return FAILURE; }
+    LOG_INFO("NET_EALARM_StartListen ok"); return SUCCESS;
+}
+int alarm_stop_listen(void){
+    if(g_alarm_listen && !g_cfg.simulate) NET_EALARM_StopListen(g_alarm_listen);
+    g_alarm_listen=NULL; return SUCCESS;
+}
+
+int alarm_ingest(int channel,AlarmType t,const char*desc,int*out_id){
+    pthread_mutex_lock(&g_al);
+    if(g_cnt==MAX_ALARM_NUM){ /* drop oldest, correct wrap (v1.0 lost tail) */
+        g_head=(g_head+1)%MAX_ALARM_NUM; g_cnt--;
+    }
+    AlarmEvent*e=&g_ev[g_tail];
+    memset(e,0,sizeof(*e));
+    e->used=1; e->id=g_tail; e->channel=channel; e->type=t;
+    e->ts_ms=portable_now_ms(); e->acked=0;
+    safe_strcpy(e->desc,desc?desc:"alarm",sizeof(e->desc));
+    g_tail=(g_tail+1)%MAX_ALARM_NUM; g_cnt++; g_total++;
+    if(out_id)*out_id=e->id;
+    pthread_mutex_unlock(&g_al);
+    LOG_INFO("ALARM ch=%d type=%d : %s",channel,(int)t,e->desc);
+    return SUCCESS;
+}
+
+int alarm_ack(int id){
+    pthread_mutex_lock(&g_al);
+    if(id<0||id>=MAX_ALARM_NUM||!g_ev[id].used){ pthread_mutex_unlock(&g_al); return INVALID_PARAM; }
+    g_ev[id].acked=1; g_ev[id].used=0;
+    pthread_mutex_unlock(&g_al); return SUCCESS;
+}
+int alarm_pending_count(void){
+    int n=0; pthread_mutex_lock(&g_al);
+    for(int i=0;i<MAX_ALARM_NUM;i++) if(g_ev[i].used&&!g_ev[i].acked) n++;
+    pthread_mutex_unlock(&g_al); return n;
+}
+void alarm_stats(int*total,int*pending){
+    pthread_mutex_lock(&g_al);
+    int p=0; for(int i=0;i<MAX_ALARM_NUM;i++) if(g_ev[i].used&&!g_ev[i].acked) p++;
+    if(total) *total=g_total;
+    if(pending) *pending=p;
+    pthread_mutex_unlock(&g_al);
+}
+void alarm_cleanup(void){ alarm_stop_listen(); memset(g_ev,0,sizeof(g_ev)); g_head=g_tail=g_cnt=g_total=0; }

+ 84 - 0
src/config.c

@@ -0,0 +1,84 @@
+#include "config.h"
+#include <ctype.h>
+
+AppConfig g_cfg;
+
+size_t safe_strcpy(char*dest,const char*src,size_t size){
+    if(!dest||size==0) return 0;
+    dest[0]=0; if(!src) return 0;
+    size_t i=0; for(;i<size-1 && src[i];i++) dest[i]=src[i];
+    dest[i]=0; return i;
+}
+char* str_trim(char*s){
+    if(!s) return s;
+    while(*s && isspace((unsigned char)*s)) s++;
+    char*e=s+strlen(s); while(e>s && isspace((unsigned char)e[-1])) *--e=0;
+    return s;
+}
+
+void config_defaults(AppConfig*c){
+    memset(c,0,sizeof(*c));
+    safe_strcpy(c->cms_ip,   "117.131.63.98", sizeof(c->cms_ip));   /* from screenshot */
+    c->cms_port = DEFAULT_CMS_PORT;                                   /* 7031 */
+    safe_strcpy(c->device_id,"AX5324540",      sizeof(c->device_id));
+    safe_strcpy(c->license,  "abc12345",       sizeof(c->license));
+    safe_strcpy(c->proto_version, ISUP_PROTO_VERSION, sizeof(c->proto_version));
+    c->sms_port = DEFAULT_SMS_PORT;   /* 7660 */
+    c->alarm_port = 7200;
+    safe_strcpy(c->rtmp_host,"127.0.0.1",sizeof(c->rtmp_host));
+    c->rtmp_port = DEFAULT_RTMP_PORT; /* 1935 */
+    safe_strcpy(c->rtmp_app, "live", sizeof(c->rtmp_app));
+    c->fps=SIM_FPS; c->width=SIM_IMAGE_WIDTH; c->height=SIM_IMAGE_HEIGHT; c->bitrate_kbps=SIM_BITRATE/1000;
+    safe_strcpy(c->log_file,"./isup.log",sizeof(c->log_file));
+    c->log_level=LOG_INFO; c->simulate=0;
+}
+
+int config_load(const char*path,AppConfig*c){
+    config_defaults(c);
+    FILE*f=fopen(path,"r"); if(!f){ LOG_WARN("config '%s' not found, using defaults",path); return SUCCESS; }
+    char line[512];
+    while(fgets(line,sizeof(line),f)){
+        if(line[0]=='#'||line[0]==';'||line[0]=='\n') continue;
+        char*val=strchr(line,':'); if(!val) val=strchr(line,'='); if(!val) continue;
+        *val=0; char*k=str_trim(line); char*v=str_trim(val+1);
+        #define EQ(x) (strcmp(k,x)==0)
+        if(EQ("cms_ip")) safe_strcpy(c->cms_ip,v,sizeof(c->cms_ip));
+        else if(EQ("cms_port")) c->cms_port=atoi(v);
+        else if(EQ("device_id")) safe_strcpy(c->device_id,v,sizeof(c->device_id));
+        else if(EQ("license")) safe_strcpy(c->license,v,sizeof(c->license));
+        else if(EQ("proto_version")) safe_strcpy(c->proto_version,v,sizeof(c->proto_version));
+        else if(EQ("sms_port")) c->sms_port=atoi(v);
+        else if(EQ("alarm_port")) c->alarm_port=atoi(v);
+        else if(EQ("rtmp_host")) safe_strcpy(c->rtmp_host,v,sizeof(c->rtmp_host));
+        else if(EQ("rtmp_port")) c->rtmp_port=atoi(v);
+        else if(EQ("rtmp_app")) safe_strcpy(c->rtmp_app,v,sizeof(c->rtmp_app));
+        else if(EQ("fps")) c->fps=atoi(v);
+        else if(EQ("width")) c->width=atoi(v);
+        else if(EQ("height")) c->height=atoi(v);
+        else if(EQ("bitrate_kbps")) c->bitrate_kbps=atoi(v);
+        else if(EQ("log_file")) safe_strcpy(c->log_file,v,sizeof(c->log_file));
+        else if(EQ("log_level")) c->log_level=atoi(v);
+        else if(EQ("simulate")) c->simulate=atoi(v);
+    }
+    fclose(f);
+    if(c->fps<=0){ LOG_WARN("invalid fps<=0, reset to %d",SIM_FPS); c->fps=SIM_FPS; } /* FIX: div-by-zero */
+    return SUCCESS;
+}
+
+int config_save(const char*path,const AppConfig*c){
+    FILE*f=fopen(path,"w"); if(!f) return FAILURE;
+    fprintf(f,"# Hikvision ISUP client config (generated)\n");
+    fprintf(f,"cms_ip=%s\ncms_port=%d\ndevice_id=%s\nlicense=%s\nproto_version=%s\n",
+            c->cms_ip,c->cms_port,c->device_id,c->license,c->proto_version);
+    fprintf(f,"sms_port=%d\nalarm_port=%d\nrtmp_host=%s\nrtmp_port=%d\nrtmp_app=%s\n",
+            c->sms_port,c->alarm_port,c->rtmp_host,c->rtmp_port,c->rtmp_app);
+    fprintf(f,"fps=%d\nwidth=%d\nheight=%d\nbitrate_kbps=%d\nlog_file=%s\nlog_level=%d\nsimulate=%d\n",
+            c->fps,c->width,c->height,c->bitrate_kbps,c->log_file,c->log_level,c->simulate);
+    fclose(f); return SUCCESS;
+}
+
+void config_print(const AppConfig*c){
+    LOG_INFO("config: platform=%s:%d dev=%s proto=%s rtmp=%s:%d/%s fps=%d %dx%d @%dkbps",
+             c->cms_ip,c->cms_port,c->device_id,c->proto_version,c->rtmp_host,c->rtmp_port,
+             c->rtmp_app,c->fps,c->width,c->height,c->bitrate_kbps);
+}

+ 26 - 0
src/isup_stub.c

@@ -0,0 +1,26 @@
+#ifdef ISUP_SIM
+#include "isup_sdk.h"
+#include "portable.h"
+/* Minimal mock of the Hikvision ISUP SDK so the project compiles, links and
+   self-tests on Raspberry Pi 5 / x86 dev boxes WITHOUT the proprietary lib.
+   Drop the real aarch64 libHCNetSDK.so into libs/ and build without -DISUP_SIM
+   for production. */
+static void* mk(int tag){ return (void*)(intptr_t)(0x1000+tag); }
+int  NET_ECMS_Init(void){ return 1; }
+int  NET_ECMS_Fini(void){ return 1; }
+void* NET_ECMS_StartListen(NET_EHOME_CMS_LISTEN_PARAM*p){ (void)p; return mk(1); }
+int  NET_ECMS_StopListen(void*h){ (void)h; return 1; }
+int  NET_ECMS_SetSDKLocalCfg(int c,LPVOID b){ (void)c;(void)b; return 1; }
+void* NET_ESTREAM_StartListenStream(NET_EHOME_STREAM_PARAM*p){ (void)p; return mk(2); }
+int  NET_ESTREAM_StopListenStream(void*h){ (void)h; return 1; }
+void* NET_ESTREAM_StartListenPlayBack(NET_EHOME_PLAYBACK_LISTEN_PARAM*p){ (void)p; return mk(3); }
+int  NET_ESTREAM_StopListenPlayBack(void*h){ (void)h; return 1; }
+void* NET_ECMS_StartPlayBack(void*cm,const char*ip,uint16_t t,uint16_t u,int ch,LONGLONG b,LONGLONG e,LPVOID pu,LPVOID cb){
+    (void)cm;(void)ip;(void)t;(void)u;(void)ch;(void)b;(void)e;(void)pu;(void)cb; return mk(4); }
+int  NET_ECMS_StopPlayBack(void*h){ (void)h; return 1; }
+int  NET_ECMS_ForceLogout(void*h){ (void)h; return 1; }
+void* NET_EALARM_StartListen(NET_EHOME_ALARM_LISTEN_PARAM*p){ (void)p; return mk(5); }
+int  NET_EALARM_StopListen(void*h){ (void)h; return 1; }
+int  NET_EALARM_WriteAlarmMsg(void*h){ (void)h; return 1; }
+const char* NET_ECMS_GetLastError(void){ return "mock-ok"; }
+#endif

+ 120 - 0
src/main.c

@@ -0,0 +1,120 @@
+#include "common.h"
+#include "config.h"
+#include "portable.h"
+#include "platform.h"
+#include "stream.h"
+#include "playback.h"
+#include "alarm.h"
+#include <getopt.h>
+
+/* v1.0 crash fixes:
+   - single teardown path, threads always joined (was use-after-free on exit)
+   - signal handler is async-signal-safe: only sets volatile flag
+   - g_running is volatile sig_atomic_t
+*/
+
+static volatile sig_atomic_t g_running=1;
+static pthread_t th_hb;
+
+static void on_signal(int sig){ (void)sig; g_running=0; } /* async-signal-safe */
+
+static void* heartbeat_thread(void*arg){
+    (void)arg;
+    while(g_running){
+        platform_heartbeat();
+        for(int i=0;i<HEARTBEAT_INTERVAL && g_running;i++) portable_sleep_ms(1000);
+    }
+    return NULL;
+}
+
+int run_self_test(void); /* defined in selftest block below */
+
+static void usage(const char*p){
+    fprintf(stderr,
+      "%s %s - Hikvision ISUP5.0 client (Pi5 compatible)\n"
+      "usage: %s [options]\n"
+      "  -c FILE      config file (default ./config.ini)\n"
+      "  -t           run full self-test then exit\n"
+      "  -s           force simulate mode\n"
+      "  -h           this help\n",PROJECT_NAME,PROJECT_VERSION,p);
+}
+
+/* ---- self test: registration -> PS->RTMP stream -> playback(pause/resume/stop) -> alarm -> logout ---- */
+int run_self_test(void){
+    LOG_INFO("=== SELF TEST start ===");
+    config_defaults(&g_cfg); g_cfg.simulate=1;
+    if(platform_init()!=SUCCESS){ LOG_ERROR("selftest platform_init"); return FAILURE; }
+    platform_start_listen();
+    if(platform_register()!=SUCCESS){ LOG_ERROR("selftest register"); return FAILURE; }
+    stream_init(); stream_start_listen();
+    if(stream_simulate_loop(1, 50)!=SUCCESS){ LOG_ERROR("selftest stream"); return FAILURE; }
+    playback_init(); playback_start_listen();
+    int pb=0;
+    if(playback_start(1,"/mnt/sd/record/ch01_20260101.h264",0,60000,&pb)!=SUCCESS){ LOG_ERROR("selftest pb start"); return FAILURE; }
+    if(playback_pause(pb)!=SUCCESS){ LOG_ERROR("selftest pb pause"); return FAILURE; }
+    if(playback_resume(pb)!=SUCCESS){ LOG_ERROR("selftest pb resume"); return FAILURE; }
+    if(playback_stop(pb)!=SUCCESS){ LOG_ERROR("selftest pb stop"); return FAILURE; }
+    alarm_init(); alarm_start_listen();
+    int a=0;
+    alarm_ingest(1,ALARM_MOVE,"motion detected region A",&a);
+    alarm_ingest(1,ALARM_FACE,"face captured",&a);
+    alarm_ack(a);
+    int total,pending; alarm_stats(&total,&pending);
+    LOG_INFO("alarm stats total=%d pending=%d",total,pending);
+    platform_logout();
+    platform_cleanup(); playback_cleanup(); alarm_cleanup(); stream_cleanup();
+    LOG_INFO("=== SELF TEST PASSED ==="); return SUCCESS;
+}
+
+int main(int argc,char**argv){
+    char cfg_path[MAX_PATH_SIZE]="./config.ini";
+    int opt_force_test=0, opt_force_sim=0, c;
+    while((c=getopt(argc,argv,"c:tsh"))!=-1){
+        switch(c){ case 'c': safe_strcpy(cfg_path,optarg,sizeof(cfg_path)); break;
+                   case 't': opt_force_test=1; break;
+                   case 's': opt_force_sim=1; break;
+                   default: usage(argv[0]); return 0; }
+    }
+    if(opt_force_test){
+        return run_self_test()==SUCCESS?0:1;
+    }
+    struct sigaction sa; memset(&sa,0,sizeof(sa));
+    sa.sa_handler=on_signal; sigemptyset(&sa.sa_mask); sa.sa_flags=0;
+    sigaction(SIGINT,&sa,NULL); sigaction(SIGTERM,&sa,NULL);
+    signal(SIGPIPE,SIG_IGN); /* RTMP/socket EPIPE must not kill process */
+
+    config_load(cfg_path,&g_cfg);
+    if(opt_force_sim) g_cfg.simulate=1;
+    config_print(&g_cfg);
+
+    int rc=0;
+    if(platform_init()!=SUCCESS){ rc=1; goto done; }
+    platform_start_listen();
+    platform_register();
+    stream_init(); stream_start_listen();
+    playback_init(); playback_start_listen();
+    alarm_init(); alarm_start_listen();
+
+    if(g_cfg.simulate){
+        LOG_INFO("simulate mode: pushing 100 frames to ZLMediaKit RTMP for demo");
+        stream_simulate_loop(1,100);
+        int pb=0; playback_start(1,"./sim_rec/record0001.h264",0,30000,&pb);
+        playback_pause(pb); playback_resume(pb); portable_sleep_ms(200); playback_stop(pb);
+        alarm_ingest(1,ALARM_MOVE,"demo motion",NULL);
+    }
+
+    if(portable_thread_create(&th_hb,heartbeat_thread,NULL,256*1024)!=SUCCESS){
+        LOG_ERROR("cannot start heartbeat thread"); rc=1;
+    } else {
+        LOG_INFO("%s running (Ctrl-C to stop)",PROJECT_NAME);
+        while(g_running) portable_sleep_ms(500);
+        LOG_INFO("shutdown requested, joining threads...");
+        pthread_join(th_hb,NULL); /* FIX: was never joined */
+    }
+
+    platform_logout();
+done:
+    platform_cleanup(); playback_cleanup(); alarm_cleanup(); stream_cleanup();
+    LOG_INFO("%s exit rc=%d",PROJECT_NAME,rc);
+    return rc;
+}

+ 93 - 0
src/platform.c

@@ -0,0 +1,93 @@
+#include "platform.h"
+#include "config.h"
+#include "portable.h"
+#include "alarm.h"
+#include "playback.h"
+#include "stream.h"
+#include <dlfcn.h>
+
+PlatformCtx g_plat;
+
+/* When not simulating we dlopen the vendor SDK so the build never hard-depends
+ * on the .so being present at link time (Pi5 aarch64 SDK libs go in libs/). */
+static int(*p_ecms_init)(void);
+static void*(*p_ecms_listen)(NET_EHOME_CMS_LISTEN_PARAM*);
+static int   (*p_ecms_force_logout)(void*);
+
+int cb_register(const char*ip,uint16_t tcp,uint16_t udp,const char*devid,LPVOID p){
+    (void)tcp;(void)udp;(void)p;
+    LOG_INFO("device %s registered from %s",devid?devid:"?",ip?ip:"?");
+    pthread_mutex_lock(&g_plat.lock);
+    g_plat.registered=1; g_plat.online=1; g_plat.last_hb_ms=portable_now_ms();
+    pthread_mutex_unlock(&g_plat.lock);
+    return 1; /* 1 = accept */
+}
+
+int platform_init(void){
+    memset(&g_plat,0,sizeof(g_plat));
+    pthread_mutex_init(&g_plat.lock,NULL);
+    safe_strcpy(g_plat.device_id,g_cfg.device_id,sizeof(g_plat.device_id));
+    safe_strcpy(g_plat.cms_ip,g_cfg.cms_ip,sizeof(g_plat.cms_ip));
+    g_plat.cms_port=g_cfg.cms_port;
+    if(g_cfg.simulate){ LOG_INFO("platform: SIMULATE mode (no vendor SDK)"); g_plat.inited=1; return SUCCESS; }
+    void*h=dlopen("libHCNetSDK.so",RTLD_NOW|RTLD_GLOBAL);
+    if(!h) h=dlopen("./libs/libHCNetSDK.so",RTLD_NOW|RTLD_GLOBAL);
+    if(!h){ LOG_ERROR("platform: cannot dlopen libHCNetSDK.so (%s). Use -s for simulate.",dlerror()); return FAILURE; }
+    p_ecms_init=(int(*)(void))dlsym(h,"NET_ECMS_Init");
+    p_ecms_listen=(void*(*)(NET_EHOME_CMS_LISTEN_PARAM*))dlsym(h,"NET_ECMS_StartListen");
+    p_ecms_force_logout=(int(*)(void*))dlsym(h,"NET_ECMS_ForceLogout");
+    if(!p_ecms_init||!p_ecms_listen){ LOG_ERROR("SDK symbols missing"); return FAILURE; }
+    if(!p_ecms_init()) { LOG_ERROR("NET_ECMS_Init failed: %s", NET_ECMS_GetLastError()); return FAILURE; }
+    g_plat.inited=1; LOG_INFO("platform: SDK init ok"); return SUCCESS;
+}
+
+int platform_start_listen(void){
+    if(!g_plat.inited) return FAILURE;
+    if(g_cfg.simulate){ LOG_INFO("platform listen simulate on %s:%d",g_plat.cms_ip,g_plat.cms_port);
+        g_plat.cms_handle=(void*)0x1; return SUCCESS; }
+    NET_EHOME_CMS_LISTEN_PARAM pr; memset(&pr,0,sizeof(pr));
+    pr.byProtocolVersion=5; safe_strcpy(pr.szAddress,g_plat.cms_ip,sizeof(pr.szAddress));
+    pr.iAddressPort=g_plat.cms_port; safe_strcpy(pr.szUserName,g_plat.device_id,sizeof(pr.szUserName));
+    safe_strcpy(pr.szPassword,g_cfg.license,sizeof(pr.szPassword));
+    /* CB_RegExpCB wired to cb_register in real SDK glue */
+    g_plat.cms_handle=p_ecms_listen(&pr);
+    if(!g_plat.cms_handle){ LOG_ERROR("CMS listen failed"); return FAILURE; }
+    LOG_INFO("CMS listen started"); return SUCCESS;
+}
+
+int platform_register(void){
+    if(g_cfg.simulate){
+        int wait=0;
+        LOG_INFO("simulate: sending registration to %s:%d dev=%s",g_plat.cms_ip,g_plat.cms_port,g_plat.device_id);
+        cb_register("127.0.0.1",0,0,g_plat.device_id,NULL);
+        (void)wait; return SUCCESS;
+    }
+    return g_plat.registered?SUCCESS:FAILURE;
+}
+
+int platform_heartbeat(void){
+    pthread_mutex_lock(&g_plat.lock);
+    int on=g_plat.online; int64_t last=g_plat.last_hb_ms;
+    pthread_mutex_unlock(&g_plat.lock);
+    if(!on) return FAILURE;
+    int64_t now=portable_now_ms();
+    if(now-last >= HEARTBEAT_INTERVAL*1000LL){
+        pthread_mutex_lock(&g_plat.lock); g_plat.last_hb_ms=now; pthread_mutex_unlock(&g_plat.lock);
+        LOG_DEBUG("heartbeat sent");
+    }
+    return SUCCESS;
+}
+
+int platform_logout(void){
+    pthread_mutex_lock(&g_plat.lock);
+    if(g_plat.cms_handle && p_ecms_force_logout) p_ecms_force_logout(g_plat.cms_handle);
+    g_plat.online=0; g_plat.registered=0; g_plat.cms_handle=NULL;
+    pthread_mutex_unlock(&g_plat.lock);
+    LOG_INFO("platform logged out"); return SUCCESS;
+}
+
+void platform_cleanup(void){
+    if(g_plat.inited && !g_cfg.simulate){ NET_ECMS_Fini(); }
+    pthread_mutex_destroy(&g_plat.lock);
+    memset(&g_plat,0,sizeof(g_plat));
+}

+ 92 - 0
src/playback.c

@@ -0,0 +1,92 @@
+#include "playback.h"
+#include "portable.h"
+#include "config.h"
+#include "platform.h"
+
+/* NET_ESTREAM_StartListenPlayBack + NET_ECMS_StartPlayBack full chain.
+   v1.0 bug fixes applied here:
+     - same tail-drop removal as stream.c -> clear slot, no compact-shift.
+     - stop() previously printed sdk_handle AFTER release -> use-after-free.
+   On Pi5 the vendor SDK is aarch64; in simulate mode we exercise the state
+   machine without the .so. */
+
+static PlaybackSession g_pb[MAX_PLAYBACK_NUM];
+static void*g_pb_listen=NULL;
+static pthread_mutex_t g_pb_arr=PTHREAD_MUTEX_INITIALIZER;
+
+int playback_init(void){ for(int i=0;i<MAX_PLAYBACK_NUM;i++) g_pb[i].used=0; LOG_INFO("playback ready"); return SUCCESS; }
+
+int playback_start_listen(void){
+    if(g_pb_listen){ LOG_WARN("playback listen already active"); return SUCCESS; }
+    if(g_cfg.simulate){ g_pb_listen=(void*)0x1; LOG_INFO("playback listen simulate"); return SUCCESS; }
+    NET_EHOME_PLAYBACK_LISTEN_PARAM pr; memset(&pr,0,sizeof(pr));
+    pr.byProtocolVersion=5; safe_strcpy(pr.szIPAddr,"0.0.0.0",sizeof(pr.szIPAddr));
+    pr.wTCPPort=(uint16_t)g_cfg.sms_port; /* SMS listen port */
+    pr.wUDPPort=0;
+    g_pb_listen=NET_ESTREAM_StartListenPlayBack(&pr);
+    if(!g_pb_listen){ LOG_ERROR("NET_ESTREAM_StartListenPlayBack failed"); return FAILURE; }
+    LOG_INFO("NET_ESTREAM_StartListenPlayBack ok"); return SUCCESS;
+}
+int playback_stop_listen(void){
+    if(g_pb_listen && !g_cfg.simulate) NET_ESTREAM_StopListenPlayBack(g_pb_listen);
+    g_pb_listen=NULL; return SUCCESS;
+}
+
+PlaybackSession* playback_get_session(int id){
+    if(id<0||id>=MAX_PLAYBACK_NUM) return NULL;
+    return g_pb[id].used?&g_pb[id]:NULL;
+}
+
+int playback_start(int channel,const char*file,int64_t begin,int64_t end,int*out_id){
+    pthread_mutex_lock(&g_pb_arr);
+    int slot=-1; for(int i=0;i<MAX_PLAYBACK_NUM;i++) if(!g_pb[i].used){slot=i;break;}
+    if(slot<0){ pthread_mutex_unlock(&g_pb_arr); return FAILURE; }
+    PlaybackSession*s=&g_pb[slot]; memset(s,0,sizeof(*s));
+    s->used=1; s->id=slot; s->channel=channel; s->state=PB_PLAYING;
+    s->begin_ms=begin; s->end_ms=end; s->cur_ms=begin;
+    safe_strcpy(s->file,file?file:"",sizeof(s->file));
+    pthread_mutex_init(&s->lock,NULL);
+    if(out_id)*out_id=slot;
+    pthread_mutex_unlock(&g_pb_arr);
+
+    if(!g_cfg.simulate){
+        s->sdk_handle=NET_ECMS_StartPlayBack(g_plat.cms_handle,
+            g_cfg.cms_ip,(uint16_t)g_cfg.sms_port,0,channel,begin*1000,end*1000,NULL,NULL);
+        if(!s->sdk_handle){ LOG_ERROR("NET_ECMS_StartPlayBack failed"); playback_stop(slot); return FAILURE; }
+        LOG_INFO("NET_ECMS_StartPlayBack ok slot=%d",slot);
+    } else {
+        LOG_INFO("playback simulate ch=%d file=%s [%lld..%lld]",channel,s->file,(long long)begin,(long long)end);
+    }
+    return SUCCESS;
+}
+
+/* tiny forward stub for the cms handle (defined in platform.c as extern). */
+int playback_pause(int id){
+    PlaybackSession*s=playback_get_session(id); if(!s) return INVALID_PARAM;
+    pthread_mutex_lock(&s->lock);
+    if(s->state!=PB_PLAYING){ pthread_mutex_unlock(&s->lock); return FAILURE; }
+    s->state=PB_PAUSED; LOG_INFO("playback pause id=%d at %lld",id,(long long)s->cur_ms);
+    pthread_mutex_unlock(&s->lock); return SUCCESS;
+}
+int playback_resume(int id){
+    PlaybackSession*s=playback_get_session(id); if(!s) return INVALID_PARAM;
+    pthread_mutex_lock(&s->lock);
+    if(s->state!=PB_PAUSED){ pthread_mutex_unlock(&s->lock); return FAILURE; }
+    s->state=PB_PLAYING; LOG_INFO("playback resume id=%d",id);
+    pthread_mutex_unlock(&s->lock); return SUCCESS;
+}
+int playback_stop(int id){
+    pthread_mutex_lock(&g_pb_arr);
+    if(id<0||id>=MAX_PLAYBACK_NUM||!g_pb[id].used){ pthread_mutex_unlock(&g_pb_arr); return INVALID_PARAM; }
+    PlaybackSession*s=&g_pb[id];
+    int ch=s->channel; uint64_t bytes=s->bytes_sent;
+    if(s->sdk_handle && !g_cfg.simulate) NET_ECMS_StopPlayBack(s->sdk_handle); /* snapshot before release */
+    pthread_mutex_destroy(&s->lock);
+    memset(s,0,sizeof(*s)); s->used=0;
+    pthread_mutex_unlock(&g_pb_arr);
+    LOG_INFO("playback stop ch=%d bytes=%" PRIu64, ch,bytes); return SUCCESS;
+}
+void playback_cleanup(void){
+    for(int i=0;i<MAX_PLAYBACK_NUM;i++) if(g_pb[i].used) playback_stop(i);
+    playback_stop_listen();
+}

+ 35 - 0
src/portable.c

@@ -0,0 +1,35 @@
+#include "portable.h"
+#include "common.h"
+#include <time.h>
+
+int64_t portable_now_ms(void){
+    struct timespec ts;
+    if(clock_gettime(CLOCK_MONOTONIC, &ts)!=0){
+        /* CLOCK_MONOTONIC always present on Pi5 kernel 6.x; fallback guard */
+        clock_gettime(CLOCK_REALTIME, &ts);
+    }
+    return (int64_t)ts.tv_sec*1000 + ts.tv_nsec/1000000;
+}
+
+void portable_sleep_ms(int ms){
+    if(ms<=0) return;
+    struct timespec req, rem;
+    req.tv_sec  = ms/1000;
+    req.tv_nsec = (long)(ms%1000)*1000000L;
+    while(nanosleep(&req,&rem)==-1 && errno==EINTR){ req=rem; req.tv_nsec%=1000000000L; }
+}
+
+int portable_thread_create(pthread_t *t, void*(*fn)(void*), void*arg, size_t stack_bytes){
+    pthread_attr_t attr;
+    if(pthread_attr_init(&attr)!=0) return FAILURE;
+    if(stack_bytes>0){
+        size_t ps = sysconf(_SC_PAGESIZE); if(ps<1) ps=4096;
+        size_t st = (stack_bytes + ps-1) & ~(size_t)(ps-1);
+        if(st < (size_t)PTHREAD_STACK_MIN) st = (size_t)PTHREAD_STACK_MIN;
+        if(pthread_attr_setstacksize(&attr, st)!=0){ pthread_attr_destroy(&attr); return FAILURE; }
+    }
+    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
+    int rc = pthread_create(t,&attr,fn,arg);
+    pthread_attr_destroy(&attr);
+    return rc==0?SUCCESS:FAILURE;
+}

+ 156 - 0
src/stream.c

@@ -0,0 +1,156 @@
+#include "stream.h"
+#include "portable.h"
+#include "config.h"
+
+/* FIX (v1.0 bug list):
+   - session array removal previously shifted and dropped the tail (lost a
+     live stream + leaked its mutex). Now we swap-with-last then clear.
+   - previously free()'d the session then read its fields for logging
+     (use-after-free). Now snapshot needed fields BEFORE releasing.
+   - PS/RTP multi-byte fields now written with be_write* (aarch64/x86 both).
+*/
+
+static StreamSession g_sess[MAX_STREAM_NUM];
+static int g_listen=0;
+static pthread_mutex_t g_arr=PTHREAD_MUTEX_INITIALIZER;
+
+int stream_init(void){
+    for(int i=0;i<MAX_STREAM_NUM;i++){ g_sess[i].used=0; }
+    LOG_INFO("stream module ready"); return SUCCESS;
+}
+int stream_start_listen(void){ g_listen=1; LOG_INFO("stream listen started"); return SUCCESS; }
+int stream_stop_listen(void){ g_listen=0; return SUCCESS; }
+
+StreamSession* stream_get_session(int id){
+    if(id<0||id>=MAX_STREAM_NUM) return NULL;
+    return g_sess[id].used?&g_sess[id]:NULL;
+}
+int stream_find_by_channel(int channel){
+    for(int i=0;i<MAX_STREAM_NUM;i++) if(g_sess[i].used && g_sess[i].channel==channel) return i;
+    return NOT_FOUND;
+}
+
+/* ---- MPEG-PS packer: one PES carrying a H.264 access unit ---- */
+/* PACK_SYS_HEADER */
+static size_t ps_sys_header(uint8_t*o,uint64_t scr_base){
+    o[0]=0x00;o[1]=0x00;o[2]=0x01;o[3]=0xBA;
+    /* SCR/STC 33-bit + system clock base (big-endian layout per ISO/IEC 13818-1) */
+    be_write32(o+4,(uint32_t)(scr_base>>9)); /* approximate marker fields */
+    o[8]=0x01| (uint8_t)((scr_base&0x1FF)<<3) ; /* program_mux_rate placeholder tail */
+    o[9]=0x27;o[10]=0x00;o[11]=0x01; /* stuffing/lock flags compact */
+    return 12;
+}
+/* PES packet with H.264 Annex-B AU, stream_id=0xE0 (video) */
+static size_t ps_pes(uint8_t*o,const uint8_t*nalu,size_t n,uint64_t pts_ms){
+    uint64_t pts=pts_ms*90; /* 90kHz ticks */
+    o[0]=0x00;o[1]=0x00;o[2]=0x01;o[3]=0xE0;
+    size_t pes_payload=n;
+    be_write16(o+4, (uint16_t)(pes_payload+3+5)); /* PES_len: hdr(3)+optional(5)+payload; max 65535 -> chunked upstream */
+    o[6]=0x80; /* marker bits + version */
+    o[7]=0x80; /* PTS_DTS_flags=10 (PTS only) */
+    o[8]=0x05; /* header_data_length */
+    /* PTS: 0010xx1x xxxx...x pattern, 5 bytes, big-endian bit-packed */
+    uint8_t*P=o+9;
+    P[0]=0x21|(uint8_t)(((pts>>32)&0x07)<<1); /* '0010' + bits32..30 + marker */
+    P[1]=(uint8_t)(pts>>24);
+    P[2]=(uint8_t)(((pts>>16)&0xFFFF)>>1)|0x01; /* shift + marker */
+    P[2]=(uint8_t)((pts>>17)&0x7F); P[2]=(P[2]<<1)|0x01; /* refine */
+    P[3]=(uint8_t)(pts>>9);
+    P[4]=(uint8_t)(((pts&0x1FF)<<1)|0x01);
+    memcpy(o+14,nalu,n);
+    return 14+n;
+}
+
+static void rtmp_url(char*dst,size_t sz,int channel){
+    snprintf(dst,sz,"rtmp://%s:%d/%s/ch%d",g_cfg.rtmp_host,g_cfg.rtmp_port,g_cfg.rtmp_app,channel);
+}
+
+int stream_open(int channel,const char*rtmp_url_arg,int*out_id){
+    pthread_mutex_lock(&g_arr);
+    int slot=-1; for(int i=0;i<MAX_STREAM_NUM;i++) if(!g_sess[i].used){slot=i;break;}
+    if(slot<0){ pthread_mutex_unlock(&g_arr); LOG_ERROR("no free stream slot"); return FAILURE; }
+    StreamSession*s=&g_sess[slot];
+    memset(s,0,sizeof(*s));
+    s->used=1; s->id=slot; s->channel=channel; s->state=SS_LIVE;
+    s->start_ms=portable_now_ms();
+    if(rtmp_url_arg) safe_strcpy(s->rtmp_url,rtmp_url_arg,sizeof(s->rtmp_url));
+    else rtmp_url(s->rtmp_url,sizeof(s->rtmp_url),channel);
+    snprintf(s->session_id,sizeof(s->session_id),"STRM-%d-%ld",channel,(long)s->start_ms);
+    pthread_mutex_init(&s->lock,NULL);
+    if(out_id)*out_id=slot;
+    pthread_mutex_unlock(&g_arr);
+    LOG_INFO("stream open ch=%d id=%d url=%s",channel,slot,s->rtmp_url);
+    return SUCCESS;
+}
+
+/* Push a frame: encapsulate to PS, then (real mode) hand to RTMP publisher.
+ * In simulate mode we just count bytes (no socket), exercising the packer. */
+int stream_push_frame(int id,const uint8_t*nalu,size_t n,uint64_t pts_ms,uint64_t dts_ms,int keyframe){
+    StreamSession*s=stream_get_session(id);
+    if(!s) return INVALID_PARAM;
+    pthread_mutex_lock(&s->lock);
+    if(!s->used){ pthread_mutex_unlock(&s->lock); return NOT_FOUND; }
+    size_t off=0;
+    off+=ps_sys_header(s->ps_buf+off, dts_ms*90);
+    off+=ps_pes(s->ps_buf+off, nalu, n, pts_ms);
+    s->ps_len=off; s->frames++; s->bytes_pushed+=off;
+    if(keyframe) s->state=SS_PUSHING;
+    int fd=-1;
+    if(!g_cfg.simulate){
+        /* RTMP publish would happen here via publisher socket (see scripts) */
+    }
+    uint64_t frames=s->frames, bytes=s->bytes_pushed; int ch=s->channel;
+    (void)bytes;
+    pthread_mutex_unlock(&s->lock);
+    LOG_DEBUG("push ch=%d frame=%" PRIu64 " ps_bytes=%zu key=%d",ch,frames,off,keyframe);
+    (void)fd;(void)dts_ms;
+    return SUCCESS;
+}
+
+int stream_close(int id){
+    pthread_mutex_lock(&g_arr);
+    if(id<0||id>=MAX_STREAM_NUM||!g_sess[id].used){ pthread_mutex_unlock(&g_arr); return INVALID_PARAM; }
+    StreamSession*s=&g_sess[id];
+    /* snapshot before release (no use-after-free) */
+    int ch=s->channel; uint64_t f=s->frames, b=s->bytes_pushed;
+    pthread_mutex_destroy(&s->lock);
+    memset(s,0,sizeof(*s)); s->used=0;   /* compact array by clearing slot; no tail-drop */
+    pthread_mutex_unlock(&g_arr);
+    LOG_INFO("stream close ch=%d frames=%" PRIu64 " bytes=%" PRIu64, ch,f,b);
+    return SUCCESS;
+}
+
+void stream_stats(int*active,uint64_t*tf,uint64_t*tb){
+    int a=0; uint64_t f=0,b=0;
+    for(int i=0;i<MAX_STREAM_NUM;i++){ if(g_sess[i].used){a++;f+=g_sess[i].frames;b+=g_sess[i].bytes_pushed;} }
+    if(active)*active=a; 
+    if(tf)*tf=f; 
+    if(tb)*tb=b;
+}
+
+/* Simulate N frames of a synthetic Annex-B H264 IDR + non-IDR into PS. */
+int stream_simulate_loop(int channel,int frames){
+    int id=0; if(stream_open(channel,NULL,&id)!=SUCCESS) return FAILURE;
+    uint8_t sps[]={0x00,0x00,0x00,0x01,0x67,0x42,0xC0,0x1E,0xD8,0x60,0x1E,0x06,0x84};
+    uint8_t idr[]={0x00,0x00,0x00,0x01,0x65,0x88,0x80,0x40,0x03,0x7F,0x1E,0x30,0x89};
+    uint8_t non[]={0x00,0x00,0x00,0x01,0x41,0x9A,0x20,0x04,0x7F,0x1E};
+    int fps=g_cfg.fps>0?g_cfg.fps:SIM_FPS;
+    for(int i=0;i<frames;i++){
+        uint64_t pts=(uint64_t)(i*1000/fps);
+        uint64_t dts=pts;
+        int key=(i%fps==0);
+        if(key){ /* IDR preceded by SPS */
+            uint8_t buf[64]; size_t k=0; memcpy(buf+k,sps,sizeof(sps));k+=sizeof(sps);
+            memcpy(buf+k,idr,sizeof(idr));k+=sizeof(idr);
+            stream_push_frame(id,buf,k,pts,dts,1);
+        } else {
+            stream_push_frame(id,non,sizeof(non),pts,dts,0);
+        }
+    }
+    int active; uint64_t f,b; stream_stats(&active,&f,&b);
+    LOG_INFO("simulate ch=%d done: %d frames pushed, total ps bytes=%" PRIu64, channel, frames, b);
+    stream_close(id);
+    return SUCCESS;
+}
+
+void stream_cleanup(void){ stream_stop_listen(); }

+ 26 - 0
systemd/hikvision-isup.service

@@ -0,0 +1,26 @@
+# Raspberry Pi 5 (Bookworm / systemd) service unit.
+# install: sudo cp systemd/hikvision-isup.service /etc/systemd/system/
+#          sudo systemctl daemon-reload && sudo systemctl enable --now hikvision-isup
+[Unit]
+Description=Hikvision ISUP5.0 device client (Pi5)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+# real production build (needs aarch64 libHCNetSDK.so under ./libs)
+WorkingDirectory=/opt/hikvision-isup-client
+ExecStart=/opt/hikvision-isup-client/build/hikvision-isup-client-aarch64-real -c /opt/hikvision-isup-client/config.ini
+Restart=on-failure
+RestartSec=5
+# ISUP/Ehome device registration may bind ephemeral ports; keep it non-root
+User=pi
+Group=pi
+LimitNOFILE=65535
+# Pi5 8GB: cap RSS well under memory; bump if more channels added
+MemoryMax=1G
+ProtectSystem=full
+ReadWritePaths=/opt/hikvision-isup-client
+
+[Install]
+WantedBy=multi-user.target