Commit d92f8afb authored by 957dd's avatar 957dd

加入了跟app的通信,网络配置那些优化

parent 6edd4759
......@@ -885,3 +885,147 @@ void device_send_saved_wifi() {
}
cJSON_Delete(root);
}
/* ==================== App WiFi 管理 (UDP 18888) 实现 ==================== */
/* 从 .nmconnection 文件读取 psk= 字段。文件权限 root:root 600,
* 设备进程以 root 运行可直接读取。返回 0=成功, -1=失败/无密码 */
static int read_wifi_psk_from_profile(const char *ssid, char *psk_out, size_t psk_sz) {
char filepath[512];
FILE *fp;
char line[256];
char *p;
int found = 0;
if (!ssid || !psk_out || psk_sz == 0)
return -1;
psk_out[0] = '\0';
/* 主路径:/etc/NetworkManager/system-connections/<ssid>.nmconnection */
snprintf(filepath, sizeof(filepath), "%s%s.nmconnection", NM_CONN_DIR, ssid);
fp = fopen(filepath, "r");
if (!fp)
return -1;
while (fgets(line, sizeof(line), fp)) {
trim_line(line);
/* 匹配 psk= 开头(前面可能有空格) */
p = strchr(line, 'p');
while (p) {
if (strncmp(p, "psk=", 4) == 0) {
const char *val = p + 4;
strncpy(psk_out, val, psk_sz - 1);
psk_out[psk_sz - 1] = '\0';
found = 1;
break;
}
p = strchr(p + 1, 'p');
}
if (found)
break;
}
fclose(fp);
return found ? 0 : -1;
}
/* 查询已保存 WiFi 列表,返回 cJSON 数组(调用方负责 cJSON_Delete) */
cJSON *app_wifi_list_query(void) {
saved_wifi_entry_t entries[MAX_WIFI_ENTRIES];
int count;
cJSON *list;
int active_idx = -1;
/* 刷新当前连接 SSID,确保 active 判断准确 */
get_current_wifi();
count = collect_saved_wifi_entries(entries, MAX_WIFI_ENTRIES);
if (count < 0)
count = 0;
list = cJSON_CreateArray();
if (!list)
return NULL;
for (int i = 0; i < count; i++) {
cJSON *item = cJSON_CreateObject();
if (!item)
continue;
cJSON_AddStringToObject(item, "ssid", entries[i].ssid);
/* password:从 .nmconnection 读取;默认 WiFi 用内置密码兜底 */
char psk[WIFI_PSK_MAX_LENGTH + 1] = {0};
if (read_wifi_psk_from_profile(entries[i].ssid, psk, sizeof(psk)) == 0)
cJSON_AddStringToObject(item, "password", psk);
else if (is_default_wifi_ssid(entries[i].ssid))
cJSON_AddStringToObject(item, "password", default_password);
else
cJSON_AddStringToObject(item, "password", "");
/* active:wifi_profile_is_in_use 内部已综合判断 */
int in_use = wifi_profile_is_in_use(entries[i].ssid);
cJSON_AddBoolToObject(item, "active", in_use ? 1 : 0);
if (in_use && active_idx < 0)
active_idx = i;
/* protected:默认 WiFi (jking) 受保护 */
cJSON_AddBoolToObject(item, "protected", is_default_wifi_ssid(entries[i].ssid) ? 1 : 0);
cJSON_AddItemToArray(list, item);
}
(void)active_idx;
return list;
}
/* 切换当前连接到已保存的指定 SSID */
int app_wifi_switch(const char *ssid) {
saved_wifi_entry_t entries[MAX_WIFI_ENTRIES];
int count;
int found = 0;
char psk[WIFI_PSK_MAX_LENGTH + 1] = {0};
if (!ssid || !ssid[0])
return -1;
count = collect_saved_wifi_entries(entries, MAX_WIFI_ENTRIES);
if (count < 0)
return -1;
for (int i = 0; i < count; i++) {
if (strcmp(entries[i].ssid, ssid) == 0) {
found = 1;
break;
}
}
if (!found)
return -1;
/* 读取已保存密码,无密码则空串连接(开放网络) */
if (read_wifi_psk_from_profile(ssid, psk, sizeof(psk)) != 0 &&
is_default_wifi_ssid(ssid))
snprintf(psk, sizeof(psk), "%s", default_password);
/* 复用现有连接逻辑:已存在则直接 connect,无密码走无密码分支。
* 区分返回码:未找到返回 -1,连接失败返回 -2,便于 agent 给准确提示。 */
if (orange_pi_connect_wifi(ssid, psk[0] ? psk : NULL) == 0)
return 0;
my_zlog_error("app_wifi_switch: WiFi 已保存但连接失败 SSID=%s", ssid);
return -2;
}
/* 删除已保存的指定 SSID(受保护的 jking 不可删) */
int app_wifi_delete(const char *ssid) {
if (!ssid || !ssid[0])
return -1;
/* 受保护检查(与 device_wifi_delete_saved_profile 内部一致,提前返回便于 agent 区分原因) */
if (is_default_wifi_ssid(ssid))
return -2;
/* device_wifi_delete_saved_profile 内部还会检查是否在用,
* 在用的返回 -3。这里统一映射为 -1 失败,由 agent 组织 message。 */
int rc = device_wifi_delete_saved_profile(ssid);
if (rc == 0)
return 0;
return -1;
}
......@@ -18,4 +18,19 @@ int orange_pi_save_wifi(const char *ssid, const char *password);
/* 保存并立即连接 WiFi(含满 10 个自动删最旧逻辑) */
int orange_pi_connect_wifi(const char *ssid, const char *password);
/* ===== App WiFi 管理 (UDP 18888) 接口 ===== */
/* 查询已保存 WiFi 列表,返回 cJSON 数组(调用方负责 cJSON_Delete):
* 每项含 ssid/password/active/protected 四字段。
* active/protected 由内部判断,password 从 .nmconnection 读取。 */
cJSON *app_wifi_list_query(void);
/* 切换当前连接到已保存的指定 SSID。
* 返回 0=已发起切换(异步), -1=未找到/失败 */
int app_wifi_switch(const char *ssid);
/* 删除已保存的指定 SSID(受保护的 jking 不可删)。
* 返回 0=成功, -1=未找到/失败, -2=受保护不可删 */
int app_wifi_delete(const char *ssid);
#endif
\ No newline at end of file
No preview for this file type
......@@ -12,7 +12,7 @@
#include "tailscale_deploy.h"
#include "runtime_deps.h"
#include "wifi_config_agent.h"
#include "stream_debug_agent.h"
#include "device_debug_agent.h"
#include "webrtcpush_run.h"
#endif
\ No newline at end of file
......@@ -22,7 +22,7 @@ file(GLOB_RECURSE MODULES_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/webrtcpush/*.c
${CMAKE_CURRENT_SOURCE_DIR}/runtime_deps/*.c
${CMAKE_CURRENT_SOURCE_DIR}/wifi_config/*.c
${CMAKE_CURRENT_SOURCE_DIR}/stream_debug/*.c
${CMAKE_CURRENT_SOURCE_DIR}/device_debug/*.c
)
set(MODULES_SOURCES
......@@ -43,7 +43,7 @@ set(MODULES_INCLUDE_DIRS
${CMAKE_CURRENT_SOURCE_DIR}/webrtcpush
${CMAKE_CURRENT_SOURCE_DIR}/runtime_deps
${CMAKE_CURRENT_SOURCE_DIR}/wifi_config
${CMAKE_CURRENT_SOURCE_DIR}/stream_debug
${CMAKE_CURRENT_SOURCE_DIR}/device_debug
${WEBRTCPUSH_GST_INCLUDE_DIRS}
${WEBRTCPUSH_JSON_INCLUDE_DIRS}
${WEBRTCPUSH_SOUP_INCLUDE_DIRS}
......
#include "stream_debug_agent.h"
#include "stream_debug_config.h"
#include "device_debug_agent.h"
#include "device_debug_config.h"
#include "browser_open.h"
#include "webrtcpush_run.h"
#include "wifi_config_agent.h"
......
#ifndef STREAM_DEBUG_AGENT_H__
#define STREAM_DEBUG_AGENT_H__
#ifndef DEVICE_DEBUG_AGENT_H__
#define DEVICE_DEBUG_AGENT_H__
#include <netinet/in.h>
#include <cJSON.h>
......
#include "device_diag.h"
#include "device_debug_config.h"
#include "browser_open.h"
#include "mylog.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dirent.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <stdint.h>
#include <fcntl.h>
#define DIAG_LOG_PASSWORD "fcrs666"
#define DIAG_LOG_DIR "/home/orangepi/car/master/log"
#define DIAG_LOG_TCP_PORT 18889
#define DIAG_LOG_MAX_FILES 2
#define DIAG_LOG_FILENAME_MAX 128
#define DIAG_LOG_PATH_MAX 512
#define DIAG_BROWSER_URL_MAX 512
#define DIAG_MESSAGE_MAX 256
static void diag_send_json(int sock, const struct sockaddr_in *addr, const char *json)
{
if (!json)
return;
sendto(sock, json, strlen(json), 0,
(const struct sockaddr *)addr, sizeof(*addr));
}
/* ===== 简易自检 ===== */
static int diag_check_camera(char *name, size_t name_sz, char *msg, size_t msg_sz)
{
DIR *dir;
struct dirent *ent;
int found = 0;
char path[64];
dir = opendir("/dev");
if (!dir) {
snprintf(name, name_sz, "未知");
snprintf(msg, msg_sz, "无法打开 /dev");
return 0;
}
while ((ent = readdir(dir)) != NULL) {
if (strncmp(ent->d_name, "video", 5) != 0)
continue;
if (strstr(ent->d_name, "dec0") || strstr(ent->d_name, "enc0"))
continue;
size_t len = strlen(ent->d_name);
if (len < 6)
continue;
int is_num = 1;
for (size_t i = 5; i < len; i++) {
if (ent->d_name[i] < '0' || ent->d_name[i] > '9') {
is_num = 0;
break;
}
}
if (!is_num)
continue;
snprintf(path, sizeof(path), "/dev/%s", ent->d_name);
int fd = open(path, O_RDWR);
if (fd >= 0) {
close(fd);
snprintf(name, name_sz, "USB Camera");
snprintf(msg, msg_sz, "/dev/%s", ent->d_name);
found = 1;
break;
}
}
closedir(dir);
if (!found) {
snprintf(name, name_sz, "未检测到");
snprintf(msg, msg_sz, "无可用 /dev/video* 设备");
}
return found;
}
static int diag_check_mic(char *name, size_t name_sz, char *msg, size_t msg_sz)
{
FILE *fp;
char line[512];
int found = 0;
fp = popen("arecord -l 2>/dev/null", "r");
if (!fp) {
snprintf(name, name_sz, "未知");
snprintf(msg, msg_sz, "arecord 不可用");
return 0;
}
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, "card") && strstr(line, "USB Audio")) {
char *p = strstr(line, "card");
snprintf(name, name_sz, "USB Audio");
snprintf(msg, msg_sz, "%s", p ? p : "USB Audio");
msg[strcspn(msg, "\n")] = '\0';
found = 1;
break;
}
}
pclose(fp);
if (!found) {
snprintf(name, name_sz, "未检测到");
snprintf(msg, msg_sz, "无 USB Audio 设备");
}
return found;
}
static int diag_check_speaker(char *name, size_t name_sz, char *msg, size_t msg_sz)
{
FILE *fp;
char line[512];
int found = 0;
fp = popen("aplay -l 2>/dev/null", "r");
if (!fp) {
snprintf(name, name_sz, "未知");
snprintf(msg, msg_sz, "aplay 不可用");
return 0;
}
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, "card") &&
(strstr(line, "rk809") || strstr(line, "analog") ||
strstr(line, "hdmi") || strstr(line, "USB Audio"))) {
char *p = strstr(line, "card");
snprintf(name, name_sz, "Audio Device");
snprintf(msg, msg_sz, "%s", p ? p : "Audio Device");
msg[strcspn(msg, "\n")] = '\0';
found = 1;
break;
}
}
pclose(fp);
if (!found) {
snprintf(name, name_sz, "未检测到");
snprintf(msg, msg_sz, "无音频输出设备");
}
return found;
}
static int diag_check_browser(char *name, size_t name_sz, char *msg, size_t msg_sz,
char *url, size_t url_sz)
{
bool running = is_browser_running();
if (running) {
snprintf(name, name_sz, "Chromium");
snprintf(msg, name_sz, "running");
char tmp_url[DIAG_BROWSER_URL_MAX] = {0};
bool tmp_running = false;
browser_get_stream_url_info(tmp_url, sizeof(tmp_url), &tmp_running);
snprintf(url, url_sz, "%s", tmp_url);
} else {
snprintf(name, name_sz, "未运行");
snprintf(msg, msg_sz, "chromium 进程不存在");
url[0] = '\0';
}
return running ? 1 : 0;
}
void device_diag_handle_check(int sock, const struct sockaddr_in *addr)
{
cJSON *resp;
cJSON *item;
char *payload;
char cam_name[64], cam_msg[128];
char mic_name[64], mic_msg[128];
char spk_name[64], spk_msg[128];
char brw_name[64], brw_msg[128];
char brw_url[DIAG_BROWSER_URL_MAX];
int cam_ok = diag_check_camera(cam_name, sizeof(cam_name), cam_msg, sizeof(cam_msg));
int mic_ok = diag_check_mic(mic_name, sizeof(mic_name), mic_msg, sizeof(mic_msg));
int spk_ok = diag_check_speaker(spk_name, sizeof(spk_name), spk_msg, sizeof(spk_msg));
int brw_ok = diag_check_browser(brw_name, sizeof(brw_name), brw_msg, sizeof(brw_msg),
brw_url, sizeof(brw_url));
resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "cmd", "device_check_response");
item = cJSON_CreateObject();
cJSON_AddBoolToObject(item, "detected", cam_ok ? 1 : 0);
cJSON_AddStringToObject(item, "name", cam_name);
cJSON_AddStringToObject(item, "message", cam_msg);
cJSON_AddItemToObject(resp, "camera", item);
item = cJSON_CreateObject();
cJSON_AddBoolToObject(item, "detected", mic_ok ? 1 : 0);
cJSON_AddStringToObject(item, "name", mic_name);
cJSON_AddStringToObject(item, "message", mic_msg);
cJSON_AddItemToObject(resp, "mic", item);
item = cJSON_CreateObject();
cJSON_AddBoolToObject(item, "detected", spk_ok ? 1 : 0);
cJSON_AddStringToObject(item, "name", spk_name);
cJSON_AddStringToObject(item, "message", spk_msg);
cJSON_AddItemToObject(resp, "speaker", item);
item = cJSON_CreateObject();
cJSON_AddBoolToObject(item, "detected", brw_ok ? 1 : 0);
cJSON_AddStringToObject(item, "name", brw_name);
cJSON_AddStringToObject(item, "message", brw_msg);
cJSON_AddItemToObject(resp, "browser", item);
if (brw_url[0])
cJSON_AddStringToObject(resp, "browserUrl", brw_url);
payload = cJSON_PrintUnformatted(resp);
if (payload) {
diag_send_json(sock, addr, payload);
free(payload);
}
cJSON_Delete(resp);
my_zlog_info("设备调试: 自检完成 cam=%d mic=%d spk=%d brw=%d",
cam_ok, mic_ok, spk_ok, brw_ok);
}
/* ===== 浏览器控制 ===== */
void device_diag_handle_browser_ctrl(int sock, const struct sockaddr_in *addr, const char *op)
{
cJSON *resp;
char *payload;
int success = 0;
const char *msg = "";
const char *cmd_name;
resp = cJSON_CreateObject();
if (strcmp(op, "refresh") == 0) {
cmd_name = "browser_refresh_result";
if (!is_browser_running()) {
success = 0;
msg = "刷新失败,浏览器未运行";
} else {
refresh_cam();
success = 1;
msg = "浏览器已刷新";
}
} else if (strcmp(op, "reopen") == 0) {
cmd_name = "browser_reopen_result";
char url[DIAG_BROWSER_URL_MAX] = {0};
char effective[DIAG_BROWSER_URL_MAX] = {0};
bool running = false;
browser_get_stream_url_info(url, sizeof(url), &running);
if (!running || !url[0]) {
success = 0;
msg = "重开失败,浏览器未运行或无 URL";
} else {
browser_begin_external_switch();
{ int _r = system("pkill -f chromium-browser 2>/dev/null"); (void)_r; }
usleep(800000);
if (browser_switch_stream_url(url, effective, sizeof(effective)) == 0) {
success = 1;
msg = "浏览器已重新打开";
} else {
success = 0;
msg = "重开失败,URL 无效";
}
browser_end_external_switch();
}
} else {
cmd_name = "browser_refresh_result";
success = 0;
msg = "未知操作";
}
cJSON_AddStringToObject(resp, "cmd", cmd_name);
cJSON_AddBoolToObject(resp, "success", success ? 1 : 0);
cJSON_AddStringToObject(resp, "message", msg);
payload = cJSON_PrintUnformatted(resp);
if (payload) {
diag_send_json(sock, addr, payload);
free(payload);
}
cJSON_Delete(resp);
my_zlog_info("设备调试: 浏览器控制 op=%s success=%d", op, success);
}
/* ===== 日志获取 ===== */
typedef struct {
char filename[DIAG_LOG_FILENAME_MAX];
long size;
time_t mtime;
} log_entry_t;
static int diag_log_compare_mtime(const void *a, const void *b)
{
const log_entry_t *ea = (const log_entry_t *)a;
const log_entry_t *eb = (const log_entry_t *)b;
if (ea->mtime > eb->mtime) return -1;
if (ea->mtime < eb->mtime) return 1;
return 0;
}
static int diag_find_recent_logs(log_entry_t *entries, int max_count)
{
DIR *dir;
struct dirent *ent;
struct stat st;
char filepath[DIAG_LOG_PATH_MAX];
log_entry_t all[32];
int count = 0;
dir = opendir(DIAG_LOG_DIR);
if (!dir) {
my_zlog_error("设备调试: 无法打开日志目录 %s", DIAG_LOG_DIR);
return 0;
}
while ((ent = readdir(dir)) != NULL && count < 32) {
if (strncmp(ent->d_name, "log_", 4) != 0)
continue;
if (!strstr(ent->d_name, ".log"))
continue;
snprintf(filepath, sizeof(filepath), "%s/%s", DIAG_LOG_DIR, ent->d_name);
if (stat(filepath, &st) != 0)
continue;
strncpy(all[count].filename, ent->d_name, DIAG_LOG_FILENAME_MAX - 1);
all[count].filename[DIAG_LOG_FILENAME_MAX - 1] = '\0';
all[count].size = (long)st.st_size;
all[count].mtime = st.st_mtime;
count++;
}
closedir(dir);
if (count == 0)
return 0;
qsort(all, count, sizeof(log_entry_t), diag_log_compare_mtime);
if (count > max_count)
count = max_count;
for (int i = 0; i < count; i++)
entries[i] = all[i];
return count;
}
static void *diag_log_tcp_thread(void *arg)
{
log_entry_t *entries = (log_entry_t *)arg;
int server_fd, client_fd;
struct sockaddr_in addr;
int opt = 1;
char filename[DIAG_LOG_FILENAME_MAX + 2];
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
my_zlog_error("设备调试: TCP socket 创建失败");
free(entries);
return NULL;
}
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(DIAG_LOG_TCP_PORT);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
my_zlog_error("设备调试: TCP bind %d 失败", DIAG_LOG_TCP_PORT);
close(server_fd);
free(entries);
return NULL;
}
if (listen(server_fd, 1) < 0) {
my_zlog_error("设备调试: TCP listen 失败");
close(server_fd);
free(entries);
return NULL;
}
my_zlog_info("设备调试: TCP 日志服务已启动,等待连接 port=%d", DIAG_LOG_TCP_PORT);
struct timeval tv;
tv.tv_sec = 30;
tv.tv_usec = 0;
setsockopt(server_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
client_fd = accept(server_fd, NULL, NULL);
if (client_fd < 0) {
my_zlog_error("设备调试: TCP accept 超时或失败");
close(server_fd);
free(entries);
return NULL;
}
while (1) {
ssize_t n;
size_t flen = 0;
int c;
char ch;
char filepath[DIAG_LOG_PATH_MAX];
FILE *f;
long fsize;
uint8_t sizebuf[8];
char buf[4096];
size_t r;
while (flen < sizeof(filename) - 1) {
n = recv(client_fd, &ch, 1, 0);
if (n <= 0) {
close(client_fd);
close(server_fd);
free(entries);
my_zlog_info("设备调试: TCP 日志传输完成");
return NULL;
}
if (ch == '\n')
break;
filename[flen++] = ch;
}
filename[flen] = '\0';
int valid = 1;
for (size_t i = 0; i < flen; i++) {
c = (unsigned char)filename[i];
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '.')) {
valid = 0;
break;
}
}
if (!valid || flen == 0) {
my_zlog_error("设备调试: 非法文件名请求 '%s'", filename);
memset(sizebuf, 0, 8);
send(client_fd, sizebuf, 8, 0);
continue;
}
snprintf(filepath, sizeof(filepath), "%s/%s", DIAG_LOG_DIR, filename);
f = fopen(filepath, "rb");
if (!f) {
my_zlog_error("设备调试: 日志文件不存在 %s", filepath);
memset(sizebuf, 0, 8);
send(client_fd, sizebuf, 8, 0);
continue;
}
fseek(f, 0, SEEK_END);
fsize = ftell(f);
rewind(f);
uint64_t sz = (uint64_t)fsize;
for (int i = 7; i >= 0; i--) {
sizebuf[i] = (uint8_t)(sz & 0xFF);
sz >>= 8;
}
send(client_fd, sizebuf, 8, 0);
while ((r = fread(buf, 1, sizeof(buf), f)) > 0) {
size_t sent = 0;
while (sent < r) {
ssize_t s = send(client_fd, buf + sent, r - sent, 0);
if (s <= 0)
break;
sent += s;
}
if (sent < r)
break;
}
fclose(f);
my_zlog_info("设备调试: 已发送日志 %s (%ld bytes)", filename, fsize);
}
close(client_fd);
close(server_fd);
free(entries);
return NULL;
}
void device_diag_handle_log_fetch(int sock, const struct sockaddr_in *addr, cJSON *root)
{
cJSON *pwd_item;
const char *password = NULL;
cJSON *resp;
cJSON *arr;
char *payload;
log_entry_t entries[DIAG_LOG_MAX_FILES];
log_entry_t *thread_entries;
int n;
pthread_t tid;
pthread_attr_t attr;
pwd_item = cJSON_GetObjectItemCaseSensitive(root, "password");
if (!cJSON_IsString(pwd_item) || !pwd_item->valuestring) {
password = "";
} else {
password = pwd_item->valuestring;
}
if (strcmp(password, DIAG_LOG_PASSWORD) != 0) {
resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "cmd", "log_fetch_ready");
cJSON_AddNumberToObject(resp, "tcpPort", 0);
cJSON_AddItemToObject(resp, "files", cJSON_CreateArray());
cJSON_AddStringToObject(resp, "message", "密码错误");
payload = cJSON_PrintUnformatted(resp);
if (payload) {
diag_send_json(sock, addr, payload);
free(payload);
}
cJSON_Delete(resp);
my_zlog_warn("设备调试: log_fetch 密码错误");
return;
}
n = diag_find_recent_logs(entries, DIAG_LOG_MAX_FILES);
if (n <= 0) {
resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "cmd", "log_fetch_ready");
cJSON_AddNumberToObject(resp, "tcpPort", 0);
cJSON_AddItemToObject(resp, "files", cJSON_CreateArray());
cJSON_AddStringToObject(resp, "message", "设备无日志文件");
payload = cJSON_PrintUnformatted(resp);
if (payload) {
diag_send_json(sock, addr, payload);
free(payload);
}
cJSON_Delete(resp);
my_zlog_warn("设备调试: 无日志文件可发送");
return;
}
thread_entries = (log_entry_t *)malloc(sizeof(log_entry_t) * n);
if (!thread_entries) {
my_zlog_error("设备调试: malloc 失败");
return;
}
for (int i = 0; i < n; i++)
thread_entries[i] = entries[i];
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
if (pthread_create(&tid, &attr, diag_log_tcp_thread, thread_entries) != 0) {
my_zlog_error("设备调试: TCP 线程创建失败");
free(thread_entries);
pthread_attr_destroy(&attr);
resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "cmd", "log_fetch_ready");
cJSON_AddNumberToObject(resp, "tcpPort", 0);
cJSON_AddItemToObject(resp, "files", cJSON_CreateArray());
cJSON_AddStringToObject(resp, "message", "设备 TCP 服务启动失败");
payload = cJSON_PrintUnformatted(resp);
if (payload) {
diag_send_json(sock, addr, payload);
free(payload);
}
cJSON_Delete(resp);
return;
}
pthread_attr_destroy(&attr);
resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "cmd", "log_fetch_ready");
cJSON_AddNumberToObject(resp, "tcpPort", DIAG_LOG_TCP_PORT);
arr = cJSON_CreateArray();
for (int i = 0; i < n; i++) {
cJSON *item = cJSON_CreateObject();
cJSON_AddStringToObject(item, "name", entries[i].filename);
cJSON_AddNumberToObject(item, "size", entries[i].size);
cJSON_AddItemToArray(arr, item);
}
cJSON_AddItemToObject(resp, "files", arr);
payload = cJSON_PrintUnformatted(resp);
if (payload) {
diag_send_json(sock, addr, payload);
free(payload);
}
cJSON_Delete(resp);
my_zlog_info("设备调试: log_fetch 已启动 TCP %d,共 %d 个日志", DIAG_LOG_TCP_PORT, n);
}
int device_diag_is_cmd(const char *cmd)
{
if (!cmd)
return 0;
if (strcmp(cmd, "device_check") == 0 ||
strcmp(cmd, "browser_refresh") == 0 ||
strcmp(cmd, "browser_reopen") == 0 ||
strcmp(cmd, "log_fetch") == 0)
return 1;
return 0;
}
#ifndef DEVICE_DIAG_H__
#define DEVICE_DIAG_H__
#include <netinet/in.h>
#include <cJSON.h>
/* ===== 设备调试协议 v4(UDP 18888) ===== */
/* 简易自检:检测 USB 摄像头/麦克风/喇叭/浏览器状态,回 device_check_response */
void device_diag_handle_check(int sock, const struct sockaddr_in *addr);
/* 浏览器控制:op="refresh" 刷新,op="reopen" 重开 */
void device_diag_handle_browser_ctrl(int sock, const struct sockaddr_in *addr, const char *op);
/* 日志获取:校验密码 fcrs666,启动 TCP 18889 传输线程,回 log_fetch_ready */
void device_diag_handle_log_fetch(int sock, const struct sockaddr_in *addr, cJSON *root);
/* 判断是否为设备诊断 cmd(device_check / browser_refresh / browser_reopen / log_fetch) */
int device_diag_is_cmd(const char *cmd);
#endif
......@@ -45,6 +45,23 @@ static const GstNeed s_webrtc_gst_required[] = {
static gboolean s_webrtc_push_ok = FALSE;
static gboolean s_webrtc_push_disabled = FALSE;
/* GLib/GObject/GStreamer 日志重定向到 zlog
* 平台底层库(libsoup/libnice/GStreamer)的 INFO/MESSAGE/DEBUG 级别
* 通常是 WebSocket 心跳帧/消息分发等 3s 一次的常规事件,
* 下沉到 debug 后 release 发布版(LOG_PRODUCTION)自动屏蔽,
* 避免日志污染。ERROR/WARNING 保留。 */
static void glib_log_handler(const gchar *log_domain, GLogLevelFlags log_level,
const gchar *message, gpointer user_data)
{
(void)user_data;
if (log_level & G_LOG_LEVEL_ERROR)
my_zlog_error("glib/%s: %s", log_domain ? log_domain : "?", message ? message : "(null)");
else if (log_level & (G_LOG_LEVEL_CRITICAL | G_LOG_LEVEL_WARNING))
my_zlog_warn("glib/%s: %s", log_domain ? log_domain : "?", message ? message : "(null)");
else
my_zlog_debug("glib/%s: %s", log_domain ? log_domain : "?", message ? message : "(null)");
}
static const char *s_chromium_bins[] = {
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
......@@ -283,8 +300,11 @@ static gboolean gst_check_required_plugins(void)
static int ensure_gstreamer_plugins(void)
{
if (!gst_is_initialized())
if (!gst_is_initialized()) {
gst_init(NULL, NULL);
/* 把 GLib/GObject/GStreamer 警告重定向到 zlog,避免污染终端 stderr */
g_log_set_default_handler(glib_log_handler, NULL);
}
if (gst_check_required_plugins()) {
my_zlog_info("runtime_deps: GStreamer plugins OK");
......
......@@ -62,7 +62,7 @@ AudioSource *audio_source_start(const char *alsa_device, char **error_message)
g_object_set(enc,
"bitrate", WEBRTCPUSH_OPUS_BITRATE,
"cbr", TRUE,
"bitrate-type", 0, /* 0=cbr */
"framesize", 20, /* 20ms 帧 */
"inband-fec", TRUE,
NULL);
......
......@@ -17,7 +17,7 @@
#define MPP_VIDEO_START_BPS WEBRTCPUSH_INITIAL_BITRATE
#define MPP_VIDEO_MIN_BPS WEBRTCPUSH_MIN_BITRATE
#define MPP_VIDEO_MAX_BPS WEBRTCPUSH_MAX_BITRATE
#define MPP_H264_PROFILE 66 /* Baseline:板端 mpph264enc 最稳 */
#define MPP_H264_PROFILE 66 /* Baseline matches negotiated 42e01f */
#define WRTC_V4L2_CAP_VIDEO_CAPTURE (1u << 0)
#define WRTC_V4L2_CAP_VIDEO_CAPTURE_MPLANE (1u << 12)
......@@ -35,6 +35,7 @@ struct MppH264Source {
GstElement *parse;
GstElement *eq;
GstElement *appsink;
GstElement *vsrc; /* v4l2src 指针,用于 recover 时更新设备 */
gboolean use_mpp;
guint8 *frame_buf;
size_t frame_size;
......@@ -282,6 +283,8 @@ static void configure_v4l2_src(GstElement *src, gboolean compressed_mjpeg)
* mmap 兼容性最好,性能损失可接受(720p@24fps)。
*/
g_object_set(src, "io-mode", 2, NULL);
/* 减少内核 DQBUF 缓冲,降低采集延迟 */
g_object_set(src, "buffer-size", 1024, NULL);
}
static GstElement *make_h264_encoder(gboolean prefer_mpp, gboolean use_test,
......@@ -399,18 +402,21 @@ static void request_keyframe(MppH264Source *src)
src->keyframe_req++;
src->pending_idr = TRUE;
/* 兜底:直接向编码器发 force-key-unit,不依赖 enc_sink_probe 触发时机 */
/* 直接向编码器 sink pad 发 force-key-unit(upstream event 标准发送方式) */
if (src->enc) {
GstEvent *ev = gst_video_event_new_upstream_force_key_unit(
GST_CLOCK_TIME_NONE, TRUE, src->keyframe_req);
if (!gst_element_send_event(src->enc, ev)) {
GstPad *sink_pad = gst_element_get_static_pad(src->enc, "sink");
if (sink_pad) {
ev = gst_video_event_new_upstream_force_key_unit(
GstEvent *ev = gst_video_event_new_upstream_force_key_unit(
GST_CLOCK_TIME_NONE, TRUE, src->keyframe_req);
gst_pad_send_event(sink_pad, ev);
gboolean ok = gst_pad_send_event(sink_pad, ev);
gst_object_unref(sink_pad);
}
if (!ok)
my_zlog_warn("mpp_h264_source: force-key-unit send failed on sink pad");
} else {
/* fallback: element_send_event */
GstEvent *ev = gst_video_event_new_upstream_force_key_unit(
GST_CLOCK_TIME_NONE, TRUE, src->keyframe_req);
gst_element_send_event(src->enc, ev);
}
}
my_zlog_info("mpp_h264_source: IDR queued (%u)", src->keyframe_req);
......@@ -690,6 +696,7 @@ MppH264Source *mpp_h264_source_start(const char *video_device, char **error_mess
}
}
src->vsrc = vsrc;
src->pipeline = pipe;
src->enc = enc;
src->parse = parse;
......@@ -881,6 +888,21 @@ gboolean mpp_h264_source_recover(MppH264Source *src)
src->pending_idr = FALSE;
src->pending_recover = FALSE;
gst_element_set_state(src->pipeline, GST_STATE_NULL);
/* 重新探测摄像头设备(USB 重枚举后可能从 video0 变成 video1/2) */
if (src->vsrc) {
gchar *new_dev = pick_v4l2_capture_device();
if (new_dev) {
gchar *old_dev = NULL;
g_object_get(src->vsrc, "device", &old_dev, NULL);
if (!old_dev || g_strcmp0(old_dev, new_dev) != 0) {
g_object_set(src->vsrc, "device", new_dev, NULL);
my_zlog_warn("mpp_h264_source: recover device changed %s -> %s",
old_dev ? old_dev : "(null)", new_dev);
}
g_free(old_dev);
g_free(new_dev);
}
}
g_usleep(300000);
ret = gst_element_set_state(src->pipeline, GST_STATE_PLAYING);
if (ret == GST_STATE_CHANGE_FAILURE) {
......
......@@ -57,6 +57,7 @@ typedef struct {
gboolean stopping;
gboolean track_open;
gboolean need_idr;
gboolean first_frame_sent; /* 首帧 IDR 不受 pacing flush 影响 */
gboolean answer_sent;
int pc;
int track;
......@@ -67,6 +68,9 @@ typedef struct {
gboolean source_pts_valid;
guint target_bitrate;
guint remb_ceiling;
guint last_remb_bitrate;
guint remb_filtered;
guint remb_down_samples;
gint64 last_bitrate_ramp_us;
gint64 last_idr_request_us;
gint64 last_stats_log_us;
......@@ -166,6 +170,8 @@ static void frame_queue_push(RtcClient *client, RtcH264Frame *frame)
{
RtcFrameQueue *q = &client->frame_queue;
RtcH264Frame *old;
gboolean overflowed = FALSE;
gboolean request_resync = FALSE;
g_mutex_lock(&q->lock);
if (q->stopping) {
......@@ -173,14 +179,32 @@ static void frame_queue_push(RtcClient *client, RtcH264Frame *frame)
rtc_h264_frame_free(frame);
return;
}
while (g_queue_get_length(&q->queue) >= q->max_depth) {
old = g_queue_pop_head(&q->queue);
if (g_queue_get_length(&q->queue) >= q->max_depth) {
overflowed = TRUE;
while ((old = g_queue_pop_head(&q->queue)) != NULL) {
q->dropped_total++;
rtc_h264_frame_free(old);
}
}
if (overflowed && !frame->is_idr) {
q->dropped_total++;
rtc_h264_frame_free(frame);
request_resync = TRUE;
} else {
g_queue_push_tail(&q->queue, frame);
g_cond_signal(&q->not_empty);
}
g_mutex_unlock(&q->lock);
/* Never continue a predictive chain after dropping an encoded P frame. */
if (request_resync) {
g_mutex_lock(&client->lock);
client->need_idr = TRUE;
client->source_pts_valid = FALSE;
client->last_idr_request_us = g_get_monotonic_time();
g_mutex_unlock(&client->lock);
my_zlog_warn("libdatachannel: encoded-frame queue overflow; request clean IDR");
}
}
static RtcH264Frame *frame_queue_pop(RtcClient *client, gint timeout_ms)
......@@ -493,14 +517,42 @@ static gboolean request_idr(RtcClient *client, gboolean bypass_throttle)
return TRUE;
}
static void log_selected_ice_path(int pc)
{
char local_candidate[512] = {0};
char remote_candidate[512] = {0};
char local_address[128] = {0};
char remote_address[128] = {0};
int pair_ret;
int local_ret;
int remote_ret;
if (pc < 0)
return;
pair_ret = rtcGetSelectedCandidatePair(
pc, local_candidate, (int)sizeof(local_candidate),
remote_candidate, (int)sizeof(remote_candidate));
local_ret = rtcGetLocalAddress(pc, local_address, (int)sizeof(local_address));
remote_ret = rtcGetRemoteAddress(pc, remote_address, (int)sizeof(remote_address));
if (pair_ret >= 0) {
my_zlog_info("libdatachannel: ICE selected local=[%s] remote=[%s] socket=%s -> %s",
local_candidate, remote_candidate,
local_ret >= 0 ? local_address : "?",
remote_ret >= 0 ? remote_address : "?");
} else {
my_zlog_warn("libdatachannel: connected but selected ICE pair unavailable (%d)",
pair_ret);
}
}
static void RTC_API on_state_change(int pc, rtcState state, void *ptr)
{
RtcClient *client = ptr;
(void)pc;
my_zlog_info("libdatachannel: peer state=%s", rtc_state_name(state));
if (!client)
return;
if (state == RTC_CONNECTED) {
log_selected_ice_path(pc);
g_mutex_lock(&client->lock);
client->source_pts_valid = FALSE;
g_mutex_unlock(&client->lock);
......@@ -520,6 +572,7 @@ static void RTC_API on_track_open(int track, void *ptr)
if (client->track == track) {
client->track_open = TRUE;
client->source_pts_valid = FALSE;
client->first_frame_sent = FALSE;
video_open = TRUE;
}
if (client->audio_track == track) {
......@@ -550,6 +603,7 @@ static void RTC_API on_track_closed(int track, void *ptr)
g_mutex_lock(&client->lock);
if (client->track == track) {
client->track_open = FALSE;
client->first_frame_sent = FALSE;
video_closed = TRUE;
}
if (client->audio_track == track) {
......@@ -574,11 +628,15 @@ static void RTC_API on_track_error(int track, const char *error, void *ptr)
static void RTC_API on_pli(int track, void *ptr)
{
RtcClient *client = ptr;
gboolean requested;
int dropped = 0;
if (!client || client->track != track)
return;
if (!request_idr(client, FALSE))
requested = request_idr(client, FALSE);
if (!requested) {
my_zlog_debug("libdatachannel: PLI throttled, skip duplicate IDR request");
return;
}
g_mutex_lock(&client->lock);
client->source_pts_valid = FALSE;
g_mutex_unlock(&client->lock);
......@@ -608,6 +666,58 @@ static guint pacing_bitrate_for_encoder(guint encoder_bitrate)
return (guint)pacing;
}
static gboolean pacing_queue_snapshot(RtcClient *client, int track,
guint *packets_out, guint *bytes_out,
guint *delay_ms_out)
{
guint packets = 0;
guint bytes = 0;
guint target;
guint pacing_bps;
int ret = -1;
if (!client || track < 0)
return FALSE;
g_mutex_lock(&client->send_lock);
if (client->track == track)
ret = rtcGetPacingQueueStats(track, &packets, &bytes);
g_mutex_unlock(&client->send_lock);
if (ret < 0)
return FALSE;
g_mutex_lock(&client->lock);
target = client->target_bitrate;
g_mutex_unlock(&client->lock);
pacing_bps = pacing_bitrate_for_encoder(target);
if (packets_out)
*packets_out = packets;
if (bytes_out)
*bytes_out = bytes;
if (delay_ms_out) {
*delay_ms_out = pacing_bps > 0
? (guint)(((guint64)bytes * 8U * 1000U + pacing_bps - 1U) /
pacing_bps)
: 0;
}
return TRUE;
}
static int clear_stale_pacing(RtcClient *client, int track)
{
int dropped = 0;
request_idr(client, TRUE);
g_mutex_lock(&client->lock);
client->source_pts_valid = FALSE;
g_mutex_unlock(&client->lock);
g_mutex_lock(&client->send_lock);
if (client->track == track)
dropped = rtcClearPacingQueue(track);
g_mutex_unlock(&client->send_lock);
frame_queue_clear(client);
return dropped;
}
static void apply_bitrate(RtcClient *client, int track, guint bitrate)
{
guint b;
......@@ -629,7 +739,7 @@ static void apply_bitrate(RtcClient *client, int track, guint bitrate)
mpp_h264_source_set_bitrate(client->h264_source, b);
}
/* 与 gst_webrtc_pipeline::ramp_video_bitrate 同档升降步长 */
/* Public Internet: climb slowly so a transient REMB spike cannot build latency. */
static void ramp_bitrate_toward_ceiling(RtcClient *client, int track)
{
guint cur;
......@@ -649,26 +759,18 @@ static void ramp_bitrate_toward_ceiling(RtcClient *client, int track)
if (!cur || !tgt || cur == tgt)
return;
diff = (cur > tgt) ? (cur - tgt) : (tgt - cur);
if (cur < tgt) {
step = diff / 4;
if (step < 100000)
step = 100000;
if (step > 300000)
step = 300000;
if (cur > tgt)
return; /* confirmed decreases are applied by on_remb() */
diff = tgt - cur;
step = (guint)(((guint64)cur * 12U) / 100U);
if (step < 50000U)
step = 50000U;
if (step > 250000U)
step = 250000U;
if (step > diff)
step = diff;
next = cur + step;
if (next > tgt)
next = tgt;
} else {
step = diff / 3;
if (step < 100000)
step = 100000;
if (step > 600000)
step = 600000;
next = (cur > step) ? (cur - step) : 0;
if (next < tgt)
next = tgt;
}
if (next == cur)
return;
......@@ -682,33 +784,68 @@ static void RTC_API on_remb(int track, unsigned int bitrate, void *ptr)
{
RtcClient *client = ptr;
guint current;
guint usable;
guint filtered;
guint ceiling;
guint next = 0;
guint down_samples = 0;
gboolean severe_down = FALSE;
gint64 now_us;
int dropped = 0;
if (!client || bitrate == 0)
return;
now_us = g_get_monotonic_time();
ceiling = clamp_bitrate(
(guint)(((guint64)bitrate * WEBRTCPUSH_REMB_UTIL_PERCENT) / 100));
usable = clamp_bitrate(
(guint)(((guint64)bitrate * WEBRTCPUSH_REMB_UTIL_PERCENT) / 100U));
g_mutex_lock(&client->lock);
current = client->target_bitrate;
client->remb_ceiling = ceiling;
if (ceiling < current) {
severe_down = ((guint64)ceiling * 100) < ((guint64)current * 75);
if (!severe_down && (current - ceiling) < WEBRTCPUSH_REMB_DOWN_MIN_STEP) {
g_mutex_unlock(&client->lock);
return;
client->last_remb_bitrate = bitrate;
if (client->remb_filtered == 0) {
filtered = bitrate;
} else if (bitrate < client->remb_filtered) {
/* React to a real fall, but do not mirror every 200ms estimator sample. */
filtered = (guint)(((guint64)client->remb_filtered * 2U + bitrate) / 3U);
} else {
/* Rising bandwidth needs sustained evidence. */
filtered = (guint)(((guint64)client->remb_filtered * 7U + bitrate) / 8U);
}
severe_down = ((guint64)usable * 100U) <
((guint64)current * WEBRTCPUSH_REMB_SEVERE_PERCENT);
if (severe_down) {
/* Reset the filter too, otherwise its old high value immediately rebounds. */
filtered = bitrate;
ceiling = usable;
client->remb_down_samples = 0;
} else {
ceiling = clamp_bitrate(
(guint)(((guint64)filtered * WEBRTCPUSH_REMB_UTIL_PERCENT) / 100U));
}
client->remb_filtered = filtered;
client->remb_ceiling = ceiling;
if (ceiling + WEBRTCPUSH_REMB_DOWN_MIN_STEP <= current) {
if (!severe_down)
client->remb_down_samples++;
down_samples = client->remb_down_samples;
if (severe_down ||
client->remb_down_samples >= WEBRTCPUSH_REMB_DOWN_CONFIRMATIONS) {
client->remb_down_samples = 0;
client->target_bitrate = ceiling;
client->last_bitrate_ramp_us = now_us;
next = ceiling;
}
if (severe_down) {
client->need_idr = TRUE;
client->last_idr_request_us = g_get_monotonic_time();
client->last_idr_request_us = now_us;
client->source_pts_valid = FALSE;
}
} else if (ceiling >= current) {
client->remb_down_samples = 0;
}
g_mutex_unlock(&client->lock);
......@@ -724,18 +861,22 @@ static void RTC_API on_remb(int track, unsigned int bitrate, void *ptr)
g_mutex_unlock(&client->send_lock);
mpp_h264_source_set_bitrate(client->h264_source, next);
if (severe_down) {
my_zlog_info("libdatachannel: REMB=%u kbps, encoder=%u kbps pacing=%u kbps, resync dropped=%d RTP packets",
bitrate / 1000, next / 1000,
my_zlog_info("libdatachannel: REMB raw=%u filtered=%u kbps, encoder=%u pacing=%u kbps, resync dropped=%d RTP packets",
bitrate / 1000, filtered / 1000, next / 1000,
pacing_bitrate_for_encoder(next) / 1000,
dropped > 0 ? dropped : 0);
} else {
my_zlog_info("libdatachannel: REMB=%u kbps, encoder=%u kbps pacing=%u kbps",
bitrate / 1000, next / 1000,
pacing_bitrate_for_encoder(next) / 1000);
my_zlog_info("libdatachannel: REMB raw=%u filtered=%u kbps, encoder=%u pacing=%u kbps after %u samples",
bitrate / 1000, filtered / 1000, next / 1000,
pacing_bitrate_for_encoder(next) / 1000, down_samples);
}
} else if (ceiling > current) {
my_zlog_debug("libdatachannel: REMB=%u kbps, ceiling %u kbps (ramping up)",
bitrate / 1000, ceiling / 1000);
my_zlog_debug("libdatachannel: REMB raw=%u filtered=%u ceiling=%u kbps (slow ramp)",
bitrate / 1000, filtered / 1000, ceiling / 1000);
} else if (down_samples > 0) {
my_zlog_debug("libdatachannel: REMB down candidate %u/%u raw=%u filtered=%u kbps",
down_samples, WEBRTCPUSH_REMB_DOWN_CONFIRMATIONS,
bitrate / 1000, filtered / 1000);
}
}
......@@ -743,7 +884,6 @@ static void maybe_ramp_bitrate(RtcClient *client, int track, gint64 now_us)
{
guint cur;
guint tgt;
gint64 interval;
if (!client || track < 0)
return;
......@@ -751,9 +891,9 @@ static void maybe_ramp_bitrate(RtcClient *client, int track, gint64 now_us)
g_mutex_lock(&client->lock);
cur = client->target_bitrate;
tgt = client->remb_ceiling;
interval = (tgt > cur) ? (gint64)WEBRTCPUSH_BITRATE_RAMP_UP_MS
: (gint64)WEBRTCPUSH_BITRATE_RAMP_DOWN_MS;
if (now_us - client->last_bitrate_ramp_us < interval * 1000) {
if (tgt <= cur ||
now_us - client->last_bitrate_ramp_us <
(gint64)WEBRTCPUSH_BITRATE_RAMP_UP_MS * 1000) {
g_mutex_unlock(&client->lock);
return;
}
......@@ -856,13 +996,25 @@ static gpointer send_thread_main(gpointer data)
const guint32 timestamp_step = 90000 / WEBRTCPUSH_H264_FPS;
const gint pop_ms = (1000 / WEBRTCPUSH_H264_FPS) + 200;
guint send_fail_streak = 0;
guint64 sent_window_bytes = 0;
guint sent_window_frames = 0;
gint64 sent_window_start_us = g_get_monotonic_time();
gint64 last_pacing_check_us = 0;
gint64 last_pacing_flush_us = 0;
for (;;) {
gboolean stopping;
int track;
guint target_bps;
guint raw_remb_bps;
guint filtered_remb_bps;
guint remb_bps;
guint queue_dropped;
guint pacing_packets = 0;
guint pacing_bytes = 0;
guint pacing_delay_ms = 0;
guint actual_kbps = 0;
guint actual_fps_x10 = 0;
guint32 timestamp;
RtcH264Frame *frame;
gint64 now;
......@@ -887,17 +1039,51 @@ static gpointer send_thread_main(gpointer data)
now = g_get_monotonic_time();
maybe_ramp_bitrate(client, track, now);
if (now - last_pacing_check_us >= 100000) {
last_pacing_check_us = now;
if (pacing_queue_snapshot(client, track, &pacing_packets,
&pacing_bytes, &pacing_delay_ms) &&
pacing_delay_ms > WEBRTCPUSH_PACING_MAX_QUEUE_MS &&
client->first_frame_sent &&
now - last_pacing_flush_us >=
(gint64)WEBRTCPUSH_PACING_GUARD_COOLDOWN_MS * 1000) {
int dropped = clear_stale_pacing(client, track);
last_pacing_flush_us = now;
my_zlog_warn("libdatachannel: pacing backlog %u packets/%u bytes ~= %ums; dropped=%d and request IDR",
pacing_packets, pacing_bytes, pacing_delay_ms,
dropped > 0 ? dropped : 0);
}
}
g_mutex_lock(&client->lock);
if (client->last_stats_log_us == 0 ||
now - client->last_stats_log_us >=
(gint64)WEBRTCPUSH_STATS_LOG_INTERVAL_MS * 1000) {
target_bps = client->target_bitrate;
raw_remb_bps = client->last_remb_bitrate;
filtered_remb_bps = client->remb_filtered;
remb_bps = client->remb_ceiling;
client->last_stats_log_us = now;
g_mutex_unlock(&client->lock);
queue_dropped = client->frame_queue.dropped_total;
my_zlog_info("libdatachannel: stats encoder=%u kbps remb_ceiling=%u kbps queue_dropped=%u",
target_bps / 1000, remb_bps / 1000, queue_dropped);
pacing_queue_snapshot(client, track, &pacing_packets,
&pacing_bytes, &pacing_delay_ms);
if (now > sent_window_start_us) {
guint64 elapsed_us = (guint64)(now - sent_window_start_us);
actual_kbps = (guint)((sent_window_bytes * 8U * 1000U) / elapsed_us);
actual_fps_x10 = (guint)(((guint64)sent_window_frames * 10U * 1000000U) /
elapsed_us);
}
my_zlog_info("libdatachannel: stats encoder=%u actual_h264=%u kbps sent_fps=%u.%u raw_remb=%u filtered_remb=%u ceiling=%u kbps pacing=%u pkt/%u bytes/%ums queue_dropped=%u",
target_bps / 1000, actual_kbps,
actual_fps_x10 / 10, actual_fps_x10 % 10,
raw_remb_bps / 1000, filtered_remb_bps / 1000,
remb_bps / 1000,
pacing_packets, pacing_bytes, pacing_delay_ms,
queue_dropped);
sent_window_bytes = 0;
sent_window_frames = 0;
sent_window_start_us = now;
} else {
g_mutex_unlock(&client->lock);
}
......@@ -938,6 +1124,7 @@ static gpointer send_thread_main(gpointer data)
send_fail_streak);
g_mutex_lock(&client->lock);
client->track_open = FALSE;
client->first_frame_sent = FALSE;
client->need_idr = TRUE;
g_mutex_unlock(&client->lock);
signal_track_ready(client);
......@@ -949,6 +1136,12 @@ static gpointer send_thread_main(gpointer data)
}
g_mutex_unlock(&client->send_lock);
send_fail_streak = 0;
if (!client->first_frame_sent) {
client->first_frame_sent = TRUE;
my_zlog_info("libdatachannel: first frame sent (%zu bytes), pacing flush armed", frame->size);
}
sent_window_bytes += frame->size;
sent_window_frames++;
g_mutex_lock(&client->lock);
client->rtp_timestamp = timestamp + timestamp_step;
......@@ -1134,6 +1327,7 @@ static void destroy_peer(RtcClient *client)
client->pc = -1;
client->track = -1;
client->track_open = FALSE;
client->first_frame_sent = FALSE;
client->audio_track = -1;
client->audio_track_open = FALSE;
client->answer_sent = FALSE;
......@@ -1311,6 +1505,9 @@ int rtc_client_start(AppState *app)
client->track = -1;
client->target_bitrate = WEBRTCPUSH_INITIAL_BITRATE;
client->remb_ceiling = WEBRTCPUSH_INITIAL_BITRATE;
client->last_remb_bitrate = 0;
client->remb_filtered = 0;
client->remb_down_samples = 0;
client->last_bitrate_ramp_us = g_get_monotonic_time();
client->pending_local_ice = g_queue_new();
client->pending_remote_ice = g_queue_new();
......@@ -1415,6 +1612,7 @@ int rtc_client_handle_offer(AppState *app, const char *sdp)
return -1;
if (!parse_offer(sdp, &video, &audio)) {
my_zlog_error("libdatachannel: phone offer has no usable H264 video");
my_zlog_error("libdatachannel: SDP dump BEGIN:\n%s", sdp);
offer_video_clear(&video);
offer_audio_clear(&audio);
return -1;
......@@ -1474,10 +1672,16 @@ int rtc_client_handle_offer(AppState *app, const char *sdp)
client->remote_ice_ufrag = g_strdup(video.ice_ufrag);
client->target_bitrate = WEBRTCPUSH_INITIAL_BITRATE;
client->remb_ceiling = WEBRTCPUSH_INITIAL_BITRATE;
client->last_remb_bitrate = 0;
client->remb_filtered = 0;
client->remb_down_samples = 0;
client->last_bitrate_ramp_us = g_get_monotonic_time();
request_idr(client, TRUE);
client->need_idr = TRUE; /* 内联,避免 request_idr 二次加锁死锁 */
client->last_idr_request_us = g_get_monotonic_time();
client->source_pts_valid = FALSE;
g_mutex_unlock(&client->lock);
mpp_h264_source_set_bitrate(client->h264_source,
WEBRTCPUSH_INITIAL_BITRATE);
my_zlog_info("libdatachannel: applying offer mid=%s pt=%d ufrag=%s",
video.mid, video.payload_type, video.ice_ufrag);
......
......@@ -7,26 +7,29 @@
* 1:走 libdatachannel 原生推流(摄像头 → MPP/x264 → libdatachannel)
*
* 原生分支:v4l2 采集 → mpph264enc(或 x264enc 回退)→ libdatachannel 发送。
* 音频尚未接入,视频跑通后再接 ALSA→Opus track。
*/
#define WEBRTCPUSH_USE_MPP 1
/* 正常固定 24fps;弱网以降码率为主,后续若启用动态帧率也不得低于 22fps。 */
#define WEBRTCPUSH_H264_FPS 24
#define WEBRTCPUSH_H264_MIN_FPS 22
#define WEBRTCPUSH_H264_GOP (WEBRTCPUSH_H264_FPS) /* 1s 一个 IDR */
#define WEBRTCPUSH_H264_GOP (WEBRTCPUSH_H264_FPS * 3) /* 3s; PLI still requests IDR */
/* 与 gst_webrtc_pipeline / jywy 浏览器推流对齐的码率策略 */
#define WEBRTCPUSH_INITIAL_BITRATE 1800000U
#define WEBRTCPUSH_INITIAL_BITRATE 1400000U
#define WEBRTCPUSH_MIN_BITRATE 500000U
#define WEBRTCPUSH_MAX_BITRATE 3200000U
#define WEBRTCPUSH_REMB_UTIL_PERCENT 85U
#define WEBRTCPUSH_REMB_DOWN_MIN_STEP 50000U /* 非急跌时 <50kbps 不调编码器 */
#define WEBRTCPUSH_BITRATE_RAMP_UP_MS 500U /* 升码更快恢复画质 */
#define WEBRTCPUSH_BITRATE_RAMP_DOWN_MS 1000U
#define WEBRTCPUSH_PACING_HEADROOM_PERCENT 108U
#define WEBRTCPUSH_REMB_UTIL_PERCENT 80U
#define WEBRTCPUSH_REMB_DOWN_MIN_STEP 100000U
#define WEBRTCPUSH_REMB_DOWN_CONFIRMATIONS 3U
#define WEBRTCPUSH_REMB_SEVERE_PERCENT 65U
#define WEBRTCPUSH_BITRATE_RAMP_UP_MS 2500U
#define WEBRTCPUSH_BITRATE_RAMP_DOWN_MS 500U
#define WEBRTCPUSH_PACING_HEADROOM_PERCENT 125U
#define WEBRTCPUSH_PACING_INTERVAL_MS 5U
#define WEBRTCPUSH_PACING_MAX_BITRATE 3750000U
#define WEBRTCPUSH_PACING_MAX_BITRATE 4000000U
#define WEBRTCPUSH_PACING_MAX_QUEUE_MS 300U
#define WEBRTCPUSH_PACING_GUARD_COOLDOWN_MS 1000U
/* RTP 分片与 NACK(MTU=1200,留 SRTP/DTLS/FU 余量) */
#define WEBRTCPUSH_RTP_MAX_FRAGMENT 1050U
......@@ -45,12 +48,12 @@
#define WEBRTCPUSH_H264_PROFILE_LEVEL_ID "42e01f"
/* 拉帧线程与发送线程之间的有界队列(leaky,满则丢最旧帧) */
#define WEBRTCPUSH_SEND_QUEUE_DEPTH 4U
#define WEBRTCPUSH_SEND_QUEUE_DEPTH 2U
/* 管道缓冲:运动场景需适度缓存,过低易 pull timeout / 糊屏 */
#define WEBRTCPUSH_MPP_PRE_ENC_BUFFERS 3 /* 编码前 leaky */
#define WEBRTCPUSH_MPP_POST_ENC_BUFFERS 2 /* 编码后保留 AU */
#define WEBRTCPUSH_APPSINK_MAX_BUFFERS 2
#define WEBRTCPUSH_MPP_PRE_ENC_BUFFERS 1 /* 编码前 leaky,低延迟 */
#define WEBRTCPUSH_MPP_POST_ENC_BUFFERS 1 /* 编码后保留 AU */
#define WEBRTCPUSH_APPSINK_MAX_BUFFERS 1
/* 视频采集:auto | /dev/videoN | test(videotestsrc 测试图案) */
#define WEBRTCPUSH_VIDEO_DEVICE "auto"
......
#include "wifi_config_agent.h"
#include "wifi_config_config.h"
#include "stream_debug_agent.h"
#include "device_debug_agent.h"
#include "device_diag.h"
#include "device_identity.h"
#include "device_fileopen.h"
#include "device_wifi_manager.h"
......@@ -60,6 +61,12 @@ void wifi_config_agent_rebind_socket(void)
pthread_mutex_unlock(&s_wifi_config_sock_mutex);
}
/* 设备诊断 cmd 转发判断(device_check / browser_refresh / browser_reopen / log_fetch) */
static int is_device_diag_cmd(const char *cmd)
{
return device_diag_is_cmd(cmd);
}
static int is_stream_debug_cmd(const char *cmd)
{
return cmd && (strcmp(cmd, "stream_mode_query") == 0 ||
......@@ -292,6 +299,31 @@ static void send_config_result(int sock, const struct sockaddr_in *addr, int suc
cJSON_Delete(root);
}
/* config_ack:设备收到 config 后立即回复,表示已收到即将执行 */
static void send_config_ack(int sock, const struct sockaddr_in *addr, int accepted,
const char *action, const char *message)
{
cJSON *root = cJSON_CreateObject();
char *payload;
if (!root)
return;
cJSON_AddStringToObject(root, "cmd", "config_ack");
cJSON_AddBoolToObject(root, "accepted", accepted ? 1 : 0);
if (action && action[0])
cJSON_AddStringToObject(root, "action", action);
if (message && message[0])
cJSON_AddStringToObject(root, "message", message);
payload = cJSON_PrintUnformatted(root);
if (payload) {
send_json(sock, addr, payload);
free(payload);
}
cJSON_Delete(root);
}
static void handle_discover(int sock, const struct sockaddr_in *addr)
{
char device_id[WIFI_CONFIG_DEVICE_ID_MAX];
......@@ -327,6 +359,7 @@ static void handle_config(int sock, const struct sockaddr_in *addr, cJSON *root)
char message[WIFI_CONFIG_MESSAGE_MAX];
int success;
/* 参数校验:ssid 非法时仍用旧协议回 config_result,保持兼容 */
if (!cJSON_IsString(ssid_item) || !ssid_item->valuestring || !ssid_item->valuestring[0]) {
send_config_result(sock, addr, 0, "缺少有效的 ssid 字段");
return;
......@@ -340,18 +373,138 @@ static void handle_config(int sock, const struct sockaddr_in *addr, cJSON *root)
my_zlog_info("WiFi配置助手: 收到配网请求 SSID=%s action=%s", ssid, action);
/* 新协议(v3):先立即回复 config_ack,表示已收到即将执行。
* 这样 action=connect 切换 WiFi 后,即使手机收不到最终结果,
* 也已通过 ack 知道设备收到了配置。 */
snprintf(message, sizeof(message), "%s",
strcmp(action, "connect") == 0
? "已收到配置,即将切换 WiFi"
: "已收到配置,即将保存");
send_config_ack(sock, addr, 1, action, message);
if (strcmp(action, "save") == 0) {
/* save 模式:保存完成后回复 config_result(最终结果) */
success = orange_pi_save_wifi(ssid, password) == 0;
snprintf(message, sizeof(message), "%s",
success ? "WiFi 配置已保存" : "WiFi 保存失败");
my_zlog_info("WiFi配置助手: 配网结果 success=%d, message=%s", success, message);
send_config_result(sock, addr, success, message);
} else {
/* connect 模式:只回 config_ack 即可,切 WiFi 后手机收不到最终结果,
* 不再回复 config_result,避免无意义发送。 */
success = orange_pi_connect_wifi(ssid, password) == 0;
snprintf(message, sizeof(message), "%s",
success ? "WiFi 配置已保存并连接成功" : "WiFi 连接失败");
my_zlog_info("WiFi配置助手: connect 已执行 success=%d (不回 config_result)", success);
}
}
my_zlog_info("WiFi配置助手: 配网结果 success=%d, message=%s", success, message);
send_config_result(sock, addr, success, message);
static void handle_wifi_list_query(int sock, const struct sockaddr_in *addr)
{
cJSON *root = cJSON_CreateObject();
cJSON *list;
char *payload;
if (!root)
return;
cJSON_AddStringToObject(root, "cmd", "wifi_list_response");
list = app_wifi_list_query();
if (!list)
list = cJSON_CreateArray();
cJSON_AddItemToObject(root, "list", list);
payload = cJSON_PrintUnformatted(root);
if (payload) {
send_json(sock, addr, payload);
free(payload);
}
cJSON_Delete(root);
my_zlog_info("WiFi配置助手: 应答 wifi_list_query");
}
static void handle_wifi_switch(int sock, const struct sockaddr_in *addr, cJSON *root)
{
cJSON *ssid_item = cJSON_GetObjectItemCaseSensitive(root, "ssid");
cJSON *resp;
const char *ssid = NULL;
char message[WIFI_CONFIG_MESSAGE_MAX];
int success;
int rc;
char *payload;
if (!cJSON_IsString(ssid_item) || !ssid_item->valuestring || !ssid_item->valuestring[0]) {
resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "cmd", "wifi_switch_result");
cJSON_AddBoolToObject(resp, "success", 0);
cJSON_AddStringToObject(resp, "message", "缺少有效的 ssid 字段");
payload = cJSON_PrintUnformatted(resp);
if (payload) { send_json(sock, addr, payload); free(payload); }
cJSON_Delete(resp);
return;
}
ssid = ssid_item->valuestring;
my_zlog_info("WiFi配置助手: 收到 wifi_switch SSID=%s", ssid);
rc = app_wifi_switch(ssid);
success = (rc == 0);
if (rc == 0)
snprintf(message, sizeof(message), "WiFi 切换成功,正在连接 %s", ssid);
else if (rc == -1)
snprintf(message, sizeof(message), "未找到已保存的 WiFi:%s", ssid);
else /* rc == -2:已保存但连接失败 */
snprintf(message, sizeof(message), "WiFi 已保存但连接失败:%s(信号弱或不可达)", ssid);
resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "cmd", "wifi_switch_result");
cJSON_AddBoolToObject(resp, "success", success ? 1 : 0);
cJSON_AddStringToObject(resp, "message", message);
payload = cJSON_PrintUnformatted(resp);
if (payload) { send_json(sock, addr, payload); free(payload); }
cJSON_Delete(resp);
}
static void handle_wifi_delete(int sock, const struct sockaddr_in *addr, cJSON *root)
{
cJSON *ssid_item = cJSON_GetObjectItemCaseSensitive(root, "ssid");
cJSON *resp;
const char *ssid = NULL;
char message[WIFI_CONFIG_MESSAGE_MAX];
int success;
int rc;
char *payload;
if (!cJSON_IsString(ssid_item) || !ssid_item->valuestring || !ssid_item->valuestring[0]) {
resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "cmd", "wifi_delete_result");
cJSON_AddBoolToObject(resp, "success", 0);
cJSON_AddStringToObject(resp, "message", "缺少有效的 ssid 字段");
payload = cJSON_PrintUnformatted(resp);
if (payload) { send_json(sock, addr, payload); free(payload); }
cJSON_Delete(resp);
return;
}
ssid = ssid_item->valuestring;
my_zlog_info("WiFi配置助手: 收到 wifi_delete SSID=%s", ssid);
rc = app_wifi_delete(ssid);
success = (rc == 0);
if (rc == 0)
snprintf(message, sizeof(message), "已删除 WiFi:%s", ssid);
else if (rc == -2)
snprintf(message, sizeof(message), "受保护的 WiFi 不可删除:%s", ssid);
else
snprintf(message, sizeof(message), "未找到或正在使用的 WiFi:%s", ssid);
resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "cmd", "wifi_delete_result");
cJSON_AddBoolToObject(resp, "success", success ? 1 : 0);
cJSON_AddStringToObject(resp, "message", message);
payload = cJSON_PrintUnformatted(resp);
if (payload) { send_json(sock, addr, payload); free(payload); }
cJSON_Delete(resp);
}
static void process_message(int sock, const char *buf, const struct sockaddr_in *addr)
......@@ -384,6 +537,20 @@ static void process_message(int sock, const char *buf, const struct sockaddr_in
stream_debug_handle_url_update(sock, addr, root);
} else if (strcmp(cmd_item->valuestring, "stream_url_query") == 0) {
stream_debug_handle_url_query(sock, addr);
} else if (strcmp(cmd_item->valuestring, "wifi_list_query") == 0) {
handle_wifi_list_query(sock, addr);
} else if (strcmp(cmd_item->valuestring, "wifi_switch") == 0) {
handle_wifi_switch(sock, addr, root);
} else if (strcmp(cmd_item->valuestring, "wifi_delete") == 0) {
handle_wifi_delete(sock, addr, root);
} else if (strcmp(cmd_item->valuestring, "device_check") == 0) {
device_diag_handle_check(sock, addr);
} else if (strcmp(cmd_item->valuestring, "browser_refresh") == 0) {
device_diag_handle_browser_ctrl(sock, addr, "refresh");
} else if (strcmp(cmd_item->valuestring, "browser_reopen") == 0) {
device_diag_handle_browser_ctrl(sock, addr, "reopen");
} else if (strcmp(cmd_item->valuestring, "log_fetch") == 0) {
device_diag_handle_log_fetch(sock, addr, root);
}
cJSON_Delete(root);
......
......@@ -26,6 +26,7 @@ public:
PacingHandler(double bitsPerSecond, std::chrono::milliseconds sendInterval);
void setBitrate(double bitsPerSecond);
size_t clear();
void getQueueStats(size_t &packetCount, size_t &byteCount);
void outgoing(message_vector &messages, const message_callback &send) override;
......@@ -40,6 +41,7 @@ private:
std::mutex mMutex;
std::queue<message_ptr> mRtpBuffer;
size_t mQueuedBytes = 0;
void schedule(const message_callback &send);
};
......
......@@ -434,6 +434,10 @@ RTC_C_EXPORT int rtcSetPacingBitrate(int tr, unsigned int bitrate);
// Returns the number of dropped packets, or a negative RTC_ERR_* value.
RTC_C_EXPORT int rtcClearPacingQueue(int tr);
// Read the current pacing queue without modifying it.
RTC_C_EXPORT int rtcGetPacingQueueStats(int tr, unsigned int *packetCount,
unsigned int *byteCount);
// Transform seconds to timestamp using track's clock rate, result is written to timestamp
RTC_C_EXPORT int rtcTransformSecondsToTimestamp(int id, double seconds, uint32_t *timestamp);
......
......@@ -1482,6 +1482,31 @@ int rtcClearPacingQueue(int tr) {
});
}
int rtcGetPacingQueueStats(int tr, unsigned int *packetCount, unsigned int *byteCount) {
return wrap([&] {
shared_ptr<PacingHandler> handler;
{
std::lock_guard lock(mutex);
auto it = pacingHandlerMap.find(tr);
if (it == pacingHandlerMap.end())
throw std::invalid_argument("Pacing handler does not exist");
handler = it->second;
}
size_t packets = 0;
size_t bytes = 0;
handler->getQueueStats(packets, bytes);
if (packetCount)
*packetCount = packets > std::numeric_limits<unsigned int>::max()
? std::numeric_limits<unsigned int>::max()
: static_cast<unsigned int>(packets);
if (byteCount)
*byteCount = bytes > std::numeric_limits<unsigned int>::max()
? std::numeric_limits<unsigned int>::max()
: static_cast<unsigned int>(bytes);
return RTC_ERR_SUCCESS;
});
}
int rtcTransformSecondsToTimestamp(int id, double seconds, uint32_t *timestamp) {
return wrap([&] {
auto config = getRtpConfig(id);
......
......@@ -8,6 +8,7 @@
#if RTC_ENABLE_MEDIA
#include <algorithm>
#include <memory>
#include "pacinghandler.hpp"
......@@ -29,10 +30,17 @@ size_t PacingHandler::clear() {
const size_t count = mRtpBuffer.size();
while (!mRtpBuffer.empty())
mRtpBuffer.pop();
mQueuedBytes = 0;
mBudget = 0.;
return count;
}
void PacingHandler::getQueueStats(size_t &packetCount, size_t &byteCount) {
std::lock_guard<std::mutex> lock(mMutex);
packetCount = mRtpBuffer.size();
byteCount = mQueuedBytes;
}
void PacingHandler::schedule(const message_callback &send) {
if (mHaveScheduled.exchange(true)) {
return;
......@@ -44,21 +52,30 @@ void PacingHandler::schedule(const message_callback &send) {
const std::lock_guard<std::mutex> lock(mMutex);
mHaveScheduled.store(false);
// Update the budget and cap it
// Keep credit for short scheduler stalls. Capping at one interval
// permanently under-runs the configured bitrate whenever the worker
// wakes up late, which turns scheduler jitter into growing media delay.
const double bytesPerSecond = mBytesPerSecond.load();
const auto now = std::chrono::high_resolution_clock::now();
auto newBudget =
std::chrono::duration<double>(std::chrono::high_resolution_clock::now() - mLastRun)
std::chrono::duration<double>(now - mLastRun)
.count() *
bytesPerSecond;
auto maxBudget = std::chrono::duration<double>(mSendInterval).count() * bytesPerSecond;
const auto catchUpWindow = std::max(mSendInterval * 5,
std::chrono::milliseconds(25));
auto maxBudget =
std::chrono::duration<double>(catchUpWindow).count() * bytesPerSecond;
mBudget = std::min(mBudget + newBudget, maxBudget);
mLastRun = std::chrono::high_resolution_clock::now();
mLastRun = now;
// Send packets while there is budget, allow a single partial packet over budget
while (!mRtpBuffer.empty() && mBudget > 0) {
auto size = int(mRtpBuffer.front()->size());
send(std::move(mRtpBuffer.front()));
mRtpBuffer.pop();
mQueuedBytes = mQueuedBytes >= static_cast<size_t>(size)
? mQueuedBytes - static_cast<size_t>(size)
: 0;
mBudget -= size;
}
......@@ -74,6 +91,7 @@ void PacingHandler::outgoing(message_vector &messages, const message_callback &s
std::lock_guard<std::mutex> lock(mMutex);
for (auto &m : messages) {
mQueuedBytes += m->size();
mRtpBuffer.push(std::move(m));
}
messages.clear();
......
......@@ -9,4 +9,4 @@ file perms = 600
millisecond = "%d(%Y-%m-%d %H:%M:%S).%ms [%V] %m%n"
[rules]
my_log.* "/home/orangepi/car/master/log/log_2026-07-02.log"; millisecond
my_log.* "/home/orangepi/car/master/log/log_2026-07-06.log"; millisecond
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment