Newer
Older
smart-home-server / devices / sensor / ld2420_radar.cpp
#include "ld2420_radar.h"

Ld2420Radar::Ld2420Radar() {
    reset_runtime_state();
}

bool Ld2420Radar::begin(HardwareSerial &serial, const RadarConfig &config) {
    serial_port = &serial;
    cfg = config;

    reset_runtime_state();

    serial_port -> begin(cfg.baud_rate, SERIAL_8N1, cfg.uart_rx_pin, cfg.uart_tx_pin);
    initialized = true;

    return true;
}

void Ld2420Radar::reset_runtime_state() {
    line_length = 0;
    line_buffer[0] = '\0';

    online = false;
    raw_presence = false;
    presence = false;

    raw_distance_m = 0.0f;
    filtered_distance_m = 0.0f;

    current_speed_m_s = 0.0f;
    activity_score_current = 0;

    last_frame_ms = 0;
    last_presence_ms = 0;
    last_distance_sample_ms = 0;

    last_distance_for_speed_m = 0.0f;
    has_distance_for_speed = false;

    second_scores_head = 0;
    second_scores_count = 0;
    last_second_push_ms = 0;

    for (size_t i = 0; i < SECOND_HISTORY_SIZE; ++i) {
        second_scores[i] = 0;
    }

    activity_score_dynamics = "constant";
}

void Ld2420Radar::update() {
    if (!initialized || serial_port == nullptr) {
        return;
    }

    while (serial_port -> available() > 0) {
        char ch = static_cast<char>(serial_port -> read());
        process_incoming_byte(ch);
    }

    uint32_t now_ms = millis();

    update_online_state(now_ms);
    update_presence_state(now_ms);
    update_second_history(now_ms);

    activity_score_dynamics = compute_dynamics();
}

void Ld2420Radar::process_incoming_byte(char ch) {
    if (ch == '\r' || ch == '\n') {
        if (line_length > 0) {
            line_buffer[line_length] = '\0';
            process_line(line_buffer);
            line_length = 0;
            line_buffer[0] = '\0';
        }
        return;
    }

    if (line_length + 1 >= LINE_BUFFER_SIZE) {
        line_length = 0;
        line_buffer[0] = '\0';
        return;
    }

    line_buffer[line_length++] = ch;
}

void Ld2420Radar::process_line(const char *line_cstr) {
    if (line_cstr == nullptr || line_cstr[0] == '\0') {
        return;
    }

    String line = String(line_cstr);
    line.trim();

    if (line.length() == 0) {
        return;
    }

    bool parsed = parse_ascii_line(line);

    if (parsed) {
        last_frame_ms = millis();
        online = true;
    } else if (cfg.enable_debug_unknown_lines) {
        Serial.print("LD2420 unknown line: ");
        Serial.println(line);
    }
}

bool Ld2420Radar::parse_ascii_line(const String &raw_line) {
    String line = normalize_line(raw_line);
    line.toLowerCase();

    /*
        Поддерживаем разные возможные названия полей.
        Это сделано специально, чтобы не завязаться
        на единственный вариант строки.
    */
    static const char *presence_keys[] = {
        "presence", "pres", "occupancy", "occupied", "target",
        "detected", "detect", "exist", "state"
    };

    static const char *distance_meter_keys[] = {
        "distance_m", "dist_m", "dis_m", "range_m", "r_m", "meter", "meters"
    };

    static const char *distance_cm_keys[] = {
        "distance_cm", "dist_cm", "dis_cm", "range_cm", "cm"
    };

    static const char *distance_generic_keys[] = {
        "distance", "dist", "dis", "range", "r"
    };

    static const char *zone_keys[] = {
        "zone", "gate", "area", "distance_zone"
    };

    static const char *move_keys[] = {
        "move", "moving", "motion", "micro_move", "micro", "still"
    };

    bool got_anything = false;
    uint32_t now_ms = millis();

    bool explicit_presence = false;
    bool has_explicit_presence = extract_bool_by_keys(line, presence_keys, sizeof(presence_keys) / sizeof(presence_keys[0]), explicit_presence);

    float distance_m = 0.0f;
    bool has_distance_m = extract_number_by_keys(line, distance_meter_keys, sizeof(distance_meter_keys) / sizeof(distance_meter_keys[0]), distance_m);

    float distance_cm = 0.0f;
    bool has_distance_cm = extract_number_by_keys(line, distance_cm_keys, sizeof(distance_cm_keys) / sizeof(distance_cm_keys[0]), distance_cm);

    float distance_generic = 0.0f;
    bool has_distance_generic = extract_number_by_keys(line, distance_generic_keys, sizeof(distance_generic_keys) / sizeof(distance_generic_keys[0]), distance_generic);

    float zone = 0.0f;
    bool has_zone = extract_number_by_keys(line, zone_keys, sizeof(zone_keys) / sizeof(zone_keys[0]), zone);

    float move_value = 0.0f;
    bool has_move_value = extract_number_by_keys(line, move_keys, sizeof(move_keys) / sizeof(move_keys[0]), move_value);

    /*
        1. Приоритет:
           явная дистанция в метрах
           -> дистанция в сантиметрах
           -> generic distance
           -> zone/gate через калибровку
    */
    if (has_distance_m) {
        if (is_distance_plausible(distance_m)) {
            raw_distance_m = distance_m;
            apply_new_distance(distance_m, now_ms);
            got_anything = true;
        }
    } else if (has_distance_cm) {
        float converted = distance_cm / 100.0f;

        if (is_distance_plausible(converted)) {
            raw_distance_m = converted;
            apply_new_distance(converted, now_ms);
            got_anything = true;
        }
    } else if (has_distance_generic) {
        float converted = distance_generic;

        /*
            Если значение выглядит как сантиметры — конвертируем.
            Если выглядит как зона — позже это перекроется has_zone.
        */
        if (converted > 20.0f) {
            converted /= 100.0f;
        }

        if (is_distance_plausible(converted)) {
            raw_distance_m = converted;
            apply_new_distance(converted, now_ms);
            got_anything = true;
        }
    } else if (has_zone) {
        if (zone >= cfg.min_valid_zone && zone <= cfg.max_valid_zone) {
            float converted = zone_to_meters(zone);

            if (is_distance_plausible(converted)) {
                raw_distance_m = converted;
                apply_new_distance(converted, now_ms);
                got_anything = true;
            }
        }
    }

    /*
        2. Presence:
           - если есть явный флаг, используем его;
           - иначе presence выводим из валидной дистанции
             или из move_value > 0.
    */
    if (has_explicit_presence) {
        raw_presence = explicit_presence;
        got_anything = true;
    } else {
        bool inferred_presence = false;

        if (got_anything && filtered_distance_m > 0.01f) {
            inferred_presence = true;
        }

        if (has_move_value && move_value > 0.0f) {
            inferred_presence = true;
        }

        raw_presence = inferred_presence;
    }

    if (raw_presence) {
        last_presence_ms = now_ms;
    }

    activity_score_current = compute_current_activity_score(raw_presence, filtered_distance_m, current_speed_m_s);

    return got_anything || has_explicit_presence || has_move_value;
}

void Ld2420Radar::update_online_state(uint32_t now_ms) {
    if (last_frame_ms == 0) {
        online = false;
        return;
    }

    online = (now_ms - last_frame_ms <= cfg.stale_after_ms);
}

void Ld2420Radar::update_presence_state(uint32_t now_ms) {
    if (!online) {
        presence = false;
        return;
    }

    if (raw_presence) {
        presence = true;
        return;
    }

    if (last_presence_ms != 0 && (now_ms - last_presence_ms <= cfg.presence_hold_ms)) {
        presence = true;
        return;
    }

    presence = false;
}

void Ld2420Radar::update_second_history(uint32_t now_ms) {
    if (last_second_push_ms == 0) {
        last_second_push_ms = now_ms;
        return;
    }

    while (now_ms - last_second_push_ms >= 1000) {
        last_second_push_ms += 1000;

        uint8_t score_to_push = online ? activity_score_current : 0;

        second_scores[second_scores_head] = score_to_push;
        second_scores_head = (second_scores_head + 1) % SECOND_HISTORY_SIZE;

        if (second_scores_count < SECOND_HISTORY_SIZE) {
            ++second_scores_count;
        }
    }
}

void Ld2420Radar::apply_new_distance(float distance_m, uint32_t now_ms) {
    if (!is_distance_plausible(distance_m)) {
        return;
    }

    if (filtered_distance_m <= 0.0f) {
        filtered_distance_m = distance_m;
    } else {
        filtered_distance_m =
            (cfg.distance_ema_alpha * distance_m) +
            ((1.0f - cfg.distance_ema_alpha) * filtered_distance_m);
    }

    if (has_distance_for_speed && last_distance_sample_ms != 0 && now_ms > last_distance_sample_ms) {
        float dt_s = static_cast<float>(now_ms - last_distance_sample_ms) / 1000.0f;

        if (dt_s > 0.02f) {
            float speed = fabsf(distance_m - last_distance_for_speed_m) / dt_s;
            current_speed_m_s = speed;
        }
    } else {
        current_speed_m_s = 0.0f;
    }

    last_distance_for_speed_m = distance_m;
    last_distance_sample_ms = now_ms;
    has_distance_for_speed = true;
}

uint8_t Ld2420Radar::compute_current_activity_score(bool detected_presence, float distance_m, float speed_m_s) const {
    if (!detected_presence) {
        return 0;
    }

    /*
        Основная идея:
        - наличие человека уже даёт маленький базовый балл;
        - движение по изменению дистанции даёт основной вклад;
        - очень близкий / очень дальний человек не должен ломать шкалу.
    */
    float base_score = 1.5f;

    float motion_score = 0.0f;
    if (speed_m_s > cfg.activity_min_speed_m_s) {
        float span = cfg.activity_max_speed_m_s - cfg.activity_min_speed_m_s;

        if (span < 0.001f) {
            span = 0.001f;
        }

        float normalized =
            (speed_m_s - cfg.activity_min_speed_m_s) / span;

        normalized = clampf(normalized, 0.0f, 1.0f);
        motion_score = normalized * 8.5f;
    }

    float distance_bonus = 0.0f;
    if (distance_m > 0.0f && distance_m < 6.0f) {
        distance_bonus = 0.5f;
    }

    int score = static_cast<int>(roundf(base_score + motion_score + distance_bonus));
    score = clampi(score, 0, 10);

    return static_cast<uint8_t>(score);
}

uint8_t Ld2420Radar::compute_minute_activity_score() const {
    if (second_scores_count == 0) {
        return 0;
    }

    uint16_t samples = min<uint16_t>(60, second_scores_count);
    uint32_t sum = 0;

    for (uint16_t i = 0; i < samples; ++i) {
        int idx = static_cast<int>(second_scores_head) - 1 - i;
        if (idx < 0) {
            idx += SECOND_HISTORY_SIZE;
        }

        sum += second_scores[idx];
    }

    int avg = static_cast<int>(roundf(static_cast<float>(sum) / static_cast<float>(samples)));
    avg = clampi(avg, 0, 10);

    return static_cast<uint8_t>(avg);
}

String Ld2420Radar::compute_dynamics() const {
    /*
        Оцениваем 10 минут по минутным средним.
    */
    float minute_avg[10];
    int valid_windows = 0;

    for (int minute_index = 0; minute_index < 10; ++minute_index) {
        int start_offset = minute_index * 60;
        int end_offset = start_offset + 60;

        if (second_scores_count < static_cast<uint16_t>(start_offset + 1)) {
            break;
        }

        uint32_t sum = 0;
        int count = 0;

        for (int sec = start_offset; sec < end_offset; ++sec) {
            if (second_scores_count < static_cast<uint16_t>(sec + 1)) {
                break;
            }

            int idx = static_cast<int>(second_scores_head) - 1 - sec;
            if (idx < 0) {
                idx += SECOND_HISTORY_SIZE;
            }

            sum += second_scores[idx];
            ++count;
        }

        if (count > 0) {
            minute_avg[valid_windows] = static_cast<float>(sum) / static_cast<float>(count);
            ++valid_windows;
        }
    }

    if (valid_windows < 3) {
        return "constant";
    }

    /*
        Разворачиваем хронологически: oldest -> newest
    */
    float ordered[10];
    for (int i = 0; i < valid_windows; ++i) {
        ordered[i] = minute_avg[valid_windows - 1 - i];
    }

    float sum_y = 0.0f;
    float min_y = ordered[0];
    float max_y = ordered[0];

    for (int i = 0; i < valid_windows; ++i) {
        sum_y += ordered[i];
        if (ordered[i] < min_y) min_y = ordered[i];
        if (ordered[i] > max_y) max_y = ordered[i];
    }

    float mean_y = sum_y / static_cast<float>(valid_windows);

    float variance = 0.0f;
    float sum_x = 0.0f;
    float sum_x2 = 0.0f;
    float sum_xy = 0.0f;

    for (int i = 0; i < valid_windows; ++i) {
        float x = static_cast<float>(i);
        float y = ordered[i];

        float dy = y - mean_y;
        variance += dy * dy;

        sum_x += x;
        sum_x2 += x * x;
        sum_xy += x * y;
    }

    float stddev = sqrtf(variance / static_cast<float>(valid_windows));
    float range = max_y - min_y;

    float n = static_cast<float>(valid_windows);
    float denom = (n * sum_x2) - (sum_x * sum_x);

    float slope = 0.0f;
    if (fabsf(denom) > 0.0001f) {
        slope = ((n * sum_xy) - (sum_x * sum_y)) / denom;
    }

    /*
        Классификация:
        - почти не меняется -> constant
        - заметный устойчивый рост -> increasing
        - заметное устойчивое падение -> decreasing
        - иначе -> variable
    */
    if (range <= 1.2f || stddev <= 0.6f) {
        return "constant";
    }

    if (slope >= 0.22f) {
        return "increasing";
    }

    if (slope <= -0.22f) {
        return "decreasing";
    }

    return "variable";
}

String Ld2420Radar::get_state_json() const {
    String json = "{";
    json += "\"online\":";
    json += (online ? "true" : "false");
    json += ",";
    json += "\"presence\":";
    json += (presence ? "true" : "false");
    json += ",";
    json += "\"activity_score\":";
    json += String(compute_minute_activity_score());
    json += ",";
    json += "\"activity_score_current\":";
    json += String(activity_score_current);
    json += ",";
    json += "\"activity_score_dynamics\":\"";
    json += activity_score_dynamics;
    json += "\",";
    json += "\"distance_m\":";
    json += String((presence && online) ? filtered_distance_m : 0.0f, 2);
    json += "}";

    return json;
}

String Ld2420Radar::normalize_line(const String &line) const {
    String out = line;

    out.replace('\t', ' ');
    out.replace(';', ' ');
    out.replace('|', ' ');
    out.replace(',', ' ');
    out.replace(':', '=');

    while (out.indexOf("  ") >= 0) {
        out.replace("  ", " ");
    }

    out.trim();
    return out;
}

bool Ld2420Radar::extract_number_by_keys(const String &line, const char *keys[], size_t key_count, float &value_out) const {
    for (size_t i = 0; i < key_count; ++i) {
        if (parse_number_after_key(line, String(keys[i]), value_out)) {
            return true;
        }
    }
    return false;
}

bool Ld2420Radar::extract_bool_by_keys(const String &line, const char *keys[], size_t key_count, bool &value_out) const {
    for (size_t i = 0; i < key_count; ++i) {
        if (parse_bool_after_key(line, String(keys[i]), value_out)) {
            return true;
        }
    }
    return false;
}

bool Ld2420Radar::parse_number_after_key(const String &line, const String &key, float &value_out) const {
    int key_pos = line.indexOf(key);
    if (key_pos < 0) {
        return false;
    }

    int eq_pos = line.indexOf('=', key_pos + key.length());
    if (eq_pos < 0) {
        return false;
    }

    int start = eq_pos + 1;
    while (start < static_cast<int>(line.length()) && line[start] == ' ') {
        ++start;
    }

    if (start >= static_cast<int>(line.length())) {
        return false;
    }

    int end = start;
    bool has_digit = false;

    if (line[end] == '+' || line[end] == '-') {
        ++end;
    }

    while (end < static_cast<int>(line.length())) {
        char ch = line[end];

        if ((ch >= '0' && ch <= '9') || ch == '.') {
            has_digit = true;
            ++end;
            continue;
        }

        break;
    }

    if (!has_digit) {
        return false;
    }

    value_out = line.substring(start, end).toFloat();
    return true;
}

bool Ld2420Radar::parse_bool_after_key(const String &line, const String &key, bool &value_out) const {
    int key_pos = line.indexOf(key);
    if (key_pos < 0) {
        return false;
    }

    int eq_pos = line.indexOf('=', key_pos + key.length());
    if (eq_pos < 0) {
        return false;
    }

    int start = eq_pos + 1;
    while (start < static_cast<int>(line.length()) && line[start] == ' ') {
        ++start;
    }

    if (start >= static_cast<int>(line.length())) {
        return false;
    }

    String tail = line.substring(start);
    tail.trim();
    tail.toLowerCase();

    if (tail.startsWith("1") || tail.startsWith("true") || tail.startsWith("on") || tail.startsWith("yes") || tail.startsWith("detect")) {
        value_out = true;
        return true;
    }

    if (tail.startsWith("0") || tail.startsWith("false") || tail.startsWith("off") || tail.startsWith("no") || tail.startsWith("clear")) {
        value_out = false;
        return true;
    }

    return false;
}

float Ld2420Radar::zone_to_meters(float zone) const {
    return (zone * cfg.zone_to_meter_k) + cfg.zone_to_meter_b;
}

bool Ld2420Radar::is_distance_plausible(float distance_m) const {
    return distance_m >= 0.0f && distance_m <= 8.5f;
}

float Ld2420Radar::clampf(float value, float min_value, float max_value) const {
    if (value < min_value) return min_value;
    if (value > max_value) return max_value;
    return value;
}

int Ld2420Radar::clampi(int value, int min_value, int max_value) const {
    if (value < min_value) return min_value;
    if (value > max_value) return max_value;
    return value;
}