POSA_LEAKSMS/firmware/board_sht30/app_sht30.c
유창욱 90f121e14c chore: import codebase with security hardening
SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트).
전체 코드리뷰에서 도출된 보안 하드닝 10건 반영:
- 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시)
- 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php)
- 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점)
- CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php)
- 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화

_backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
2026-06-20 09:37:40 +09:00

218 lines
9.6 KiB
C

/* =============================================================================
* app_sht30.c - SHT30 온습도 보드 애플리케이션 태스크
*
* RPi sht30_monitor.py 의 동작을 STM32 + FreeRTOS 로 이식한다.
*
* - 측정 태스크(sht30_measure_task):
* net 업 대기 -> SNTP 시간 동기 -> 주기적으로 SHT30 측정 후 서버 보고.
* sht30_monitor.py 의 first_success 로직과 동일하게, "최초 성공" 측정은
* event_type="startup", 이후는 "periodic" 으로 보고한다.
* 온습도가 정상 범위를 벗어나면 metric_status="out_of_range" 로 분류한다.
* 측정 실패 시 크래시하지 않고 로그만 남긴 뒤 다음 주기에 재시도한다.
*
* - 헬스 태스크(health_task):
* IWDG 워치독을 주기적으로 refresh 하고, 태스크 스택 여유를 로깅한다.
* (RPi systemd watchdog / 헬스 체크 대응)
*
* 두 태스크의 분리 이유:
* 네트워크 I/O(TLS 핸드셰이크/전송)는 수 초간 블로킹될 수 있으므로, 워치독
* refresh 를 측정 태스크에 의존시키면 통신 지연이 곧 리셋으로 이어질 수 있다.
* 따라서 워치독은 독립적인 고우선순위 헬스 태스크에서 갱신한다.
* ===========================================================================*/
#include <stdint.h>
#include <math.h>
#include "FreeRTOS.h"
#include "task.h"
#include "app_config.h" /* 주기/범위/버전 + board_config.h + secrets.h */
#include "applog.h"
#include "watchdog.h"
#include "net.h"
#include "timesync.h"
#include "sht30.h"
#include "reporter.h"
#include "bsp.h"
/* ── 내부 타이밍 상수 ─────────────────────────────────────────────────────── */
/* net 업 / 시간 동기 대기 타임아웃. 실패해도 무한 재시도(폐쇄망 부팅 대비). */
#define APP_NET_WAIT_TIMEOUT_MS (APP_NET_DHCP_TIMEOUT_MS + 10000u)
#define APP_TIMESYNC_TIMEOUT_MS 30000u
/* 헬스(워치독) 태스크 주기. 워치독 타임아웃의 1/4 이하로 충분히 여유 있게. */
#define HEALTH_TICK_MS (APP_WATCHDOG_TIMEOUT_MS / 4u)
/* 측정 실패 후 짧은 재시도 간격 (정상 주기와 별도). 센서 글리치 복구용. */
#define MEASURE_RETRY_MS 5000u
/* 스택 여유 로깅 주기 (헬스 틱 N회마다 1번). */
#define STACK_LOG_EVERY_N_TICKS 12u
/* 측정 태스크 핸들 (스택 워터마크 로깅용으로 헬스 태스크가 참조) */
static TaskHandle_t s_measure_task = NULL;
/* ── 헬퍼: 온습도 정상 범위 분류 (sht30_monitor.py metric_status) ─────────── */
static const char *classify_metric_status(double temp_c, double rh)
{
/* NaN/Inf 방어: 비정상 부동소수도 out_of_range 로 취급 */
if (!isfinite(temp_c) || !isfinite(rh)) {
return "out_of_range";
}
if (temp_c < APP_SHT30_TEMP_LOW_C || temp_c > APP_SHT30_TEMP_HIGH_C) {
return "out_of_range";
}
if (rh < APP_SHT30_RH_LOW || rh > APP_SHT30_RH_HIGH) {
return "out_of_range";
}
return "normal";
}
/* ── 헬퍼: 네트워크 업 + 시간 동기 보장 (무한 재시도) ─────────────────────── */
static void ensure_network_and_time(void)
{
/* 1) 링크 업 + IP 확보까지 대기. 폐쇄망에서 케이블/스위치 지연이 있을 수
* 있으므로 타임아웃 시 경고만 남기고 계속 재시도한다. */
while (net_wait_up(APP_NET_WAIT_TIMEOUT_MS) != 0) {
LOGW("net not up yet, retrying...");
/* 워치독은 헬스 태스크가 별도로 refresh 하므로 여기서는 단순 대기 */
vTaskDelay(pdMS_TO_TICKS(2000));
}
char ip[16];
net_ip_str(ip, sizeof(ip));
LOGI("network up, ip=%s", ip);
/* 2) SNTP 시작 후 최초 동기까지 대기. TLS 인증서 유효기간 검증과 보고
* timestamp 필드에 정확한 Unix 시간이 필요하므로 동기 완료를 보장한다.
* (SNTP 는 UDP 라 TLS 불필요 -> TLS 연결보다 먼저 수행) */
timesync_init();
while (timesync_wait(APP_TIMESYNC_TIMEOUT_MS) != 0) {
LOGW("time sync pending, retrying...");
vTaskDelay(pdMS_TO_TICKS(APP_SNTP_RETRY_DELAY_MS));
}
LOGI("time synced, epoch=%lu", (unsigned long)timesync_now());
}
/* ── 측정 태스크 ─────────────────────────────────────────────────────────── */
/* RPi sht30_monitor.py main loop 대응:
* while True:
* reading = read_sht30()
* if reading is None: 로그 후 continue
* event_type = "startup" if first_success else "periodic"
* post_reading(...); first_success = False
* sleep(REPORT_INTERVAL)
*/
static void sht30_measure_task(void *arg)
{
(void)arg;
/* 부팅 시 네트워크/시간 준비 (sht30_monitor.py 의 시작 전 OS NTP 대응) */
ensure_network_and_time();
/* first_success: 첫 성공 측정만 "startup" 으로 보고 (RPi 동일 로직) */
int first_success = 1;
LOGI("sht30 measure task started (interval=%us)",
(unsigned)APP_SHT30_REPORT_INTERVAL_SEC);
for (;;) {
double temp_c = 0.0;
double rh = 0.0;
int rc = sht30_measure(&temp_c, &rh);
if (rc != 0) {
/* 측정 실패: 크래시 금지. 로그 후 짧게 쉬고 다음 주기에 재시도.
* rc == -1: I2C 통신 오류, rc == -2: CRC 오류 */
LOGE("sht30_measure failed (rc=%d), retry in %ums",
rc, (unsigned)MEASURE_RETRY_MS);
vTaskDelay(pdMS_TO_TICKS(MEASURE_RETRY_MS));
continue;
}
const char *metric_status = classify_metric_status(temp_c, rh);
const char *event_type = first_success ? "startup" : "periodic";
/* 운영 가시성: 측정값/상태 로깅 (소수 2자리로 표시) */
LOGI("sht30 %s: T=%.2fC RH=%.2f%% status=%s",
event_type, temp_c, rh, metric_status);
/* 서버 보고. reporter 가 본문 생성 -> 서명 -> TLS POST -> 재시도 처리.
* 반환 0 성공(HTTP 200), 음수 실패. */
int prc = report_sht30_event(event_type, temp_c, rh, metric_status);
if (prc == 0) {
/* 최초 성공 보고 이후에는 startup -> periodic 으로 전환 */
if (first_success) {
first_success = 0;
}
bsp_led_toggle(); /* 성공 보고 표시 */
} else {
/* 보고 실패: reporter 내부 재시도까지 모두 소진한 경우.
* 다음 정상 주기에 다시 시도한다. first_success 는 유지하여
* 최초 보고가 반드시 "startup" 으로 도달하도록 한다. */
LOGW("report_sht30_event failed (rc=%d), will retry next cycle", prc);
}
/* 정상 보고 주기 대기 (sht30_monitor.py REPORT_INTERVAL). */
vTaskDelay(pdMS_TO_TICKS((TickType_t)APP_SHT30_REPORT_INTERVAL_SEC * 1000u));
}
}
/* ── 헬스 태스크 ─────────────────────────────────────────────────────────── */
/* 워치독 refresh + 주기적 스택 여유 로깅. 네트워크/측정 블로킹과 독립적으로
* 동작해야 하므로 측정 태스크보다 높은 우선순위로 생성된다(main.c). */
static void health_task(void *arg)
{
(void)arg;
uint32_t tick = 0;
for (;;) {
/* IWDG 갱신: 이 호출이 APP_WATCHDOG_TIMEOUT_MS 내에 끊기면 MCU 리셋 */
watchdog_refresh();
/* 주기적 스택 워터마크 로깅 (개발/운영 진단용) */
if ((tick % STACK_LOG_EVERY_N_TICKS) == 0u) {
UBaseType_t health_free = uxTaskGetStackHighWaterMark(NULL);
UBaseType_t measure_free = (s_measure_task != NULL)
? uxTaskGetStackHighWaterMark(s_measure_task)
: 0;
LOGI("health: heap_free=%u stack_min[health]=%u stack_min[measure]=%u",
(unsigned)xPortGetFreeHeapSize(),
(unsigned)health_free,
(unsigned)measure_free);
}
tick++;
vTaskDelay(pdMS_TO_TICKS(HEALTH_TICK_MS));
}
}
/* ── 태스크 생성 진입점 (main.c 에서 호출) ───────────────────────────────── */
/* 스택 크기: TLS 핸드셰이크는 mbedTLS 가 측정 태스크 문맥에서 수행되므로
* 측정 태스크에 넉넉한 스택을 준다(reporter 가 동일 태스크에서 호출됨). */
#define MEASURE_TASK_STACK_WORDS 3072u /* 12 KB (word=4B): mbedTLS 핸드셰이크 피크 여유 */
#define HEALTH_TASK_STACK_WORDS 256u /* 1 KB: 로깅/워치독만 */
/* 우선순위: 헬스(워치독) > 측정. 워치독 갱신이 통신 지연에 막히지 않게 한다. */
#define MEASURE_TASK_PRIO (tskIDLE_PRIORITY + 2)
#define HEALTH_TASK_PRIO (tskIDLE_PRIORITY + 3)
void app_sht30_start(void)
{
BaseType_t ok;
ok = xTaskCreate(sht30_measure_task, "sht30",
MEASURE_TASK_STACK_WORDS, NULL,
MEASURE_TASK_PRIO, &s_measure_task);
if (ok != pdPASS) {
bsp_fatal("failed to create sht30 measure task");
}
ok = xTaskCreate(health_task, "health",
HEALTH_TASK_STACK_WORDS, NULL,
HEALTH_TASK_PRIO, NULL);
if (ok != pdPASS) {
bsp_fatal("failed to create health task");
}
}