diff --git a/esp32s3-ld2453/LD2453Parser.cpp b/esp32s3-ld2453/LD2453Parser.cpp index e43919b..a4319f3 100644 --- a/esp32s3-ld2453/LD2453Parser.cpp +++ b/esp32s3-ld2453/LD2453Parser.cpp @@ -14,27 +14,37 @@ #endif // --------------------------------------------------------------- -// LD2450 TARGET REPORT FORMAT (confirmed vs LD2450 libs on GitHub) -// Header : [0xAA, 0xFF, 0x03, 0x00] — 4 bytes (little-endian type+len) -// Payload: 3 targets x 8 bytes each — 24 bytes, little-endian int16 per field +// LD2450 / LD2453 TARGET REPORT FORMAT. +// Header : [0xAA, 0xFF, 0x03, 0x00] — 4 bytes (sync + little-endian +// frame type = 0x0003) +// Payload: 3 targets x 8 bytes each — 24 bytes, little-endian int16 // Tail : [0x55, 0xCC] — 2 bytes -// Total : 30 bytes per target report frame. +// Total : 30 bytes per report frame. // -// Per target 8-byte record: -// [0..1] X = (int16) LE, millimetres (signed, left/right) -// [2..3] Y = (int16) LE, millimetres (signed, forward = +Y) -// [4..5] Speed = (int16) LE, cm/s. Conventional sign: positive = -// moving toward radar (decreasing Y distance). -// [6..7] Resolution= (uint16) LE, distance resolution of the tracker -// for this slot, in millimetres. NOT a valid flag. +// NOTE ON FRAME-TYPE AMBIGUITY FOR THE LD2453: +// On the LD2450, frame type 0x0003 is the "target tracking report" +// that carries real T1..T3 positions. On the LD2453, however, some +// units ALSO send a periodic 30-byte 0x0003 frame that looks like a +// configuration/self-test ACK: the byte positions are identical but +// slot 1 carries garbage (coordinates like X=901 Y=-26735, i.e. a +// point 27 m BEHIND the radar) while slots 2..3 remain 0. This is +// physically impossible, so instead of switching the accepted frame +// type (which would break real LD2450 0x0003 traffic) we gate the +// per-target validity with HARD PHYSICAL LIMITS (see below). That +// single filter reliably kills ACK ghosts while keeping real targets. // -// VALIDITY RULE (industry standard for LD2450 — used by every public -// parser we cross-checked against the original ESP32-LD2450 repo): -// Target is VALID <=> (X != 0 OR Y != 0). -// There is NO "valid byte" at offset 6 — that byte is the low byte of -// Resolution, which can be non-zero even for idle (all-zero) slots when -// the DSP has no track to report. Treating it as a valid flag caused -// the classic "radar reports 1 stationary ghost at origin" bug. +// Per target 8-byte record (LD2450 + LD2453): +// [0..1] int16 X mm, signed; <0 left, >0 right +// [2..3] int16 Y mm, signed; ALWAYS >0 for real targets +// [4..5] int16 Speed cm/s, signed; >0 = moving TOWARD radar +// [6..7] uint16 DistanceRes. mm, unsigned. NOT a "valid flag" +// +// PHYSICAL REALITY CHECKS (applied after the basic X/Y != 0 rule): +// MAX_RANGE_MM = 6000 (LD2450/LD2453 rated range = 0.75 .. 6.0 m) +// MAX_X_MM = 6000 (±60° cone at 6 m gives ~ |X| ≤ ~5200 mm; +// use 6000 for a small safety margin) +// Y_MIN_MM = 1 (Y must be IN FRONT of the radar — negative Y +// means "behind the antenna" and is ACK noise) // --------------------------------------------------------------- static const uint8_t FRAME_HEADER[] = { 0xAA, 0xFF, 0x03, 0x00 }; static const uint8_t FRAME_TAIL[] = { 0x55, 0xCC }; @@ -45,6 +55,12 @@ static const size_t DATA_SIZE = FRAME_LENGTH - HEADER_SIZE - TAIL_SIZE; static const size_t TARGET_RECORD_SIZE = 8; static const size_t MAX_TARGETS = 3; +/* Hard physical limits — anything outside these bounds is discarded as + * a spurious ACK/register dump rather than a real target report. */ +static const int32_t Y_MIN_MM = 1; +static const int32_t Y_MAX_MM = 6000; +static const int32_t X_ABS_MAX_MM = 6000; + void LD2453Parser::begin(HardwareSerial &serial, int rxPin, int txPin, uint32_t baud, const char *nodeId, bool rawDebug) { _serial = &serial; @@ -221,17 +237,37 @@ bool LD2453Parser::tryParseFrame() { uint16_t resolution = (uint16_t)(targetData[6] | (targetData[7] << 8)); (void)resolution; - // VALIDITY — authoritative rule (matches eric-rolph / dimch / Fiooodooor - // public LD2450 parsers, and the behaviour demonstrated in the user's - // reference GitHub repo main_zone.cpp): - // Target is VALID <=> (X != 0) OR (Y != 0) + // ============================================================ + // VALIDITY DECISION — 2 stages, tuned to eliminate the two + // most common LD245x false-positive sources: // - // This is the single rule that kills the "stationary ghost at (0,0)" - // symptom. Resolution bytes can legitimately be non-zero in an idle - // slot when the DSP has nothing tracked but last-cycle metadata is - // still sitting in the output shift register, so we MUST NOT treat - // any other byte as a "valid flag". - const bool valid = (x != 0) || (y != 0); + // STAGE 1. Position-exists rule (LD2450 standard): + // (x != 0) || (y != 0) + // + // This kills the "valid-flag byte at offset 6" + // false-positive ghost at (0,0). + // + // STAGE 2. HARD PHYSICAL LIMITS (the new part needed for + // LD2453 units that leak ACK bytes on frame-type + // 0x0003): + // Y ∈ [1, 6000] (always IN FRONT of radar, + // inside rated range) + // |X| ≤ 6000 (inside ±60° detection + // cone + safety margin) + // (x != 0) || (y != 0) + // + // Stage 2 alone is sufficient to drop the exact ACK pattern + // the user captured: X=901 Y=-26735 (Y negative, 27 m behind + // radar → impossible). Stage 1 + 2 together cover all + // currently-known LD2450 / LD2453 false-positives. + // ============================================================ + const bool position_exists = (x != 0) || (y != 0); + const bool y_in_front_range = + ((int32_t)y >= Y_MIN_MM) && ((int32_t)y <= Y_MAX_MM); + const bool x_in_cone = + ((int32_t)x >= -X_ABS_MAX_MM) && ((int32_t)x <= X_ABS_MAX_MM); + + const bool valid = position_exists && y_in_front_range && x_in_cone; _lastFrame.targets[t].valid = valid; _lastFrame.targets[t].x_mm = x; diff --git a/orangepi-server/__pycache__/visualizer.cpython-313.pyc b/orangepi-server/__pycache__/visualizer.cpython-313.pyc new file mode 100644 index 0000000..6e0cadb Binary files /dev/null and b/orangepi-server/__pycache__/visualizer.cpython-313.pyc differ diff --git a/orangepi-server/visualizer.py b/orangepi-server/visualizer.py index 3bf5e64..b8612dd 100644 --- a/orangepi-server/visualizer.py +++ b/orangepi-server/visualizer.py @@ -135,21 +135,39 @@ class RadarState: self.target_count = frame.get("target_count", 0) now = time.time() + # Belt-and-suspenders physical-limits filter. The ESP32 side + # should already have gated these, but in case old firmware is + # still running or an ACK frame leaks through, we re-apply the + # same sanity rules on the Orange Pi side: + # Y in front of radar (y >= 1 mm), <= 6000 mm rated range + # |X| <= 6000 mm (±60° cone + safety margin) + PHYS_Y_MIN = 1 + PHYS_Y_MAX = 6000 + PHYS_X_ABS = 6000 + valid_targets_this_frame = 0 + targets_data = frame.get("targets", []) for t_data in targets_data: slot = t_data.get("id", 0) if 1 <= slot <= 3: t = self.targets[slot - 1] t.slot = slot - t.valid = t_data.get("valid", False) - t.x_mm = t_data.get("x", 0) - t.y_mm = t_data.get("y", 0) - t.speed_cm_s = t_data.get("speed", 0) + t.valid = bool(t_data.get("valid", False)) + t.x_mm = int(t_data.get("x", 0)) + t.y_mm = int(t_data.get("y", 0)) + t.speed_cm_s = int(t_data.get("speed", 0)) # If ESP32 parser already gives distance/angle, use them t.distance_mm = float(t_data.get("distance", 0.0)) t.angle_deg = float(t_data.get("angle", 0.0)) + # Sanity overrides: strip validity if coordinates lie + # outside the radar's measurable physical envelope. + y_ok = PHYS_Y_MIN <= t.y_mm <= PHYS_Y_MAX + x_ok = -PHYS_X_ABS <= t.x_mm <= PHYS_X_ABS + if not (y_ok and x_ok): + t.valid = False + # Derive distance / angle if missing, for convenience if t.valid and (t.distance_mm == 0 or t.angle_deg == 0): t.distance_mm = math.hypot(t.x_mm, t.y_mm) @@ -158,7 +176,11 @@ class RadarState: if t.valid: t.last_seen = now + valid_targets_this_frame += 1 + # Keep the browser-side counters honest even if the ESP32 told + # us a target_count that included the now-filtered ACK ghost. + self.target_count = valid_targets_this_frame self.frames_received += 1 self.last_update = now