Turn raw tcpdump lines into a verdict so a human never has to eyeball packet sizes. This is THE tell that separates the three failure modes: * no packets -> nodes not reaching the Pi (AP / subnet / down) * ~48-64 B packets -> edge_tier>=1 (on-device features
(stdout, window_s)
| 1331 | cx = sum(p['x'] for p in poly) / n |
| 1332 | cy = sum(p['y'] for p in poly) / n |
| 1333 | return [{'x': p['x'] + (cx - p['x']) * frac, 'y': p['y'] + (cy - p['y']) * frac} |
| 1334 | for p in poly] |
| 1335 | |
| 1336 | |
| 1337 | def _rusense_geofence_verdict(positions, windows): |
| 1338 | """Port of evaluateGeofence(). positions={id:{x,y,z}}, windows={id:[rssi…]}. |
| 1339 | Returns a dict incl. ok, energetic, corroborated, interior, inside_perimeter, |
| 1340 | reason, hot_count, total. ok=False when fewer than 3 corners are mapped.""" |
| 1341 | nodes = [] |
| 1342 | for nid, pos in (positions or {}).items(): |
| 1343 | if not isinstance(pos, dict) or not _gf_finite(pos.get('x')) or not _gf_finite(pos.get('y')): |
| 1344 | continue |
| 1345 | win = windows.get(str(nid)) or [] |
| 1346 | disturbance = 1.0 - math.exp(-_gf_std(win) / (_GF_DBM_SCALE or 1e-9)) |
| 1347 | nodes.append({'id': str(nid), 'x': float(pos['x']), 'y': float(pos['y']), |
| 1348 | 'disturbance': disturbance, 'hot': disturbance >= _GF_HOT_THRESHOLD}) |
| 1349 | if len(nodes) < 3: |
| 1350 | return {'ok': False, 'reason': 'need >=3 mapped node corners', 'inside_perimeter': False, |
| 1351 | 'energetic': False, 'corroborated': False, 'interior': False, |
| 1352 | 'hot_count': 0, 'total': 0.0, 'mapped': len(nodes)} |
| 1353 | hull = _gf_convex_hull(nodes) |
| 1354 | inset = _gf_inset(hull, _GF_INSET_FRACTION) |
| 1355 | total = sum(n['disturbance'] for n in nodes) |
| 1356 | centroid = None |
| 1357 | if total > 1e-6: |
| 1358 | centroid = {'x': sum(n['x'] * n['disturbance'] for n in nodes) / total, |
| 1359 | 'y': sum(n['y'] * n['disturbance'] for n in nodes) / total} |
| 1360 | hot_count = sum(1 for n in nodes if n['hot']) |
| 1361 | interior = _gf_point_in_polygon(centroid, inset) if centroid else False |
| 1362 | corroborated = hot_count >= _GF_MIN_HOT_NODES |
| 1363 | energetic = total >= _GF_MIN_TOTAL_DISTURBANCE |
| 1364 | inside_perimeter = interior and corroborated and energetic |
| 1365 | if not energetic: |
| 1366 | reason = 'quiet' |
| 1367 | elif not corroborated: |
| 1368 | reason = f'edge/outside — only {hot_count} corner(s) disturbed' |
| 1369 | elif not interior: |
| 1370 | reason = 'disturbance centroid outside perimeter' |
| 1371 | else: |
| 1372 | reason = 'inside perimeter' |
| 1373 | return {'ok': True, 'reason': reason, 'inside_perimeter': inside_perimeter, |
| 1374 | 'energetic': energetic, 'corroborated': corroborated, 'interior': interior, |
| 1375 | 'hot_count': hot_count, 'total': round(total, 3), 'mapped': len(nodes)} |
| 1376 | |
| 1377 | |
| 1378 | def _rusense_get(path): |
| 1379 | """GET JSON from the local sensing-server; None on any failure.""" |
| 1380 | if _rusense_requests is None: |
| 1381 | return None |
| 1382 | try: |
| 1383 | resp = _rusense_requests.get(f"{RUSENSE_UPSTREAM_HTTP}{path}", timeout=4) |
| 1384 | if resp.status_code != 200: |
| 1385 | return None |
| 1386 | return resp.json() |
| 1387 | except Exception: |
| 1388 | return None |
| 1389 | |
| 1390 |
no test coverage detected