[telegram] me decis la actividad de las ultimas 4 semanas basadas en el wifi tracker?


**Looking for data sources**

It seems like I'm needing to inspect memory for some data, possibly related to a wifi tracker on the machine. Clarifying whether the user might have asked in Spanish could help. I should also find relevant files that could assist in my search. Maybe using a to-do list could organize this process better. Overall, it seems like I should search for the information methodically to ensure I gather everything necessary.

todo

Revisar actividad últimas 4 semanas desde wifi tracker

Encontrar datos del wifi tracker, calcular actividad de las últimas 4 semanas y responder al usuario.


Created #1: Revisar actividad últimas 4 semanas desde wifi tracker (pending)


bash

find /home/sebas -maxdepth 4 \( -iname '*wifi*tracker*' -o -iname '*wifitracker*' -o -iname '*wifi*' \) 2>/dev/null | head -200


find /home/sebas/runtime/agent-logs/wifi-presence -maxdepth 2 -type f | sort | tail -50


# WiFi Tracker

## Purpose

Track LAN/Wi‑Fi presence visible from `ballbox-first` and expose a simple dashboard at `/wifi-tracker/`.

This is presence estimation from the Pi.
It is not exact AP association telemetry.

## Pieces

- collector: `/home/sebas/pi-config/bin/wifi-presence-log`
- raw events: `/home/sebas/runtime/agent-logs/wifi-presence/activity.jsonl`
- rotated raw events: `/home/sebas/runtime/agent-logs/wifi-presence/activity-*.jsonl`
- state: `/home/sebas/runtime/agent-logs/wifi-presence/state.json`
- aliases: `/home/sebas/runtime/agent-logs/wifi-presence/aliases.json`
- fingerprints/evidence: `/home/sebas/runtime/agent-logs/wifi-presence/fingerprints.json`
- alert prefs: `/home/sebas/runtime/agent-logs/wifi-presence/alert-prefs.json`
- inventory ever-seen: `/home/sebas/runtime/agent-logs/wifi-presence/inventory.json`
- summary: `/var/www/wifi-tracker/summary.json`
- UI: `/var/www/wifi-tracker/index.html`
- API: `/home/sebas/pi-config/bin/wifi-tracker-api.py`

## Services

- `wifi-presence-log.timer`
- `wifi-presence-log.service`
- `wifi-tracker-api.service`

## URLs

- `/wifi-tracker/`
- `/wifi-tracker/api/summary`
- `/wifi-tracker/api/aliases`
- `/wifi-tracker/api/alert-prefs`
- `/wifi-tracker/api/health`

## Useful commands

```bash
systemctl --user status wifi-presence-log.timer
systemctl --user status wifi-tracker-api.service
systemctl --user start wifi-presence-log.service
journalctl --user -u wifi-tracker-api.service -n 50 --no-pager
curl -s http://127.0.0.1/wifi-tracker/api/health | jq
curl -s http://127.0.0.1/wifi-tracker/api/summary | jq '.totals'
tail -n 20 /home/sebas/runtime/agent-logs/wifi-presence/activity.jsonl
```

## Data model

Raw event types:

- `cycle_start`
- `router_probe`
- `device_snapshot`
- `device_online`
- `device_offline`
- `cycle_end`

Dashboard activity summary is still derived from approximate sessions.
A new session starts when snapshot gaps exceed the configured offline gap.
Buckets still exist in summary data, but the main UI now emphasizes a device presence timeline instead of percentage-style bars.

The main graph shows horizontal blue segments for periods considered present.
Those segments are derived from raw `device_snapshot` observations, with online turning on immediately and offline only turning off after 3 consecutive missing scan slots (~6 minutes at the current 2-minute scan interval).

The collector now also maintains an `inventory.json` ever-seen device list so historical devices remain visible in the UI even if they are not present in the current state snapshot.

The collector now also runs lightweight proactive fingerprinting for visible devices.
If a device has no manual alias, the service may write a descriptive alias directly into `aliases.json` with a ` [from AI]` suffix.
Manual aliases always win and are not overwritten.

Per-MAC evidence is stored in `fingerprints.json`, including guessed alias, confidence, sources, ports, and small evidence snippets.

## Retention

The raw JSONL log rotates automatically when it grows past the configured size threshold.
Old rotated files are kept in a small rolling set.

Alert toggles are stored in `alert-prefs.json`.
The UI can enable or disable alerts per device without removing the device from the inventory.


**Analyzing activity logs**

I need to summarize the activity from the last four weeks. It seems like I'd have to parse logs that might include device snapshots and some alias information. I'll want to inspect the summary to see if it should include session details. Maybe I could also check for API summaries. Let's take a closer look at what the summaries contain!

python - <<'PY'
import json
p='/var/www/wifi-tracker/summary.json'
print(open(p).read()[:4000])
PY


{
  "aliases": {
    "12:6e:0c:42:eb:c9": "Macbook sebas",
    "16:a3:ee:79:d7:8f": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
    "1c:3c:d4:06:a0:9b": "Router Casa",
    "2c:cf:67:94:23:8d": "ballbox-first",
    "38:2c:e5:4c:99:b7": "Enchufe smart - Luces balcon",
    "48:5c:2c:4c:36:c6": "Android TV - TV de Sala 68",
    "52:b2:e7:72:71:b5": "iphone nacho nc",
    "74:e6:b8:38:96:44": "LG Smart TV - Living",
    "92:1e:fe:36:21:42": "Dispositivo con MAC privada / probable m\u00f3vil [from AI]",
    "98:77:d5:11:b2:e0": "Luz WiZ 4",
    "98:77:d5:11:c1:ca": "Luz WiZ 3",
    "ae:54:7d:48:70:dc": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
    "b0:2a:43:83:47:fc": "Google Home Mini - Sala de estar",
    "c4:82:e1:b4:d7:ca": "Smart infrared remote",
    "cc:40:85:0a:cb:56": "Luz WiZ",
    "cc:40:85:0b:e1:5e": "Luz WiZ 2",
    "e6:5a:df:8b:89:96": "iphone rodri",
    "fe:8c:bc:ae:b6:e4": "iPhone sebas"
  },
  "cidr": "192.168.1.0/24",
  "devices": [
    {
      "activity": {
        "day": [
          {
            "bucket": "2026-05-24",
            "count": 0
          },
          {
            "bucket": "2026-05-25",
            "count": 0
          },
          {
            "bucket": "2026-05-26",
            "count": 0
          },
          {
            "bucket": "2026-05-27",
            "count": 0
          },
          {
            "bucket": "2026-05-28",
            "count": 0
          },
          {
            "bucket": "2026-05-29",
            "count": 0
          },
          {
            "bucket": "2026-05-30",
            "count": 0
          },
          {
            "bucket": "2026-05-31",
            "count": 0
          },
          {
            "bucket": "2026-06-01",
            "count": 0
          },
          {
            "bucket": "2026-06-02",
            "count": 0
          },
          {
            "bucket": "2026-06-03",
            "count": 0
          },
          {
            "bucket": "2026-06-04",
            "count": 0
          },
          {
            "bucket": "2026-06-05",
            "count": 19.69
          },
          {
            "bucket": "2026-06-06",
            "count": 20.52
          }
        ],
        "hour": [
          {
            "bucket": "2026-06-05T21:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-05T22:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-05T23:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T00:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T01:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T02:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T03:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T04:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T05:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T06:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T07:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T08:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T09:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T10:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T11:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T12:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T13:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T14:00Z",
            "count": 1.0
          },
          {
            "bucket": "2026-06-06T15:00Z",
            "count


Apple iPhone con MAC privada y puertos lockdownd [from AI]

Dispositivo con MAC privada / probable móvil [from AI]


{
  "devices": {
    "12:6e:0c:42:eb:c9": {
      "alias": "Macbook sebas",
      "display_name": "Macbook sebas",
      "first_seen_ts": "2026-05-05T17:00:53Z",
      "iface": "wlan0",
      "ip": "192.168.1.3",
      "last_seen_ts": "2026-06-06T20:29:18Z",
      "mac": "12:6e:0c:42:eb:c9",
      "neighbor_state": "DELAY",
      "online": true,
      "oui_prefix": "12:6E:0C",
      "reachable": true,
      "roles": []
    },
    "16:a3:ee:79:d7:8f": {
      "alias": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
      "display_name": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
      "first_seen_ts": "2026-05-28T15:04:22Z",
      "iface": "wlan0",
      "ip": "192.168.1.60",
      "last_seen_ts": "2026-06-05T15:16:35Z",
      "mac": "16:a3:ee:79:d7:8f",
      "neighbor_state": "PROBE",
      "online": false,
      "oui_prefix": "16:A3:EE",
      "reachable": true,
      "roles": []
    },
    "1c:3c:d4:06:a0:9b": {
      "alias": "Router Casa",
      "display_name": "Router Casa",
      "first_seen_ts": "2026-05-05T17:00:53Z",
      "hostname": "dev.opt",
      "iface": "wlan0",
      "ip": "192.168.1.1",
      "last_seen_ts": "2026-06-06T20:29:18Z",
      "mac": "1c:3c:d4:06:a0:9b",
      "neighbor_state": "REACHABLE",
      "online": true,
      "oui_prefix": "1C:3C:D4",
      "reachable": true,
      "roles": [
        "gateway"
      ]
    },
    "38:2c:e5:4c:99:b7": {
      "alias": "Enchufe smart - Luces balcon",
      "display_name": "Enchufe smart - Luces balcon",
      "first_seen_ts": "2026-05-05T19:11:04Z",
      "iface": "wlan0",
      "ip": "192.168.1.48",
      "last_seen_ts": "2026-06-06T20:29:18Z",
      "mac": "38:2c:e5:4c:99:b7",
      "neighbor_state": "REACHABLE",
      "online": true,
      "oui_prefix": "38:2C:E5",
      "reachable": true,
      "roles": []
    },
    "40:1a:58:c7:5c:3d": {
      "alias": null,
      "display_name": "40:1a:58:c7:5c:3d",
      "first_seen_ts": "2026-05-11T13:37:25Z",
      "iface": "wlan0",
      "ip": "192.168.1.87",
      "last_seen_ts": "2026-06-05T15:14:36Z",
      "mac": "40:1a:58:c7:5c:3d",
      "neighbor_state": "PROBE",
      "online": false,
      "oui_prefix": "40:1A:58",
      "reachable": true,
      "roles": []
    },
    "48:5c:2c:4c:36:c6": {
      "alias": "Android TV - TV de Sala 68",
      "display_name": "Android TV - TV de Sala 68",
      "first_seen_ts": "2026-05-05T17:00:53Z",
      "iface": "wlan0",
      "ip": "192.168.1.32",
      "last_seen_ts": "2026-06-06T20:29:18Z",
      "mac": "48:5c:2c:4c:36:c6",
      "neighbor_state": "REACHABLE",
      "online": true,
      "oui_prefix": "48:5C:2C",
      "reachable": true,
      "roles": []
    },
    "52:b2:e7:72:71:b5": {
      "alias": "iphone nacho nc",
      "display_name": "iphone nacho nc",
      "first_seen_ts": "2026-05-06T02:24:00Z",
      "iface": "wlan0",
      "ip": "192.168.1.19",
      "last_seen_ts": "2026-05-06T02:24:00Z",
      "mac": "52:b2:e7:72:71:b5",
      "neighbor_state": "STALE",
      "online": false,
      "oui_prefix": "52:B2:E7",
      "reachable": true,
      "roles": []
    },
    "74:e6:b8:38:96:44": {
      "alias": "LG Smart TV - Living",
      "display_name": "LG Smart TV - Living",
      "first_seen_ts": "2026-05-05T17:00:53Z",
      "iface": "wlan0",
      "ip": "192.168.1.59",
      "last_seen_ts": "2026-06-06T20:29:18Z",
      "mac": "74:e6:b8:38:96:44",
      "neighbor_state": "REACHABLE",
      "online": true,
      "oui_prefix": "74:E6:B8",
      "reachable": true,
      "roles": []
    },
    "92:1e:fe:36:21:42": {
      "alias": "Dispositivo con MAC privada / probable m\u00f3vil [from AI]",
      "display_name": "Dispositivo con MAC privada / probable m\u00f3vil [from AI]",
      "first_seen_ts": "2026-06-05T15:02:28Z",
      "iface": "wlan0",
      "ip": "192.168.1.47",
      "last_seen_ts": "2026-06-05T15:16:35Z",
      "mac": "92:1e:fe:36:21:42",
      "neighbor_state": "PROBE",
      "online": false,
      "oui_prefix": "92:1E:FE",
      "reachable": true,
      "roles": []
    },
    "98:77:d5:11:b2:e0": {
      "alias": "Luz WiZ 4",
      "display_name": "Luz WiZ 4",
      "first_seen_ts": "2026-05-07T20:51:15Z",
      "iface": "wlan0",
      "ip": "192.168.1.77",
      "last_seen_ts": "2026-05-25T20:22:28Z",
      "mac": "98:77:d5:11:b2:e0",
      "neighbor_state": "PROBE",
      "online": false,
      "oui_prefix": "98:77:D5",
      "reachable": true,
      "roles": []
    },
    "98:77:d5:11:c1:ca": {
      "alias": "Luz WiZ 3",
      "display_name": "Luz WiZ 3",
      "first_seen_ts": "2026-05-06T23:15:15Z",
      "iface": "wlan0",
      "ip": "192.168.1.76",
      "last_seen_ts": "2026-06-03T17:58:21Z",
      "mac": "98:77:d5:11:c1:ca",
      "neighbor_state": "PROBE",
      "online": false,
      "oui_prefix": "98:77:D5",
      "reachable": true,
      "roles": []
    },
    "ae:54:7d:48:70:dc": {
      "alias": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
      "display_name": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
      "first_seen_ts": "2026-05-23T03:50:16Z",
      "iface": "wlan0",
      "ip": "192.168.1.86",
      "last_seen_ts": "2026-05-25T20:24:21Z",
      "mac": "ae:54:7d:48:70:dc",
      "neighbor_state": "PROBE",
      "online": false,
      "oui_prefix": "AE:54:7D",
      "reachable": true,
      "roles": []
    },
    "b0:2a:43:83:47:fc": {
      "alias": "Google Home Mini - Sala de estar",
      "display_name": "Google Home Mini - Sala de estar",
      "first_seen_ts": "2026-05-05T17:00:53Z",
      "iface": "wlan0",
      "ip": "192.168.1.7",
      "last_seen_ts": "2026-06-06T20:29:18Z",
      "mac": "b0:2a:43:83:47:fc",
      "neighbor_state": "REACHABLE",
      "online": true,
      "oui_prefix": "B0:2A:43",
      "reachable": true,
      "roles": []
    },
    "c4:82:e1:b4:d7:ca": {
      "alias": "Smart infrared remote",
      "display_name": "Smart infrared remote",
      "first_seen_ts": "2026-05-05T19:11:04Z",
      "iface": "wlan0",
      "ip": "192.168.1.43",
      "last_seen_ts": "2026-06-06T20:29:18Z",
      "mac": "c4:82:e1:b4:d7:ca",
      "neighbor_state": "REACHABLE",
      "online": true,
      "oui_prefix": "C4:82:E1",
      "reachable": true,
      "roles": []

[60 more lines in file. Use offset=201 to continue.]


**Inspecting device activity**

I want to check activity from the last four weeks, starting from May 9. The user seems interested in overall activity, possibly focusing on devices. I think I need to compute notable periods, so I'll look into the raw logs to build a summary of device active hours and days. It's important to keep it concise, so I might use Python to parse the device snapshots over that range and inspect the event schema for details.

python - <<'PY'
import json,glob,itertools,os
files=sorted(glob.glob('/home/sebas/runtime/agent-logs/wifi-presence/activity*.jsonl'))
for f in files:
    print('FILE',os.path.basename(f))
    with open(f) as fh:
        for line in fh:
            obj=json.loads(line)
            if obj.get('event')=='device_snapshot':
                print(json.dumps(obj)[:500])
                raise SystemExit
PY


FILE activity-20260526-160119.jsonl
{"device": {"alias": "Router Casa", "display_name": "Router Casa", "hostname": "dev.opt", "iface": "wlan0", "ip": "192.168.1.1", "mac": "1c:3c:d4:06:a0:9b", "neighbor_state": "REACHABLE", "oui_prefix": "1C:3C:D4", "reachable": true, "roles": ["gateway"]}, "event": "device_snapshot", "gateway": "192.168.1.1", "iface": "wlan0", "ts": "2026-05-24T16:47:37Z"}


**Analyzing device usage**

I need to focus on interesting devices, perhaps personal phones and laptops. I’m considering counting overall usage, computing the hours and days for each device observed over the last four weeks while excluding infrastructure devices. I might want to list the top devices based on observed hours, along with any transient devices. This could give me useful insights into usage patterns! Let's see what I can uncover.

python - <<'PY'
import json,glob,datetime,collections,os
start=datetime.datetime(2026,5,9,tzinfo=datetime.timezone.utc)
end=datetime.datetime(2026,6,7,tzinfo=datetime.timezone.utc)
files=sorted(glob.glob('/home/sebas/runtime/agent-logs/wifi-presence/activity*.jsonl'))
obs=collections.defaultdict(list)
aliases={}
for f in files:
    with open(f) as fh:
        for line in fh:
            try:o=json.loads(line)
            except: continue
            if o.get('event')!='device_snapshot':
                continue
            ts=datetime.datetime.fromisoformat(o['ts'].replace('Z','+00:00'))
            if not (start<=ts<end):
                continue
            d=o['device']; mac=d['mac']; aliases[mac]=d.get('alias') or d.get('display_name') or mac
            obs[mac].append(ts)

rows=[]
for mac,times in obs.items():
    times=sorted(times)
    days={t.date().isoformat() for t in times}
    # approximate hours present via session stitching gaps <=6min
    total=datetime.timedelta()
    s=prev=times[0]
    for t in times[1:]:
        if t-prev <= datetime.timedelta(minutes=6):
            prev=t
        else:
            total += (prev-s)+datetime.timedelta(minutes=2)
            s=prev=t
    total += (prev-s)+datetime.timedelta(minutes=2)
    rows.append((total.total_seconds()/3600,len(days),len(times),mac,aliases[mac]))
rows.sort(reverse=True)
for hrs,days,cnt,mac,alias in rows[:20]:
    print(f'{hrs:6.1f}h {days:2d}d {cnt:5d} {mac} {alias}')
print('devices',len(rows))
PY


 315.6h 14d  9295 c4:82:e1:b4:d7:ca Smart infrared remote
 315.6h 14d  9295 b0:2a:43:83:47:fc Google Home Mini - Sala de estar
 315.6h 14d  9295 48:5c:2c:4c:36:c6 Android TV - TV de Sala 68
 315.6h 14d  9295 38:2c:e5:4c:99:b7 Enchufe smart - Luces balcon
 315.6h 14d  9295 1c:3c:d4:06:a0:9b Router Casa
  94.8h  5d  2793 cc:40:85:0a:cb:56 Luz WiZ
  53.5h  5d  1574 98:77:d5:11:c1:ca Luz WiZ 3
  17.3h  2d   510 ae:54:7d:48:70:dc Apple iPhone con MAC privada y puertos lockdownd [from AI]
  14.7h  1d   434 fe:8c:bc:ae:b6:e4 iPhone sebas
  14.6h  3d   431 40:1a:58:c7:5c:3d 40:1a:58:c7:5c:3d
   4.5h  1d   133 cc:40:85:0b:e1:5e Luz WiZ 2
   4.5h  1d   133 98:77:d5:11:b2:e0 Luz WiZ 4
   4.4h  2d   131 74:e6:b8:38:96:44 LG Smart TV - Living
   3.4h  1d   101 12:6e:0c:42:eb:c9 Macbook sebas
   1.9h  3d    57 16:a3:ee:79:d7:8f Apple iPhone con MAC privada y puertos lockdownd [from AI]
   0.3h  1d     8 92:1e:fe:36:21:42 Dispositivo con MAC privada / probable móvil [from AI]
devices 16


**Considering log retention limits**

I'm finding that the logs only have a 14-day retention period, and I need to mention this limitation. It's possible that the tracker started later or only retains data summaries. I see that the inventory shows the first data on May 5, while the logs began rotating on May 24, meaning the last four weeks aren't fully available. I should check the daily buckets to figure this out since I can't infer anything before May 24. I'll also compute daily activity for phones and laptops to create a more human-centric summary.

python - <<'PY'
import json,glob,datetime,collections
start=datetime.datetime(2026,5,9,tzinfo=datetime.timezone.utc)
files=sorted(glob.glob('/home/sebas/runtime/agent-logs/wifi-presence/activity*.jsonl'))
targets={
 '12:6e:0c:42:eb:c9':'Macbook sebas',
 'fe:8c:bc:ae:b6:e4':'iPhone sebas',
 'e6:5a:df:8b:89:96':'iphone rodri',
 '52:b2:e7:72:71:b5':'iphone nacho nc',
 '16:a3:ee:79:d7:8f':'iPhone privada A',
 'ae:54:7d:48:70:dc':'iPhone privada B',
 '92:1e:fe:36:21:42':'móvil privado C',
 '40:1a:58:c7:5c:3d':'desconocido 40:1a:58'
}
obs=collections.defaultdict(list)
for f in files:
    with open(f) as fh:
        for line in fh:
            o=json.loads(line)
            if o.get('event')!='device_snapshot': continue
            ts=datetime.datetime.fromisoformat(o['ts'].replace('Z','+00:00'))
            d=o['device']; mac=d['mac']
            if mac in targets:
                obs[mac].append(ts)
for mac,name in targets.items():
    times=sorted(obs.get(mac,[]))
    print('\n',name,mac)
    if not times:
        print(' no snapshots in raw retained logs')
        continue
    days=collections.Counter(t.date().isoformat() for t in times)
    print(' first',times[0],'last',times[-1],'days',len(days),'snaps',len(times))
    for day,c in sorted(days.items()):
        print('  ',day,c)
PY


 Macbook sebas 12:6e:0c:42:eb:c9
 first 2026-06-06 17:05:15+00:00 last 2026-06-06 20:29:18+00:00 days 1 snaps 101
   2026-06-06 101

 iPhone sebas fe:8c:bc:ae:b6:e4
 first 2026-06-06 03:31:15+00:00 last 2026-06-06 20:29:18+00:00 days 1 snaps 434
   2026-06-06 434

 iphone rodri e6:5a:df:8b:89:96
 no snapshots in raw retained logs

 iphone nacho nc 52:b2:e7:72:71:b5
 no snapshots in raw retained logs

 iPhone privada A 16:a3:ee:79:d7:8f
 first 2026-05-28 15:04:22+00:00 last 2026-06-05 15:16:35+00:00 days 3 snaps 57
   2026-05-28 15
   2026-06-01 17
   2026-06-05 25

 iPhone privada B ae:54:7d:48:70:dc
 first 2026-05-24 16:47:37+00:00 last 2026-05-25 20:24:21+00:00 days 2 snaps 510
   2026-05-24 7
   2026-05-25 503

 móvil privado C 92:1e:fe:36:21:42
 first 2026-06-05 15:02:28+00:00 last 2026-06-05 15:16:35+00:00 days 1 snaps 8
   2026-06-05 8

 desconocido 40:1a:58 40:1a:58:c7:5c:3d
 first 2026-05-24 16:47:37+00:00 last 2026-06-05 15:14:36+00:00 days 3 snaps 431
   2026-05-24 163
   2026-05-25 249
   2026-06-05 19


python - <<'PY'
import json,glob,datetime,collections
from zoneinfo import ZoneInfo
TZ=ZoneInfo('America/Argentina/Buenos_Aires')
files=sorted(glob.glob('/home/sebas/runtime/agent-logs/wifi-presence/activity*.jsonl'))
obs=collections.defaultdict(list)
for f in files:
  with open(f) as fh:
    for line in fh:
      o=json.loads(line)
      if o.get('event')!='device_snapshot': continue
      ts=datetime.datetime.fromisoformat(o['ts'].replace('Z','+00:00'))
      mac=o['device']['mac']
      obs[mac].append(ts)

def sessions(times):
  times=sorted(times)
  if not times: return []
  out=[]; s=prev=times[0]
  for t in times[1:]:
    if t-prev <= datetime.timedelta(minutes=6): prev=t
    else:
      out.append((s,prev+datetime.timedelta(minutes=2)))
      s=prev=t
  out.append((s,prev+datetime.timedelta(minutes=2)))
  return out
for mac,name in [
 ('fe:8c:bc:ae:b6:e4','iPhone sebas'),
 ('12:6e:0c:42:eb:c9','Macbook sebas'),
 ('16:a3:ee:79:d7:8f','iPhone privada A'),
 ('ae:54:7d:48:70:dc','iPhone privada B'),
 ('92:1e:fe:36:21:42','móvil privado C'),
 ('40:1a:58:c7:5c:3d','desconocido 40:1a:58'),
]:
  ss=sessions(obs.get(mac,[]))
  print('\n'+name)
  for s,e in ss[:10]:
    sl=s.astimezone(TZ); el=e.astimezone(TZ)
    dur=(e-s).total_seconds()/3600
    print(f' {sl:%Y-%m-%d %H:%M} -> {el:%H:%M} ({dur:.1f}h)')
PY


iPhone sebas
 2026-06-06 00:31 -> 10:37 (10.1h)
 2026-06-06 12:54 -> 17:31 (4.6h)

Macbook sebas
 2026-06-06 14:05 -> 17:31 (3.4h)

iPhone privada A
 2026-05-28 12:04 -> 12:34 (0.5h)
 2026-06-01 10:12 -> 10:47 (0.6h)
 2026-06-05 11:28 -> 12:18 (0.8h)

iPhone privada B
 2026-05-24 13:47 -> 14:02 (0.2h)
 2026-05-25 00:20 -> 17:26 (17.1h)

móvil privado C
 2026-06-05 12:02 -> 12:18 (0.3h)

desconocido 40:1a:58
 2026-05-24 13:47 -> 19:19 (5.5h)
 2026-05-25 08:44 -> 17:11 (8.4h)
 2026-06-05 11:38 -> 12:16 (0.6h)


**Finalizing CSV note**

I need to mention that I can’t provide a full 4-week history due to retention policies. I could say something like, "If you'd like, I can create a CSV for you." Plus, I'll make sure to update my to-do list to mark this task as complete. This way, everything is tidy and accounted for!


Updated #1 (pending → completed)


No completo.

El wifi tracker hoy sólo conserva
crudo desde el 24/05 al 06/06.
O sea: ~2 semanas, no 4.

Con lo retenido:

- Infra de casa:
  router, Google Home, TV sala,
  enchufe balcón, remote IR:
  casi 24/7 todo el período.

- Señales “humanas” detectadas:
  - 24/05:
    un desconocido
    `40:1a:58:c7:5c:3d`
    ~5.5h
    +
    un iPhone privado
    ~0.2h
  - 25/05:
    iPhone privado
    ~17.1h
    +
    mismo desconocido
    ~8.4h
  - 28/05:
    iPhone privado
    ~0.5h
  - 01/06:
    iPhone privado
    ~0.6h
  - 05/06:
    iPhone privado
    ~0.8h
    +
    otro móvil privado
    ~0.3h
    +
    desconocido
    ~0.6h
  - 06/06:
    `iPhone sebas`
    ~14.6h detectado
    `Macbook sebas`
    ~3.4h detectada

Dato importante:
- Antes del 06/06 no aparece
  “Macbook sebas”.
- “iPhone sebas” sólo aparece
  el 06/06.
- Antes hay varios iPhone con
  MAC privada, así que no puedo
  afirmar cuál era cuál.

Si querés, te lo rehago en uno de
estos formatos:
1. resumen por día
2. foco en “presencia humana”
3. tabla por dispositivo
4. te dejo export CSV/JSON


[telegram] que raro que no haya al menos una semana mas de actividad (donde aprox se creo el proyecto, mira metadata de archivos)

cuantos moviles hubieron y que perfil de conexion?


todo

Revisar metadata y móviles en wifi tracker

Ver si hay más historia por metadata/rotación y contar móviles con perfil de conexión.


Created #2: Revisar metadata y móviles en wifi tracker (pending)


bash

stat -c '%n | size=%s | mtime=%y | birth=%w' /home/sebas/runtime/agent-logs/wifi-presence/* /var/www/wifi-tracker/summary.json 2>/dev/null | sort

python - <<'PY'
import glob,os
for f in sorted(glob.glob('/home/sebas/runtime/agent-logs/wifi-presence/activity*.jsonl')):
    with open(f,'rb') as fh:
        lines=sum(1 for _ in fh)
    print(os.path.basename(f), lines)
PY


/home/sebas/runtime/agent-logs/wifi-presence/activity-20260526-160119.jsonl | size=4194428 | mtime=2026-05-26 13:01:19.988554171 -0300 | birth=2026-05-24 13:45:37.874647493 -0300
/home/sebas/runtime/agent-logs/wifi-presence/activity-20260528-213316.jsonl | size=4194420 | mtime=2026-05-28 18:33:06.193631676 -0300 | birth=2026-05-26 13:01:19.992554190 -0300
/home/sebas/runtime/agent-logs/wifi-presence/activity-20260531-101721.jsonl | size=4194545 | mtime=2026-05-31 07:17:20.936282326 -0300 | birth=2026-05-28 18:33:16.141678543 -0300
/home/sebas/runtime/agent-logs/wifi-presence/activity-20260602-185215.jsonl | size=4194357 | mtime=2026-06-02 15:52:04.750346827 -0300 | birth=2026-05-31 07:17:21.536285022 -0300
/home/sebas/runtime/agent-logs/wifi-presence/activity-20260605-041624.jsonl | size=4194360 | mtime=2026-06-05 01:16:23.440143937 -0300 | birth=2026-06-02 15:52:15.074397273 -0300
/home/sebas/runtime/agent-logs/wifi-presence/activity.jsonl | size=3040244 | mtime=2026-06-06 17:33:01.141522284 -0300 | birth=2026-06-05 01:16:24.116147446 -0300
/home/sebas/runtime/agent-logs/wifi-presence/alert-prefs.json | size=818 | mtime=2026-05-06 14:38:41.203889755 -0300 | birth=2026-05-07 18:41:32.300590827 -0300
/home/sebas/runtime/agent-logs/wifi-presence/aliases.json | size=901 | mtime=2026-06-05 12:02:28.365945750 -0300 | birth=2026-06-05 12:02:28.365945750 -0300
/home/sebas/runtime/agent-logs/wifi-presence/fingerprints.json | size=13296 | mtime=2026-06-06 17:31:19.221151195 -0300 | birth=2026-06-06 17:31:19.221151195 -0300
/home/sebas/runtime/agent-logs/wifi-presence/inventory.json | size=7914 | mtime=2026-06-06 17:31:19.349151648 -0300 | birth=2026-06-06 17:31:19.349151648 -0300
/home/sebas/runtime/agent-logs/wifi-presence/state.json | size=8322 | mtime=2026-06-06 17:31:19.349151648 -0300 | birth=2026-06-06 17:31:19.349151648 -0300
/var/www/wifi-tracker/summary.json | size=1729414 | mtime=2026-06-06 17:31:19.829153345 -0300 | birth=2026-06-06 17:31:19.825153331 -0300


activity-20260526-160119.jsonl 13736
activity-20260528-213316.jsonl 14086
activity-20260531-101721.jsonl 14309
activity-20260602-185215.jsonl 14161
activity-20260605-041624.jsonl 14197
activity.jsonl 10222


**Considering project creation**

I’m thinking about creating a project around May 5, starting with the inventory I first saw. I'll need to count the smartphones and gather their profiles. Using fingerprints might help in identifying the smartphones, so let's inspect those fingerprints and summarize the devices. It seems like classifying them could be a useful step. This approach might give me a clearer understanding of what I have!

python - <<'PY'
import json,glob,datetime,collections,re
files=sorted(glob.glob('/home/sebas/runtime/agent-logs/wifi-presence/activity*.jsonl'))
obs=collections.defaultdict(list)
for f in files:
  with open(f) as fh:
    for line in fh:
      o=json.loads(line)
      if o.get('event')!='device_snapshot': continue
      d=o['device']
      obs[d['mac']].append(datetime.datetime.fromisoformat(o['ts'].replace('Z','+00:00')))
# mobile-ish candidates from aliases/fingerprints patterns will be decided separately; print all non-always-on irregular devices
for mac,times in sorted(obs.items(), key=lambda kv: len(kv[1])):
  days=sorted({t.date().isoformat() for t in times})
  print(mac, len(times), days[:10], '...', days[-3:])
PY


{
  "12:6e:0c:42:eb:c9": {
    "alias_source": "manual",
    "confidence": 0.96,
    "evidence": {
      "mdns": [
        {
          "host": "Sebastians-MacBook-Pro-2.local",
          "port": "22",
          "service_name": "Sebastian\\226\\128\\153s MacBook Pro \\0402\\041",
          "service_type": "_sftp-ssh._tcp",
          "txt": [
            ""
          ]
        },
        {
          "host": "Sebastians-MacBook-Pro-2.local",
          "port": "22",
          "service_name": "Sebastian\\226\\128\\153s MacBook Pro \\0402\\041",
          "service_type": "_ssh._tcp",
          "txt": [
            ""
          ]
        },
        {
          "host": "Sebastians-MacBook-Pro-2.local",
          "port": "22",
          "service_name": "Sebastian\\226\\128\\153s MacBook Pro \\0402\\041",
          "service_type": "_ssh._tcp",
          "txt": [
            ""
          ]
        }
      ]
    },
    "guess_alias": "Apple MacBook con SSH / companion-link [from AI]",
    "ip": "192.168.1.3",
    "last_probe_ts": "2026-06-06T20:31:19Z",
    "oui_prefix": "12:6E:0C",
    "ports_open": [],
    "private_mac": true,
    "sources": [
      "mdns-apple-mac"
    ]
  },
  "16:a3:ee:79:d7:8f": {
    "alias_source": "ai",
    "confidence": 0.45,
    "evidence": {
      "mdns": []
    },
    "guess_alias": "Dispositivo con MAC privada / probable m\u00f3vil [from AI]",
    "ip": "192.168.1.60",
    "last_probe_ts": "2026-06-05T15:16:35Z",
    "oui_prefix": "16:A3:EE",
    "ports_open": [],
    "private_mac": true,
    "sources": [
      "private-mac-only"
    ]
  },
  "1c:3c:d4:06:a0:9b": {
    "alias_source": "manual",
    "confidence": 0.0,
    "evidence": {
      "mdns": []
    },
    "guess_alias": null,
    "ip": "192.168.1.1",
    "last_probe_ts": "2026-06-06T20:31:19Z",
    "oui_prefix": "1C:3C:D4",
    "ports_open": [],
    "private_mac": false,
    "sources": []
  },
  "38:2c:e5:4c:99:b7": {
    "alias_source": "manual",
    "confidence": 0.88,
    "evidence": {
      "mdns": []
    },
    "guess_alias": "Tuya smart device con puerto 6668 [from AI]",
    "ip": "192.168.1.48",
    "last_probe_ts": "2026-06-06T20:31:19Z",
    "oui_prefix": "38:2C:E5",
    "ports_open": [
      6668
    ],
    "private_mac": false,
    "sources": [
      "tuya-6668"
    ]
  },
  "40:1a:58:c7:5c:3d": {
    "alias_source": "ai",
    "confidence": 0.0,
    "evidence": {
      "mdns": []
    },
    "guess_alias": null,
    "ip": "192.168.1.87",
    "last_probe_ts": "2026-06-05T15:14:28Z",
    "oui_prefix": "40:1A:58",
    "ports_open": [],
    "private_mac": false,
    "sources": []
  },
  "48:5c:2c:4c:36:c6": {
    "alias_source": "manual",
    "confidence": 0.98,
    "evidence": {
      "http_8008": "",
      "mdns": [
        {
          "host": "Android_adb0e3fbcacb48a9a81bec6e2ced375b.local",
          "port": "6466",
          "service_name": "TV de Sala 68",
          "service_type": "_androidtvremote2._tcp",
          "txt": [
            "\"bt=48:5C:2C:4C:36:C7\""
          ]
        },
        {
          "host": "2570ae6a-6064-075a-8f04-490fa600a876.local",
          "port": "8009",
          "service_name": "onn\\.-4K-Streaming-Bo-2570ae6a6064075a8f04490fa600a876",
          "service_type": "_googlecast._tcp",
          "txt": [
            "\"rs=Youtube\" \"rr=233637DE\" \"ct=242664\" \"nf=1\" \"bs=FA8F8382860A\" \"st=1\" \"ca=266757\" \"fn=TV de Sala 68\" \"ic=/setup/icon.png\" \"md=onn. 4K Streaming Box\" \"ve=05\" \"rm=A7A19734552BA763\" \"cd=0AD2115E2C385B056E2682A72D6D8DB8\" \"id=2570ae6a6064075a8f04490fa600a876\""
          ]
        }
      ],
      "mdns_names": [
        "TV de Sala 68",
        "onn\\.-4K-Streaming-Bo-2570ae6a6064075a8f04490fa600a876"
      ]
    },
    "guess_alias": "Android TV con mDNS remote [from AI]",
    "ip": "192.168.1.32",
    "last_probe_ts": "2026-06-06T20:31:16Z",
    "oui_prefix": "48:5C:2C",
    "ports_open": [
      8008,
      8009,
      8443
    ],
    "private_mac": false,
    "sources": [
      "mdns-googlecast",
      "mdns-androidtv",
      "google-cast"
    ]
  },
  "74:e6:b8:38:96:44": {
    "alias_source": "manual",
    "confidence": 0.98,
    "evidence": {
      "mdns": [
        {
          "host": "LGwebOSTV.local",
          "port": "7000",
          "service_name": "\\091LG\\093 webOS TV UP7750PSB",
          "service_type": "_airplay._tcp",
          "txt": [
            "\"pk=113f377f21290c9e59b022f2afb3df42748e4bfc1e3501c70b95687b5a229303\" \"gcgl=0\" \"gid=00000000-0000-0000-0000-D2264A1A5C0F\" \"psi=00000000-0000-0000-0000-D2264A1A5C0F\" \"pi=D2:26:4A:1A:5C:0F\" \"srcvers=377.25.06\" \"protovers=1.1\" \"serialNumber=206NSNS05471_74:e6:b8:38:96:44\\010\" \"manufacturer=LG Electronics\" \"model=43UP7750PSB\" \"flags=0x644\" \"fv=p20.03.53.45\" \"rsf=0x3\" \"features=0x7F8AD0,0x38BCB46\" \"deviceid=D2:26:4A:1A:5C:0F\" \"acl=0\""
          ]
        },
        {
          "host": "LGwebOSTV.local",
          "port": "7000",
          "service_name": "\\091LG\\093 webOS TV UP7750PSB",
          "service_type": "_airplay._tcp",
          "txt": [
            "\"pk=113f377f21290c9e59b022f2afb3df42748e4bfc1e3501c70b95687b5a229303\" \"gcgl=0\" \"gid=00000000-0000-0000-0000-D2264A1A5C0F\" \"psi=00000000-0000-0000-0000-D2264A1A5C0F\" \"pi=D2:26:4A:1A:5C:0F\" \"srcvers=377.25.06\" \"protovers=1.1\" \"serialNumber=206NSNS05471_74:e6:b8:38:96:44\\010\" \"manufacturer=LG Electronics\" \"model=43UP7750PSB\" \"flags=0x644\" \"fv=p20.03.53.45\" \"rsf=0x3\" \"features=0x7F8AD0,0x38BCB46\" \"deviceid=D2:26:4A:1A:5C:0F\" \"acl=0\""
          ]
        },
        {
          "host": "LGwebOSTV.local",
          "port": "33091",
          "service_name": "LG webOS TV 3CDE",
          "service_type": "_hap._tcp",
          "txt": [
            "\"sh=u960TA==\" \"ci=31\" \"sf=0\" \"s#=350\" \"pv=1.1\" \"md=LG webOS TV\" \"id=D2:26:4A:1A:5C:0F\" \"ff=8\" \"c#=1\""
          ]
        },
        {
          "host": "LGwebOSTV.local",
          "port": "33091",
          "service_name": "LG webOS TV 3CDE",
          "service_type": "_hap._tcp",
          "txt": [
            "\"sh=u960TA==\" \"ci=31\" \"sf=0\" \"s#=350\" \"pv=1.1\" \"md=LG webOS TV\" \"id=D2:26:4A:1A:5C:0F\" \"ff=8\" \"c#=1\""
          ]
        },
        {
          "host": "LGwebOSTV.local",
          "port": "7250",
          "service_name": "\\091LG\\093 webOS TV UP7750PSB",
          "service_type": "_display._tcp",
          "txt": [
            "\"mac=74:e6:b8:38:96:44\" \"1AS=0\" \"dev_name=MiracastR2Sink\" \"proto_ver=2.0\" \"aud_src=0\" \"aud_psink=0\" \"Hdcp=HDCP2.2\" \"ses_avail=1\" \"Type=p-sink\""
          ]
        }
      ]
    },
    "guess_alias": "LG Smart TV con puertos webOS / AirPlay [from AI]",
    "ip": "192.168.1.59",
    "last_probe_ts": "2026-06-06T20:31:19Z",
    "oui_prefix": "74:E6:B8",
    "ports_open": [
      3000,
      7000
    ],
    "private_mac": false,
    "sources": [
      "lg-tv"
    ]
  },
  "92:1e:fe:36:21:42": {
    "alias_source": "ai",

[220 more lines in file. Use offset=221 to continue.]


92:1e:fe:36:21:42 8 ['2026-06-05'] ... ['2026-06-05']
16:a3:ee:79:d7:8f 57 ['2026-05-28', '2026-06-01', '2026-06-05'] ... ['2026-05-28', '2026-06-01', '2026-06-05']
12:6e:0c:42:eb:c9 102 ['2026-06-06'] ... ['2026-06-06']
74:e6:b8:38:96:44 132 ['2026-05-28', '2026-06-06'] ... ['2026-05-28', '2026-06-06']
cc:40:85:0b:e1:5e 133 ['2026-05-25'] ... ['2026-05-25']
98:77:d5:11:b2:e0 133 ['2026-05-25'] ... ['2026-05-25']
40:1a:58:c7:5c:3d 431 ['2026-05-24', '2026-05-25', '2026-06-05'] ... ['2026-05-24', '2026-05-25', '2026-06-05']
fe:8c:bc:ae:b6:e4 435 ['2026-06-06'] ... ['2026-06-06']
ae:54:7d:48:70:dc 510 ['2026-05-24', '2026-05-25'] ... ['2026-05-24', '2026-05-25']
98:77:d5:11:c1:ca 1574 ['2026-05-25', '2026-05-27', '2026-06-01', '2026-06-02', '2026-06-03'] ... ['2026-06-01', '2026-06-02', '2026-06-03']
cc:40:85:0a:cb:56 2793 ['2026-05-24', '2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28'] ... ['2026-05-26', '2026-05-27', '2026-05-28']
1c:3c:d4:06:a0:9b 9296 ['2026-05-24', '2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29', '2026-05-30', '2026-05-31', '2026-06-01', '2026-06-02'] ... ['2026-06-04', '2026-06-05', '2026-06-06']
b0:2a:43:83:47:fc 9296 ['2026-05-24', '2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29', '2026-05-30', '2026-05-31', '2026-06-01', '2026-06-02'] ... ['2026-06-04', '2026-06-05', '2026-06-06']
48:5c:2c:4c:36:c6 9296 ['2026-05-24', '2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29', '2026-05-30', '2026-05-31', '2026-06-01', '2026-06-02'] ... ['2026-06-04', '2026-06-05', '2026-06-06']
c4:82:e1:b4:d7:ca 9296 ['2026-05-24', '2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29', '2026-05-30', '2026-05-31', '2026-06-01', '2026-06-02'] ... ['2026-06-04', '2026-06-05', '2026-06-06']
38:2c:e5:4c:99:b7 9296 ['2026-05-24', '2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29', '2026-05-30', '2026-05-31', '2026-06-01', '2026-06-02'] ... ['2026-06-04', '2026-06-05', '2026-06-06']


**Identifying project details**

I need to start by checking the inventory, as the first seen indicates the project began on May 5. It seems I only have six files due to the rolling nature of retention. I should also identify the count of mobile devices, and perhaps I can use fingerprints to continue tracking the remaining device entries, which might include iPhones. I'll read the rest to find what's relevant.


    "confidence": 0.45,
    "evidence": {
      "mdns": []
    },
    "guess_alias": "Dispositivo con MAC privada / probable m\u00f3vil [from AI]",
    "ip": "192.168.1.47",
    "last_probe_ts": "2026-06-05T15:16:29Z",
    "oui_prefix": "92:1E:FE",
    "ports_open": [],
    "private_mac": true,
    "sources": [
      "private-mac-only"
    ]
  },
  "98:77:d5:11:b2:e0": {
    "alias_source": "manual",
    "confidence": 0.0,
    "evidence": {
      "mdns": [
        {
          "host": "9877D511B2E0.local",
          "port": "5540",
          "service_name": "483A52B77EBF4649",
          "service_type": "_matterc._udp",
          "txt": [
            "\"PI=Repeatedly power your light off & on until a blue pulse indicates setup mode, then complete the setup in your commissioner app\" \"PH=17\" \"RI=0D00EE4D6C804E21DD2259169D3A3F289A19\" \"CM=0\" \"D=267\" \"DT=0\" \"VP=4107+8870\""
          ]
        }
      ]
    },
    "guess_alias": null,
    "ip": "192.168.1.77",
    "last_probe_ts": "2026-05-25T20:22:18Z",
    "oui_prefix": "98:77:D5",
    "ports_open": [],
    "private_mac": false,
    "sources": []
  },
  "98:77:d5:11:c1:ca": {
    "alias_source": "manual",
    "confidence": 0.0,
    "evidence": {
      "mdns": [
        {
          "host": "9877D511C1CA.local",
          "port": "5540",
          "service_name": "5F1B21770EE48C66",
          "service_type": "_matterc._udp",
          "txt": [
            "\"PI=Repeatedly power your light off & on until a blue pulse indicates setup mode, then complete the setup in your commissioner app\" \"PH=17\" \"RI=1400802CF199C7D803EA02C37B4D98BEA7C8\" \"CM=0\" \"D=20\" \"DT=0\" \"VP=4107+8870\""
          ]
        }
      ]
    },
    "guess_alias": null,
    "ip": "192.168.1.76",
    "last_probe_ts": "2026-06-03T17:58:18Z",
    "oui_prefix": "98:77:D5",
    "ports_open": [],
    "private_mac": false,
    "sources": []
  },
  "ae:54:7d:48:70:dc": {
    "alias_source": "ai",
    "confidence": 0.45,
    "evidence": {
      "mdns": []
    },
    "guess_alias": "Dispositivo con MAC privada / probable m\u00f3vil [from AI]",
    "ip": "192.168.1.86",
    "last_probe_ts": "2026-05-25T20:24:19Z",
    "oui_prefix": "AE:54:7D",
    "ports_open": [],
    "private_mac": true,
    "sources": [
      "private-mac-only"
    ]
  },
  "b0:2a:43:83:47:fc": {
    "alias_source": "manual",
    "confidence": 0.0,
    "evidence": {
      "http_8008": "",
      "mdns": [
        {
          "host": "0252a76e-5468-8afd-6aca-3880c548f61a.local",
          "port": "10101",
          "service_name": "0725D5654C882124A9850097A514F6EB",
          "service_type": "_googcrossdevice._tcp",
          "txt": [
            "\"ts=3\" \"rp=50\" \"id=0725D5654C882124A9850097A514F6EB\""
          ]
        },
        {
          "host": "0252a76e-5468-8afd-6aca-3880c548f61a.local",
          "port": "10001",
          "service_name": "0252a76e-5468-8afd-6aca-3880c548f61a",
          "service_type": "_googlezone._tcp",
          "txt": [
            "\"CGS\" \"UDS\" \"id=0725D5654C882124A9850097A514F6EB\""
          ]
        },
        {
          "host": "0252a76e-5468-8afd-6aca-3880c548f61a.local",
          "port": "8009",
          "service_name": "Google-Home-Mini-0252a76e54688afd6aca3880c548f61a",
          "service_type": "_googlecast._tcp",
          "txt": [
            "\"rs=\" \"nf=1\" \"bs=FA8FCA582B2F\" \"st=0\" \"ca=199172\" \"fn=Sala de estar\" \"ic=/setup/icon.png\" \"md=Google Home Mini\" \"ve=05\" \"rm=\" \"cd=0725D5654C882124A9850097A514F6EB\" \"id=0252a76e54688afd6aca3880c548f61a\""
          ]
        }
      ],
      "mdns_names": [
        "0725D5654C882124A9850097A514F6EB",
        "0252a76e-5468-8afd-6aca-3880c548f61a",
        "Google-Home-Mini-0252a76e54688afd6aca3880c548f61a"
      ]
    },
    "guess_alias": null,
    "ip": "192.168.1.7",
    "last_probe_ts": "2026-06-06T20:31:19Z",
    "oui_prefix": "B0:2A:43",
    "ports_open": [
      8008,
      8009,
      8443
    ],
    "private_mac": false,
    "sources": [
      "mdns-googlecast",
      "google-cast"
    ]
  },
  "c4:82:e1:b4:d7:ca": {
    "alias_source": "manual",
    "confidence": 0.93,
    "evidence": {
      "mdns": []
    },
    "guess_alias": "Tuya smart controller / smart infrared con puerto 6668 [from AI]",
    "ip": "192.168.1.43",
    "last_probe_ts": "2026-06-06T20:31:17Z",
    "oui_prefix": "C4:82:E1",
    "ports_open": [
      6668
    ],
    "private_mac": false,
    "sources": [
      "tuya-6668"
    ]
  },
  "cc:40:85:0a:cb:56": {
    "alias_source": "manual",
    "confidence": 0.92,
    "evidence": {
      "mdns": [],
      "vendor": "WiZ"
    },
    "guess_alias": "WiZ / Matter smart light con vendor WiZ [from AI]",
    "ip": "192.168.1.8",
    "last_probe_ts": "2026-05-28T15:32:33Z",
    "oui_prefix": "CC:40:85",
    "ports_open": [],
    "private_mac": false,
    "sources": [
      "oui-wiz"
    ]
  },
  "cc:40:85:0b:e1:5e": {
    "alias_source": "manual",
    "confidence": 0.98,
    "evidence": {
      "mdns": [
        {
          "host": "CC40850BE15E.local",
          "port": "5540",
          "service_name": "BA4007BE5FE7EBFE",
          "service_type": "_matterc._udp",
          "txt": [
            "\"PI=Repeatedly power your light off & on until a blue pulse indicates setup mode, then complete the setup in your commissioner app\" \"PH=17\" \"RI=040058697234D9C8F5C666C2EEB9D2CD859F\" \"CM=0\" \"D=1966\" \"DT=0\" \"VP=4107+8610\""
          ]
        }
      ],
      "mdns_types": [
        "_matterc._udp"
      ],
      "vendor": "WiZ"
    },
    "guess_alias": "WiZ / Matter smart light con vendor WiZ [from AI]",
    "ip": "192.168.1.9",
    "last_probe_ts": "2026-05-25T20:22:28Z",
    "oui_prefix": "CC:40:85",
    "ports_open": [],
    "private_mac": false,
    "sources": [
      "mdns-matter",
      "oui-wiz"
    ]
  },
  "fe:8c:bc:ae:b6:e4": {
    "alias_source": "manual",
    "confidence": 0.95,
    "evidence": {
      "mdns": []
    },
    "guess_alias": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
    "ip": "192.168.1.44",
    "last_probe_ts": "2026-06-06T20:31:18Z",
    "oui_prefix": "FE:8C:BC",
    "ports_open": [
      49152,
      62078
    ],
    "private_mac": true,
    "sources": [
      "apple-lockdownd"
    ]
  }
}


**Calculating mobile device count**

I need to compute the total count of mobile devices and create a succinct profile. I'm estimating maybe around 5 mobile candidates currently in retained logs. Should I include MacBook? Probably not. It could be 6 if I consider unknown entries, since 40 aren’t mobile. Initially, I want to confirm if the project existed earlier based on metadata. The first seen date from inventory is 05/05, so I should inspect the inventory further to determine the exact number of mobile devices.

python - <<'PY'
import json,glob,datetime,collections
from zoneinfo import ZoneInfo
TZ=ZoneInfo('America/Argentina/Buenos_Aires')
inv=json.load(open('/home/sebas/runtime/agent-logs/wifi-presence/inventory.json'))['devices']
fp=json.load(open('/home/sebas/runtime/agent-logs/wifi-presence/fingerprints.json'))
files=sorted(glob.glob('/home/sebas/runtime/agent-logs/wifi-presence/activity*.jsonl'))
obs=collections.defaultdict(list)
for f in files:
  with open(f) as fh:
    for line in fh:
      o=json.loads(line)
      if o.get('event')!='device_snapshot': continue
      d=o['device']; obs[d['mac']].append(datetime.datetime.fromisoformat(o['ts'].replace('Z','+00:00')))

def sess(times):
  times=sorted(times)
  if not times: return []
  out=[]; s=prev=times[0]
  for t in times[1:]:
    if t-prev <= datetime.timedelta(minutes=6): prev=t
    else:
      out.append((s,prev+datetime.timedelta(minutes=2)))
      s=prev=t
  out.append((s,prev+datetime.timedelta(minutes=2)))
  return out
cands=[]
for mac,d in inv.items():
  alias=(d.get('alias') or d.get('display_name') or '')
  f=fp.get(mac,{})
  ports=f.get('ports_open',[])
  priv=f.get('private_mac')
  mobile=False; reason=[]
  if 'iphone' in alias.lower(): mobile=True; reason.append('alias')
  if 62078 in ports or 49152 in ports: mobile=True; reason.append('apple-lockdownd')
  if priv and ('probable móvil' in (f.get('guess_alias') or '') or 'Apple iPhone' in alias): mobile=True; reason.append('private-mac')
  if mobile:
    ss=sess(obs.get(mac,[]))
    cands.append((mac,alias,d.get('first_seen_ts'),d.get('last_seen_ts'),len(ss),sum((e-s).total_seconds() for s,e in ss)/3600,ports,priv,reason))
for row in cands:
  print(row)
PY


('16:a3:ee:79:d7:8f', 'Apple iPhone con MAC privada y puertos lockdownd [from AI]', '2026-05-28T15:04:22Z', '2026-06-05T15:16:35Z', 3, 1.9269444444444443, [], True, ['alias', 'private-mac'])
('52:b2:e7:72:71:b5', 'iphone nacho nc', '2026-05-06T02:24:00Z', '2026-05-06T02:24:00Z', 0, 0.0, [], None, ['alias'])
('92:1e:fe:36:21:42', 'Dispositivo con MAC privada / probable móvil [from AI]', '2026-06-05T15:02:28Z', '2026-06-05T15:16:35Z', 1, 0.26861111111111113, [], True, ['private-mac'])
('ae:54:7d:48:70:dc', 'Apple iPhone con MAC privada y puertos lockdownd [from AI]', '2026-05-23T03:50:16Z', '2026-05-25T20:24:21Z', 2, 17.34611111111111, [], True, ['alias', 'private-mac'])
('e6:5a:df:8b:89:96', 'iphone rodri', '2026-05-06T02:24:00Z', '2026-05-06T02:24:00Z', 0, 0.0, [], None, ['alias'])
('fe:8c:bc:ae:b6:e4', 'iPhone sebas', '2026-05-05T17:54:04Z', '2026-06-06T20:33:19Z', 2, 14.789444444444445, [49152, 62078], True, ['alias', 'apple-lockdownd'])


Updated #2 (pending → completed)


Sí. Metadata confirma eso.

Lo que pasa:
- el proyecto existe al menos desde
  `2026-05-05`
- se ve en `inventory.json`
  con `first_seen_ts` desde ese día
- pero el crudo `activity*.jsonl`
  rota y sólo guarda ~6 archivos
  de ~4.2 MB cada uno

Entonces:
- hay historia desde 05/05
- pero sesiones detalladas sólo quedan
  desde 24/05 aprox

Ejemplos:
- `iPhone sebas`:
  first seen `2026-05-05`
- `Macbook sebas`:
  first seen `2026-05-05`
- `iphone rodri`:
  first seen `2026-05-06`
- `iphone nacho nc`:
  first seen `2026-05-06`

Móviles detectados, ever-seen:
- 6 móviles

Lista:
1. `iPhone sebas`
2. `iphone rodri`
3. `iphone nacho nc`
4. iPhone privado
   `ae:54:7d:48:70:dc`
5. iPhone privado
   `16:a3:ee:79:d7:8f`
6. móvil privado
   `92:1e:fe:36:21:42`

Perfil de conexión:

- `iPhone sebas`
  - perfil: residente / largo
  - first seen: 05/05
  - en crudo retenido:
    06/06 dos sesiones
    ~10.1h + ~4.6h
  - evidencia fuerte:
    puertos Apple `62078/49152`

- `ae:54:7d:48:70:dc`
  - perfil: visita larga
  - 24/05 y 25/05
  - ~17.3h total
  - parece iPhone con MAC privada

- `16:a3:ee:79:d7:8f`
  - perfil: visitas cortas repetidas
  - 28/05, 01/06, 05/06
  - ~1.9h total
  - parece iPhone con MAC privada

- `92:1e:fe:36:21:42`
  - perfil: paso muy breve
  - 05/06
  - ~16 min
  - móvil privado no identificado

- `iphone rodri`
  - perfil: histórico puntual
  - sólo inventario
  - first seen + last seen: 06/05
  - sin detalle de sesiones retenido

- `iphone nacho nc`
  - perfil: histórico puntual
  - sólo inventario
  - first seen + last seen: 06/05
  - sin detalle de sesiones retenido

Lectura útil:
- móviles únicos ever-seen: 6
- con actividad detallada retenida: 4
- con patrón “estadía larga” claro:
  2 (`iPhone sebas`,
  `ae:54:...`)
- con patrón “visita corta”:
  2 (`16:a3:...`,
  `92:1e:...`)

Si querés, sigo con uno:
1. separar “posibles personas”
2. armar línea de tiempo día por día
3. decirte cómo subir retención a 4+
   semanas reales


[telegram] nombra
ae:54:7d:48:70:dc "pili cassani ?"
16:a3:ee:79:d7:8f "juan cassani"
92:1e:fe:36:21:42 "benja cassani o dahi, android?"

los datos rotados no se pueden recuperar? y por que rotamos? si es por espacio no se puede comprimir la informacion?


todo

Renombrar aliases y explicar retención wifi tracker

Actualizar aliases de móviles indicados por el usuario y revisar por qué rota el log, si se puede recuperar y comprimir.

actualizando aliases y revisando retención


Created #3: Renombrar aliases y explicar retención wifi tracker (pending)


#!/usr/bin/env python3
import concurrent.futures as cf
import ipaddress
import json
import os
import re
import socket
import ssl
import subprocess
import sys
import time
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List

LOG_DIR = Path(os.environ.get("WIFI_PRESENCE_LOG_DIR", "/home/sebas/runtime/agent-logs/wifi-presence"))
STATE_PATH = Path(os.environ.get("WIFI_PRESENCE_STATE", str(LOG_DIR / "state.json")))
LOG_PATH = Path(os.environ.get("WIFI_PRESENCE_LOG", str(LOG_DIR / "activity.jsonl")))
ALIASES_PATH = Path(os.environ.get("WIFI_PRESENCE_ALIASES", str(LOG_DIR / "aliases.json")))
FINGERPRINTS_PATH = Path(os.environ.get("WIFI_PRESENCE_FINGERPRINTS", str(LOG_DIR / "fingerprints.json")))
ALERT_PREFS_PATH = Path(os.environ.get("WIFI_PRESENCE_ALERT_PREFS", str(LOG_DIR / "alert-prefs.json")))
INVENTORY_PATH = Path(os.environ.get("WIFI_PRESENCE_INVENTORY", str(LOG_DIR / "inventory.json")))
SUMMARY_PATH = Path(os.environ.get("WIFI_PRESENCE_SUMMARY", "/var/www/wifi-tracker/summary.json"))
PING_WORKERS = int(os.environ.get("WIFI_PRESENCE_PING_WORKERS", "32"))
HOSTNAME_TIMEOUT = float(os.environ.get("WIFI_PRESENCE_HOSTNAME_TIMEOUT", "0.35"))
SUMMARY_TAIL_LINES = int(os.environ.get("WIFI_PRESENCE_SUMMARY_TAIL_LINES", "20000"))
ROTATE_MAX_BYTES = int(os.environ.get("WIFI_PRESENCE_ROTATE_MAX_BYTES", str(4 * 1024 * 1024)))
ROTATE_KEEP = int(os.environ.get("WIFI_PRESENCE_ROTATE_KEEP", "5"))
OFFLINE_GAP_MINUTES = int(os.environ.get("WIFI_PRESENCE_OFFLINE_GAP_MINUTES", "5"))

def sh(cmd: str) -> str:
    return subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.DEVNULL).strip()

def now() -> str:
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

def parse_ts(ts: str) -> datetime:
    return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)

def ensure_paths() -> None:
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    SUMMARY_PATH.parent.mkdir(parents=True, exist_ok=True)
    if not STATE_PATH.exists():
        STATE_PATH.write_text('{"devices": {}}\n')
    if not ALIASES_PATH.exists():
        ALIASES_PATH.write_text('{}\n')
    if not FINGERPRINTS_PATH.exists():
        FINGERPRINTS_PATH.write_text('{}\n')
    if not ALERT_PREFS_PATH.exists():
        ALERT_PREFS_PATH.write_text('{"new_device_alerts": true, "devices": {}}\n')
    if not INVENTORY_PATH.exists():
        INVENTORY_PATH.write_text('{"devices": {}}\n')

def rotate_logs() -> None:
    if not LOG_PATH.exists() or LOG_PATH.stat().st_size < ROTATE_MAX_BYTES:
        return
    ts = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
    rotated = LOG_DIR / f"activity-{ts}.jsonl"
    LOG_PATH.replace(rotated)
    old = sorted(LOG_DIR.glob("activity-*.jsonl"))
    for path in old[:-ROTATE_KEEP]:
        try:
            path.unlink()
        except FileNotFoundError:
            pass

def load_json(path: Path, default):
    try:
        return json.loads(path.read_text())
    except Exception:
        return default

def load_state() -> Dict:
    return load_json(STATE_PATH, {"devices": {}})

def load_aliases() -> Dict:
    data = load_json(ALIASES_PATH, {})
    return {k.lower(): v for k, v in data.items() if isinstance(v, str)}

def load_fingerprints() -> Dict:
    return load_json(FINGERPRINTS_PATH, {})

def load_alert_prefs() -> Dict:
    return load_json(ALERT_PREFS_PATH, {"new_device_alerts": True, "devices": {}})

def load_inventory() -> Dict:
    return load_json(INVENTORY_PATH, {"devices": {}})

def save_inventory(data: Dict):
    write_json_atomic(INVENTORY_PATH, data)

def save_state(state: Dict) -> None:
    tmp = STATE_PATH.with_suffix(".tmp")
    tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
    tmp.replace(STATE_PATH)

def write_json_atomic(path: Path, data) -> None:
    tmp = path.with_suffix(".tmp")
    tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
    tmp.replace(path)

def log_event(event: Dict) -> None:
    rotate_logs()
    event.setdefault("ts", now())
    with LOG_PATH.open("a") as f:
        f.write(json.dumps(event, sort_keys=True) + "\n")

def default_gateway() -> str:
    return sh("ip route | awk '/default/ {print $3; exit}'")

def default_iface() -> str:
    return sh("ip route | awk '/default/ {print $5; exit}'")

def iface_cidr(iface: str) -> str:
    return sh(f"ip -o -f inet addr show dev {iface} | awk '{{print $4; exit}}'")

def network_cidr(cidr: str) -> str:
    return str(ipaddress.ip_interface(cidr).network)

def local_ip(iface: str) -> str:
    out = sh(f"ip -o -4 addr show dev {iface}")
    return out.split()[3].split("/")[0]

def ping_host(ip: str) -> None:
    subprocess.run(["ping", "-c", "1", "-W", "1", ip], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

def sweep_network(cidr: str, self_ip: str) -> None:
    net = ipaddress.ip_network(cidr, strict=False)
    hosts: List[str] = [str(h) for h in net.hosts() if str(h) != self_ip]
    with cf.ThreadPoolExecutor(max_workers=PING_WORKERS) as ex:
        list(ex.map(ping_host, hosts))

def arp_entries(iface: str) -> List[Dict]:
    out = sh(f"ip neigh show dev {iface} || true")
    rows = []
    for line in out.splitlines():
        parts = line.split()
        if len(parts) < 4:
            continue
        row = {"ip": parts[0], "mac": None, "state": parts[-1], "iface": iface}
        if "lladdr" in parts:
            i = parts.index("lladdr")
            if i + 1 < len(parts):
                row["mac"] = parts[i + 1].lower()
        rows.append(row)
    return rows

def reverse_name(ip: str):
    try:
        socket.setdefaulttimeout(HOSTNAME_TIMEOUT)
        name, _, _ = socket.gethostbyaddr(ip)
        return name
    except Exception:
        return None

def probe_router(gateway: str) -> Dict:
    out = {"gateway": gateway}
    try:
        with urllib.request.urlopen(f"http://{gateway}/", timeout=4) as r:
            html = r.read(65536).decode("utf-8", "ignore")
        if "Huawei" in html:
            out["vendor"] = "Huawei"
        m = re.search(r"ProductName\s*=\s*'([^']+)'", html)
        if m:
            out["model"] = m.group(1).replace("\\x2d", "-")
    except Exception as e:
        out["probe_error"] = str(e)
    return out

def tcp_open(ip: str, port: int, timeout: float = 0.5) -> bool:
    s = socket.socket()
    s.settimeout(timeout)
    try:
        s.connect((ip, port))
        return True
    except Exception:
        return False
    finally:
        s.close()

def http_sniff(ip: str, port: int = 80, https: bool = False):
    try:
        ctx = ssl._create_unverified_context() if https else None
        with urllib.request.urlopen(("https" if https else "http") + f"://{ip}:{port}/", timeout=1.5, context=ctx) as r:
            data = r.read(1024).decode("utf-8", "ignore")
            return data
    except Exception:
        return None

def private_mac(mac: str) -> bool:
    try:
        return bool(int(mac.split(":")[0], 16) & 0b10)
    except Exception:
        return False

def discover_mdns() -> Dict:
    try:
        out = sh("timeout 8s avahi-browse -arpk 2>/dev/null || true")
    except Exception:
        return {}
    by_ip = defaultdict(list)
    for line in out.splitlines():
        if not line.startswith("="):
            continue
        parts = line.split(";")
        if len(parts) < 9:
            continue
        service_name = parts[3].replace("\\032", " ")
        service_type = parts[4]
        host = parts[6]
        addr = parts[7]
        port = parts[8]
        txt = parts[9:]
        if re.fullmatch(r"\d+\.\d+\.\d+\.\d+", addr):
            by_ip[addr].append({
                "service_name": service_name,
                "service_type": service_type,
                "host": host,
                "port": port,
                "txt": txt,
            })
    return dict(by_ip)

def fingerprint_device(dev: Dict, gw: str, discoveries: Dict) -> Dict:
    ip = dev.get("ip")
    mac = (dev.get("mac") or "").lower()
    prefix = dev.get("oui_prefix") or ""
    ev = {

[466 more lines in file. Use offset=261 to continue.]


**Considering file edits**

I need to edit the aliases.json file, and I'm thinking that maybe the summary updates could happen automatically in the next cycle. I wonder if running a service would be needed? It might not be necessary, though. I guess I'll go ahead and make the necessary edits first.

edit

"16:a3:ee:79:d7:8f": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",

"92:1e:fe:36:21:42": "Dispositivo con MAC privada / probable m\u00f3vil [from AI]",

"92:1e:fe:36:21:42": "benja cassani o dahi, android?",

"ae:54:7d:48:70:dc": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",


Successfully replaced 3 block(s) in /home/sebas/runtime/agent-logs/wifi-presence/aliases.json.

  1 {
  2   "12:6e:0c:42:eb:c9": "Macbook sebas",
- 3   "16:a3:ee:79:d7:8f": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
+ 3   "16:a3:ee:79:d7:8f": "juan cassani",
  4   "1c:3c:d4:06:a0:9b": "Router Casa",
  5   "2c:cf:67:94:23:8d": "ballbox-first",
  6   "38:2c:e5:4c:99:b7": "Enchufe smart - Luces balcon",
  7   "48:5c:2c:4c:36:c6": "Android TV - TV de Sala 68",
  8   "52:b2:e7:72:71:b5": "iphone nacho nc",
  9   "74:e6:b8:38:96:44": "LG Smart TV - Living",
-10   "92:1e:fe:36:21:42": "Dispositivo con MAC privada / probable m\u00f3vil [from AI]",
+10   "92:1e:fe:36:21:42": "benja cassani o dahi, android?",
 11   "98:77:d5:11:b2:e0": "Luz WiZ 4",
 12   "98:77:d5:11:c1:ca": "Luz WiZ 3",
-13   "ae:54:7d:48:70:dc": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
+13   "ae:54:7d:48:70:dc": "pili cassani ?",
 14   "b0:2a:43:83:47:fc": "Google Home Mini - Sala de estar",
 15   "c4:82:e1:b4:d7:ca": "Smart infrared remote",
 16   "cc:40:85:0a:cb:56": "Luz WiZ",
 17   "cc:40:85:0b:e1:5e": "Luz WiZ 2",
    ...

--- /home/sebas/runtime/agent-logs/wifi-presence/aliases.json
+++ /home/sebas/runtime/agent-logs/wifi-presence/aliases.json
@@ -1,17 +1,17 @@
 {
   "12:6e:0c:42:eb:c9": "Macbook sebas",
-  "16:a3:ee:79:d7:8f": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
+  "16:a3:ee:79:d7:8f": "juan cassani",
   "1c:3c:d4:06:a0:9b": "Router Casa",
   "2c:cf:67:94:23:8d": "ballbox-first",
   "38:2c:e5:4c:99:b7": "Enchufe smart - Luces balcon",
   "48:5c:2c:4c:36:c6": "Android TV - TV de Sala 68",
   "52:b2:e7:72:71:b5": "iphone nacho nc",
   "74:e6:b8:38:96:44": "LG Smart TV - Living",
-  "92:1e:fe:36:21:42": "Dispositivo con MAC privada / probable m\u00f3vil [from AI]",
+  "92:1e:fe:36:21:42": "benja cassani o dahi, android?",
   "98:77:d5:11:b2:e0": "Luz WiZ 4",
   "98:77:d5:11:c1:ca": "Luz WiZ 3",
-  "ae:54:7d:48:70:dc": "Apple iPhone con MAC privada y puertos lockdownd [from AI]",
+  "ae:54:7d:48:70:dc": "pili cassani ?",
   "b0:2a:43:83:47:fc": "Google Home Mini - Sala de estar",
   "c4:82:e1:b4:d7:ca": "Smart infrared remote",
   "cc:40:85:0a:cb:56": "Luz WiZ",
   "cc:40:85:0b:e1:5e": "Luz WiZ 2",


**Addressing log recovery issues**

I’m considering how to mention that recovery from rotated logs is impossible without backups. I should inspect the summary to see if there are older buckets available, maybe only for the last 14 days due to the tail. It might be helpful to check if inventory logs contain the first seen metadata. I also wonder why logs rotate at a specific size cap and if compressing them could work as a solution. Lastly, it’s important to note that deleted old files are unrecoverable unless there's a backup.


Updated #3 (pending → completed)


Sí. Ya los nombré:

- `ae:54:7d:48:70:dc`
  → `pili cassani ?`
- `16:a3:ee:79:d7:8f`
  → `juan cassani`
- `92:1e:fe:36:21:42`
  → `benja cassani o dahi, android?`

Sobre lo rotado:

- si esos archivos viejos ya fueron borrados,
  no desde acá, no se recuperan
- sólo sería posible si existen:
  backup, snapshot, copia manual,
  o recuperación forense del FS
  y no vale la pena en este caso

Por qué rotamos:
- el collector tiene esto:
  - `ROTATE_MAX_BYTES = 4 MB`
  - `ROTATE_KEEP = 5`
- o sea:
  `activity.jsonl` llega a 4 MB,
  se renombra,
  y sólo quedan 5 rotados + actual

Entonces el motivo sí:
- espacio / acotar crecimiento
- también velocidad:
  el summary lee una cola,
  no historial infinito

Se puede comprimir mejor:
- sí, totalmente
- hoy estamos guardando crudo JSONL,
  que es cómodo pero ineficiente

Mejoras razonables:
1. gzip de rotados
   - mismo dato
   - mucho menos espacio
   - probablemente 5x a 10x menos

2. subir `ROTATE_KEEP`
   - con gzip podrías guardar
     meses, no días

3. guardar resumen diario aparte
   - sesiones por dispositivo/día
   - eso ocupa poquísimo
   - y preserva historia útil
   - aunque borres el crudo

4. bajar verbosidad del crudo
   - hoy se loguea mucho snapshot
   - se podría guardar:
     sólo cambios + heartbeat más chico

Mi lectura:
- el sistema actual está bien para
  “qué pasó hace poco”
- está mal para histórico real
- conviene:
  crudo corto + comprimido
  y agregado diario permanente

Si querés, hago el cambio para que:
- no pierda historial
- conserve 2 a 6 meses
- y siga liviano

<!-- telegram_button label="Sí, arreglalo" prompt="Implementá la mejora de retención del wifi tracker para conservar más historial con compresión y resumen diario." -->


[telegram] Implementá la mejora de retención del wifi tracker para conservar más historial con compresión y resumen diario.


**Making a plan**

I need to implement something, and it looks like using a to-do list could help. I wonder if I should read relevant skill memory, but maybe that’s not necessary. It seems like inspecting the code more thoroughly and checking the documentation or service would be beneficial.  So, I’ll focus on figuring out the best steps to take for moving forward effectively!

todo

Mejorar retención wifi tracker

Implementar compresión de logs rotados y resumen diario persistente para conservar más historial del wifi tracker con bajo uso de espacio. Actualizar código y docs, validar funcionamiento.


Created #4: Mejorar retención wifi tracker (pending)

Mejorar retención wifi tracker

Mejorar retención wifi tracker


        "ip": ip,
        "mac": mac,
        "oui_prefix": prefix,
        "private_mac": private_mac(mac),
        "ports_open": [],
        "sources": [],
        "confidence": 0.0,
        "guess_alias": None,
        "evidence": {"mdns": discoveries.get(ip, [])},
    }
    if ip == gw:
        ev["guess_alias"] = None
        return ev
    ports_to_probe = [80, 3000, 49152, 5540, 62078, 6668, 7000, 8008, 8009, 8443]
    for port in ports_to_probe:
        if tcp_open(ip, port):
            ev["ports_open"].append(port)
    mdns = discoveries.get(ip, [])
    mdns_types = [x.get("service_type") for x in mdns]
    mdns_names = [x.get("service_name") for x in mdns]
    if any(t == '_matterc._udp' for t in mdns_types) and prefix == "CC:40:85":
        ev["sources"].append("mdns-matter")
        ev["evidence"]["mdns_types"] = mdns_types
        ev["guess_alias"] = "WiZ / Matter smart light con mDNS Matter [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.98)
    if any(t == '_googlecast._tcp' for t in mdns_types):
        ev["sources"].append("mdns-googlecast")
        ev["evidence"]["mdns_names"] = mdns_names
        if any('Google Home Mini' in n for n in mdns_names):
            ev["guess_alias"] = "Google Home Mini / Google Cast con mDNS [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.99)
        elif any('onn.' in n or 'TV' in n for n in mdns_names):
            ev["guess_alias"] = "Android TV / Google Cast con mDNS [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.97)
    if any(t == '_androidtvremote2._tcp' for t in mdns_types):
        ev["sources"].append("mdns-androidtv")
        ev["guess_alias"] = "Android TV con mDNS remote [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.98)
    if any(t in ('_ssh._tcp','_sftp-ssh._tcp','_companion-link._tcp') for t in mdns_types):
        ev["sources"].append("mdns-apple-mac")
        if any('MacBook' in n or 'Mac' in n for n in mdns_names):
            ev["guess_alias"] = "Apple MacBook con SSH / companion-link [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.96)
    if prefix == "CC:40:85":
        ev["sources"].append("oui-wiz")
        ev["confidence"] = max(ev["confidence"], 0.92)
        ev["guess_alias"] = "WiZ / Matter smart light con vendor WiZ [from AI]"
        ev["evidence"]["vendor"] = "WiZ"
        if 5540 in ev["ports_open"]:
            ev["sources"].append("matter")
            ev["evidence"]["matter_port"] = 5540
            ev["guess_alias"] = "WiZ / Matter smart light con vendor WiZ y Matter [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.97)
    if 6668 in ev["ports_open"]:
        ev["sources"].append("tuya-6668")
        if prefix == "C4:82:E1":
            ev["guess_alias"] = "Tuya smart controller / smart infrared con puerto 6668 [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.93)
        else:
            ev["guess_alias"] = "Tuya smart device con puerto 6668 [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.88)
    if 8008 in ev["ports_open"] and 8009 in ev["ports_open"]:
        data = http_sniff(ip, 8008) or ""
        ev["sources"].append("google-cast")
        ev["evidence"]["http_8008"] = data[:180]
        if "Google Home Mini" in data or "Google-Home-Mini" in data:
            ev["guess_alias"] = "Google Home Mini / Google Cast con eureka_info [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.98)
        elif "build_version" in data or "cast_build_revision" in data or "name" in data:
            ev["guess_alias"] = "Android TV / Google Cast con eureka_info [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.95)
    if prefix == "74:E6:B8" and 3000 in ev["ports_open"]:
        ev["sources"].append("lg-tv")
        ev["guess_alias"] = "LG Smart TV con puertos webOS / AirPlay [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.98)
    if 62078 in ev["ports_open"] and 49152 in ev["ports_open"]:
        ev["sources"].append("apple-lockdownd")
        if ev["private_mac"]:
            ev["guess_alias"] = "Apple iPhone con MAC privada y puertos lockdownd [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.95)
        else:
            ev["guess_alias"] = "Apple iPhone / iOS con puertos lockdownd [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.9)
    if 7000 in ev["ports_open"] and not ev["guess_alias"]:
        ev["sources"].append("airplay")
        ev["guess_alias"] = "Apple / AirPlay device con puerto 7000 [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.75)
    if 80 in ev["ports_open"] and not ev["guess_alias"]:
        data = http_sniff(ip, 80) or ""
        if data:
            ev["sources"].append("http-title")
            ev["evidence"]["http_80"] = data[:180]
    if not ev["guess_alias"] and ev["private_mac"]:
        ev["sources"].append("private-mac-only")
        ev["guess_alias"] = "Dispositivo con MAC privada / probable móvil [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.45)
    return ev

def maybe_autoname(devices: List[Dict], aliases: Dict, gw: str, discoveries: Dict) -> Dict:
    changed = False
    fingerprints = load_fingerprints()
    for dev in devices:
        mac = (dev.get("mac") or "").lower()
        if not mac:
            continue
        fp = fingerprint_device(dev, gw, discoveries)
        existing_alias = aliases.get(mac)
        source = "manual" if existing_alias and "[from AI]" not in existing_alias else "ai"
        fingerprints[mac] = {
            "last_probe_ts": now(),
            "guess_alias": fp.get("guess_alias"),
            "confidence": fp.get("confidence"),
            "sources": fp.get("sources"),
            "evidence": fp.get("evidence"),
            "ports_open": fp.get("ports_open"),
            "ip": dev.get("ip"),
            "oui_prefix": dev.get("oui_prefix"),
            "private_mac": fp.get("private_mac"),
            "alias_source": source,
        }
        if mac in aliases:
            continue
        guessed = fp.get("guess_alias")
        if guessed:
            aliases[mac] = guessed
            changed = True
    write_json_atomic(FINGERPRINTS_PATH, fingerprints)
    if changed:
        write_json_atomic(ALIASES_PATH, aliases)
    return aliases

def normalize_device(row: Dict, gateway: str, aliases: Dict) -> Dict:
    ip = row["ip"]
    mac = row.get("mac")
    hostname = reverse_name(ip)
    roles = ["gateway"] if ip == gateway else []
    state = row.get("state")
    reachable = state in {"REACHABLE", "STALE", "DELAY", "PROBE", "PERMANENT"}
    if state == "FAILED":
        reachable = False
    return {
        "ip": ip,
        "mac": mac,
        "alias": aliases.get(mac),
        "display_name": aliases.get(mac) or hostname or mac,
        "hostname": hostname,
        "iface": row.get("iface"),
        "neighbor_state": state,
        "reachable": reachable,
        "oui_prefix": mac.upper()[0:8] if mac else None,
        "roles": roles,
    }

def build_sessions(events: List[Dict], mac: str) -> List[Dict]:
    mac = mac.lower()
    snapshots = []
    for ev in events:
        if ev.get("event") != "device_snapshot":
            continue
        dev = ev.get("device") or {}
        if (dev.get("mac") or "").lower() != mac:
            continue
        ts = ev.get("ts")
        if ts:
            snapshots.append(parse_ts(ts))
    snapshots.sort()
    if not snapshots:
        return []
    sessions = []
    start = prev = snapshots[0]
    gap = timedelta(minutes=OFFLINE_GAP_MINUTES)
    for dt in snapshots[1:]:
        if dt - prev > gap:
            sessions.append({"start": start, "end": prev})
            start = dt
        prev = dt
    sessions.append({"start": start, "end": prev})
    return sessions

def fill_bucket_series(start: datetime, count: int, step: timedelta, key_fmt: str, values: Dict[str, float]) -> List[Dict]:
    out = []
    for i in range(count):
        dt = start + step * i
        key = dt.strftime(key_fmt)
        out.append({"bucket": key, "count": round(values.get(key, 0), 2)})
    return out

def summarize_presence_timeline(events: List[Dict], mac: str, now_dt: datetime, hours: int = 24, step_minutes: int = 2, offline_misses: int = 3) -> Dict:
    start = (now_dt - timedelta(hours=hours)).replace(second=0, microsecond=0)
    slots = []
    observed = set()
    for ev in events:
        if ev.get("event") != "device_snapshot":
            continue
        dev = ev.get("device") or {}
        if (dev.get("mac") or "").lower() != mac.lower():
            continue
        ts = ev.get("ts")
        if not ts:
            continue
        dt = parse_ts(ts)
        if dt < start or dt > now_dt + timedelta(minutes=step_minutes):
            continue
        minute = (dt.minute // step_minutes) * step_minutes
        bucket = dt.replace(minute=minute, second=0, microsecond=0)
        observed.add(bucket)
    cursor = start.replace(minute=(start.minute // step_minutes) * step_minutes, second=0, microsecond=0)
    state = False
    miss_count = offline_misses
    while cursor <= now_dt:
        seen = cursor in observed
        if seen:
            state = True
            miss_count = 0
        else:
            if state:
                miss_count += 1
                if miss_count >= offline_misses:
                    state = False
        slots.append({"ts": cursor.strftime("%Y-%m-%dT%H:%M:%SZ"), "observed": seen, "online": state})
        cursor += timedelta(minutes=step_minutes)
    segments = []
    seg_start = None
    for slot in slots:
        if slot["online"] and seg_start is None:
            seg_start = slot["ts"]
        if not slot["online"] and seg_start is not None:
            segments.append({"start": seg_start, "end": slot["ts"]})
            seg_start = None
    if seg_start is not None and slots:
        segments.append({"start": seg_start, "end": slots[-1]["ts"]})
    return {"start": start.strftime("%Y-%m-%dT%H:%M:%SZ"), "end": now_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), "step_minutes": step_minutes, "offline_misses": offline_misses, "segments": segments, "slots": slots}

def summarize_activity(events: List[Dict], mac: str, now_dt: datetime) -> Dict:
    hour_values = defaultdict(float)
    day_values = defaultdict(float)
    week_values = defaultdict(float)
    hour_start = (now_dt - timedelta(hours=23)).replace(minute=0, second=0, microsecond=0)
    day_start = (now_dt - timedelta(days=13)).replace(hour=0, minute=0, second=0, microsecond=0)
    week_anchor = (now_dt - timedelta(days=now_dt.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
    week_start = week_anchor - timedelta(weeks=11)
    sessions = build_sessions(events, mac)

    for session in sessions:
        start = session["start"]
        end = session["end"] + timedelta(minutes=2)
        cursor = max(start, hour_start)
        while cursor < end:
            nxt = min((cursor.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)), end)
            key = cursor.replace(minute=0, second=0, microsecond=0).strftime("%Y-%m-%dT%H:00Z")
            hour_values[key] += max((nxt - cursor).total_seconds() / 3600, 0)
            cursor = nxt
        cursor = max(start, day_start)
        while cursor < end:
            nxt = min((cursor.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)), end)
            key = cursor.strftime("%Y-%m-%d")
            day_values[key] += max((nxt - cursor).total_seconds() / 3600, 0)
            cursor = nxt
        cursor = max(start, week_start)
        while cursor < end:
            wk = (cursor - timedelta(days=cursor.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
            nxt = min(wk + timedelta(weeks=1), end)
            key = wk.strftime("%Y-%m-%d")
            week_values[key] += max((nxt - cursor).total_seconds() / 3600, 0)
            cursor = nxt

    return {
        "hour": fill_bucket_series(hour_start, 24, timedelta(hours=1), "%Y-%m-%dT%H:00Z", hour_values),
        "day": fill_bucket_series(day_start, 14, timedelta(days=1), "%Y-%m-%d", day_values),
        "week": fill_bucket_series(week_start, 12, timedelta(weeks=1), "%Y-%m-%d", week_values),
        "sessions": len(sessions),
    }

def hydrate_inventory_from_events(inventory: Dict, aliases: Dict):
    inv_devices = inventory.setdefault("devices", {})
    try:
        lines = LOG_PATH.read_text().splitlines()[-SUMMARY_TAIL_LINES:]
    except Exception:
        return inventory
    for line in lines:
        try:
            ev = json.loads(line)
        except Exception:
            continue
        dev = ev.get("device") or {}
        mac = (dev.get("mac") or "").lower()
        if not mac:
            continue
        prev_inv = inv_devices.get(mac, {})
        merged = dict(prev_inv)
        merged.update({k: v for k, v in dev.items() if v is not None})
        merged["alias"] = aliases.get(mac, merged.get("alias"))
        merged["display_name"] = merged.get("alias") or merged.get("hostname") or merged.get("mac") or merged.get("ip")
        merged["first_seen_ts"] = prev_inv.get("first_seen_ts") or ev.get("ts") or dev.get("first_seen_ts")
        merged["last_seen_ts"] = ev.get("ts") or dev.get("last_seen_ts") or prev_inv.get("last_seen_ts")
        inv_devices[mac] = merged
    return inventory

def build_summary(devices: List[Dict], state: Dict, inventory: Dict, iface: str, gw: str, cidr: str, router: Dict, aliases: Dict, alert_prefs: Dict) -> Dict:
    now_ts = now()
    now_dt = parse_ts(now_ts)
    active_total = len([d for d in devices if d.get("online")])
    named = sum(1 for d in devices if d.get("hostname"))
    gateway_mac = next((d.get("mac") for d in devices if "gateway" in d.get("roles", [])), None)
    vendors = {}
    timeline = []
    alert_events = []
    try:
        lines = LOG_PATH.read_text().splitlines()[-SUMMARY_TAIL_LINES:]
    except Exception:
        lines = []
    events = []
    for line in lines:
        try:
            events.append(json.loads(line))
        except Exception:
            continue
    for ev in events:
        et = ev.get("event")
        ts = ev.get("ts")
        dev = ev.get("device") or {}
        mac = (dev.get("mac") or "").lower() or None
        if et in {"device_online", "device_offline", "new_device_detected"} and ts:
            inv = (inventory.get("devices") or {}).get(mac or "", {})
            row = {
                "ts": ts,
                "event": et,
                "ip": dev.get("ip") or inv.get("ip"),
                "mac": mac,
                "alias": aliases.get(mac) if mac else inv.get("alias"),
                "display_name": aliases.get(mac) if mac else None,
            }
            row["display_name"] = row["alias"] or inv.get("display_name") or dev.get("display_name") or dev.get("hostname") or mac or row.get("ip")
            timeline.append(row)
            enabled = alert_prefs.get("devices", {}).get(mac, {}).get("alerts_enabled", True) if mac else True
            if et == 'new_device_detected':
                if alert_prefs.get("new_device_alerts", True):
                    alert_events.append(row)
            elif et in {'device_online', 'device_offline'} and enabled:
                alert_events.append(row)
    for d in devices:
        prefix = d.get("oui_prefix") or "unknown"
        vendors[prefix] = vendors.get(prefix, 0) + 1
        d["activity"] = summarize_activity(events, d["mac"], now_dt)
        d["presence_timeline"] = summarize_presence_timeline(events, d["mac"], now_dt)
    devices.sort(key=lambda d: (not d.get("online", False), tuple(int(x) for x in d.get("ip", "255.255.255.255").split(".")) if "." in d.get("ip", "") else (255,255,255,255)))
    summary = {
        "updated_at": now_ts,
        "iface": iface,
        "gateway": gw,
        "cidr": cidr,
        "router": router,
        "totals": {
            "devices_now": active_total,
            "named_devices": named,
            "known_devices": len((inventory.get("devices") or {})),
            "aliased_devices": len([k for k, v in aliases.items() if v]),
        },
        "aliases": aliases,
        "devices": devices,
        "vendors": [{"name": k, "count": v} for k, v in sorted(vendors.items(), key=lambda x: (-x[1], x[0]))],
        "recent_events": timeline[-100:],
        "recent_alerts": alert_events[-100:],
        "highlights": {"gateway_mac": gateway_mac},
    }
    write_json_atomic(SUMMARY_PATH, summary)
    return summary

def main() -> int:
    ensure_paths()
    started = time.time()
    iface = default_iface()
    gw = default_gateway()
    iface_addr = iface_cidr(iface)
    cidr = network_cidr(iface_addr)
    self_ip = local_ip(iface)
    aliases = load_aliases()

    log_event({"event": "cycle_start", "iface": iface, "gateway": gw, "cidr": cidr, "iface_addr": iface_addr, "self_ip": self_ip, "ping_workers": PING_WORKERS})
    router = probe_router(gw)
    alert_prefs = load_alert_prefs()
    discoveries = discover_mdns()
    log_event({"event": "router_probe", "router": router})

    sweep_network(cidr, self_ip)
    rows = arp_entries(iface)
    raw_devices = [normalize_device(r, gw, aliases) for r in rows if r.get("mac") and "." in r.get("ip", "")]
    aliases = maybe_autoname(raw_devices, aliases, gw, discoveries)
    devices = [normalize_device(r, gw, aliases) for r in rows if r.get("mac") and "." in r.get("ip", "")]
    devices.sort(key=lambda d: tuple(int(x) for x in d["ip"].split(".")))

    state = load_state()
    inventory = hydrate_inventory_from_events(load_inventory(), aliases)
    prev = state.get("devices", {})
    current = {}

    for dev in devices:
        mac = dev["mac"]
        current[mac] = dev
        dev_state = prev.get(mac, {})
        event_base = {"iface": iface, "gateway": gw, "device": dev}
        log_event({"event": "device_snapshot", **event_base})
        if mac not in prev and alert_prefs.get("new_device_alerts", True):
            log_event({"event": "new_device_detected", **event_base})
        if not dev_state.get("online") and alert_prefs.get("devices", {}).get(mac, {}).get("alerts_enabled", True):
            log_event({"event": "device_online", **event_base})

    for mac, old in prev.items():
        if mac not in current and old.get("online"):
            if alert_prefs.get("devices", {}).get(mac, {}).get("alerts_enabled", True):
                log_event({"event": "device_offline", "iface": iface, "gateway": gw, "device": old})

    new_state = {"devices": {}}
    seen_ts = now()
    all_devices = []
    for mac, dev in current.items():
        dev["online"] = True
        dev["last_seen_ts"] = seen_ts
        dev["first_seen_ts"] = prev.get(mac, {}).get("first_seen_ts") or seen_ts
        new_state["devices"][mac] = dev
        all_devices.append(dev)
    for mac, old in prev.items():
        if mac in current:
            continue
        offline = dict(old)
        offline["online"] = False
        offline["alias"] = aliases.get(mac, offline.get("alias"))
        offline["display_name"] = offline["alias"] or offline.get("hostname") or offline.get("mac")
        new_state["devices"][mac] = offline
        all_devices.append(offline)
    inv_devices = inventory.setdefault("devices", {})
    for dev in all_devices:
        mac = dev.get("mac")
        if not mac:
            continue
        prev_inv = inv_devices.get(mac, {})
        merged = dict(prev_inv)
        merged.update({k: v for k, v in dev.items() if v is not None})
        merged["alias"] = aliases.get(mac, merged.get("alias"))
        merged["display_name"] = merged.get("alias") or merged.get("hostname") or merged.get("mac") or merged.get("ip")
        merged["first_seen_ts"] = prev_inv.get("first_seen_ts") or dev.get("first_seen_ts") or now()
        if dev.get("last_seen_ts"):
            merged["last_seen_ts"] = dev.get("last_seen_ts")
        inv_devices[mac] = merged
    save_state(new_state)
    save_inventory(inventory)
    inventory_devices = list(inv_devices.values())
    inventory_devices.sort(key=lambda d: (not d.get("online", False), tuple(int(x) for x in d.get("ip", "255.255.255.255").split(".")) if "." in d.get("ip", "") else (255,255,255,255)))
    build_summary(inventory_devices, new_state, inventory, iface, gw, cidr, router, aliases, alert_prefs)

    log_event({"event": "cycle_end", "iface": iface, "gateway": gw, "seen_devices": len(current), "duration_sec": round(time.time() - started, 2)})
    return 0

if __name__ == "__main__":
    sys.exit(main())


[Unit]
Description=WiFi tracker alias API
After=network.target

[Service]
ExecStart=/home/sebas/pi-config/bin/wifi-tracker-api.py
Restart=always
RestartSec=2

[Install]
WantedBy=default.target


# WiFi Tracker

## Purpose

Track LAN/Wi‑Fi presence visible from `ballbox-first` and expose a simple dashboard at `/wifi-tracker/`.

This is presence estimation from the Pi.
It is not exact AP association telemetry.

## Pieces

- collector: `/home/sebas/pi-config/bin/wifi-presence-log`
- raw events: `/home/sebas/runtime/agent-logs/wifi-presence/activity.jsonl`
- rotated raw events: `/home/sebas/runtime/agent-logs/wifi-presence/activity-*.jsonl`
- state: `/home/sebas/runtime/agent-logs/wifi-presence/state.json`
- aliases: `/home/sebas/runtime/agent-logs/wifi-presence/aliases.json`
- fingerprints/evidence: `/home/sebas/runtime/agent-logs/wifi-presence/fingerprints.json`
- alert prefs: `/home/sebas/runtime/agent-logs/wifi-presence/alert-prefs.json`
- inventory ever-seen: `/home/sebas/runtime/agent-logs/wifi-presence/inventory.json`
- summary: `/var/www/wifi-tracker/summary.json`
- UI: `/var/www/wifi-tracker/index.html`
- API: `/home/sebas/pi-config/bin/wifi-tracker-api.py`

## Services

- `wifi-presence-log.timer`
- `wifi-presence-log.service`
- `wifi-tracker-api.service`

## URLs

- `/wifi-tracker/`
- `/wifi-tracker/api/summary`
- `/wifi-tracker/api/aliases`
- `/wifi-tracker/api/alert-prefs`
- `/wifi-tracker/api/health`

## Useful commands

```bash
systemctl --user status wifi-presence-log.timer
systemctl --user status wifi-tracker-api.service
systemctl --user start wifi-presence-log.service
journalctl --user -u wifi-tracker-api.service -n 50 --no-pager
curl -s http://127.0.0.1/wifi-tracker/api/health | jq
curl -s http://127.0.0.1/wifi-tracker/api/summary | jq '.totals'
tail -n 20 /home/sebas/runtime/agent-logs/wifi-presence/activity.jsonl
```

## Data model

Raw event types:

- `cycle_start`
- `router_probe`
- `device_snapshot`
- `device_online`
- `device_offline`
- `cycle_end`

Dashboard activity summary is still derived from approximate sessions.
A new session starts when snapshot gaps exceed the configured offline gap.
Buckets still exist in summary data, but the main UI now emphasizes a device presence timeline instead of percentage-style bars.

The main graph shows horizontal blue segments for periods considered present.
Those segments are derived from raw `device_snapshot` observations, with online turning on immediately and offline only turning off after 3 consecutive missing scan slots (~6 minutes at the current 2-minute scan interval).

The collector now also maintains an `inventory.json` ever-seen device list so historical devices remain visible in the UI even if they are not present in the current state snapshot.

The collector now also runs lightweight proactive fingerprinting for visible devices.
If a device has no manual alias, the service may write a descriptive alias directly into `aliases.json` with a ` [from AI]` suffix.
Manual aliases always win and are not overwritten.

Per-MAC evidence is stored in `fingerprints.json`, including guessed alias, confidence, sources, ports, and small evidence snippets.

## Retention

The raw JSONL log rotates automatically when it grows past the configured size threshold.
Old rotated files are kept in a small rolling set.

Alert toggles are stored in `alert-prefs.json`.
The UI can enable or disable alerts per device without removing the device from the inventory.