view service/wifi/wifi.py @ 1754:92999dfbf321 default tip

add shelly support
author drewp@bigasterisk.com
date Tue, 04 Jun 2024 13:03:43 -0700
parents 81aa0873b48d
children
line wrap: on
line source

"""
scrape the tomato router status pages to see who's connected to the
wifi access points. Includes leases that aren't currently connected.

Returns:
 json listing (for magma page)
 rdf graph (for reasoning)
 activity stream, when we start saving history

Todo: this should be the one polling and writing to mongo, not entrancemusic

"""
import datetime
import logging
import time
import traceback
from dataclasses import dataclass
from typing import List

import background_loop
from dateutil import tz
from patchablegraph import PatchableGraph
from patchablegraph.handler import GraphEvents, StaticGraph
from prometheus_client import Counter, Gauge, Summary
from pymongo import DESCENDING
from pymongo import MongoClient as Connection
from pymongo.collection import Collection
from rdflib import RDF, ConjunctiveGraph, Literal, Namespace
from starlette.applications import Starlette
from starlette.routing import Route
from starlette_exporter import PrometheusMiddleware, handle_metrics

from scrape import SeenNode, Wifi

logging.basicConfig()
log = logging.getLogger()

AST = Namespace("http://bigasterisk.com/")
DEV = Namespace("http://projects.bigasterisk.com/device/")
ROOM = Namespace("http://projects.bigasterisk.com/room/")

# class Index(PrettyErrorHandler, cyclone.web.RequestHandler):

#     def get(self):
#         age = time.time() - self.settings.poller.lastPollTime
#         if age > 10:
#             raise ValueError("poll data is stale. age=%s" % age)

#         self.set_header("Content-Type", "text/html")
#         self.write(open("index.html").read())


def whenConnected(mongo, macThatIsNowConnected):
    lastArrive = None
    for ev in mongo.find({'address': macThatIsNowConnected.upper()}, sort=[('created', -1)], max_time_ms=5000):
        if ev['action'] == 'arrive':
            lastArrive = ev
        if ev['action'] == 'leave':
            break
    if lastArrive is None:
        raise ValueError("no past arrivals")

    return lastArrive['created']


# class Table(PrettyErrorHandler, cyclone.web.RequestHandler):

#     def get(self):

#         def rowDict(row):
#             row['cls'] = "signal" if row.get('connected') else "nosignal"
#             if 'name' not in row:
#                 row['name'] = row.get('clientHostname', '-')
#             if 'signal' not in row:
#                 row['signal'] = 'yes' if row.get('connected') else 'no'

#             try:
#                 conn = whenConnected(self.settings.mongo, row.get('mac', '??'))
#                 row['connectedAgo'] = connectedAgoString(conn)
#             except ValueError:
#                 row['connectedAgo'] = 'yes' if row.get('connected') else ''
#             row['router'] = row.get('ssid', '')
#             return row

#         self.set_header("Content-Type", "application/xhtml+xml")
#         self.write(
#             pystache.render(
#                 open("table.mustache").read(),
#                 dict(rows=sorted(map(rowDict, self.settings.poller.lastAddrs),
#                                  key=lambda a: (not a.get('connected'), a.get('name'))))))

# class Json(PrettyErrorHandler, cyclone.web.RequestHandler):

#     def get(self):
#         self.set_header("Content-Type", "application/json")
#         age = time.time() - self.settings.poller.lastPollTime
#         if age > 10:
#             raise ValueError("poll data is stale. age=%s" % age)
#         self.write(json.dumps({"wifi": self.settings.poller.lastAddrs, "dataAge": age}))

POLL = Summary('poll', 'Time in HTTP poll requests')
POLL_SUCCESSES = Counter('poll_successes', 'poll success count')
POLL_ERRORS = Counter('poll_errors', 'poll error count')
CURRENTLY_ON_WIFI = Gauge('currently_on_wifi', 'current nodes known to wifi router (some may be wired)')
MAC_ON_WIFI = Gauge('connected', 'mac addr is currently connected', ['mac'])


@dataclass
class Poller:
    wifi: Wifi
    mongo: Collection
    masterGraph: PatchableGraph

    def __post_init__(self):
        self.lastAddrs = []  # List[SeenNode]
        self.lastWithSignal = []
        self.lastPollTime = 0

    async def poll(self, first_run):
        with POLL.time():
            try:
                newAddrs = await self.wifi.getPresentMacAddrs()
                self.onNodes(newAddrs)
                POLL_SUCCESSES.inc()
            except Exception as e:
                log.error("poll error: %r\n%s", e, traceback.format_exc())
                POLL_ERRORS.inc()

    def onNodes(self, newAddrs: List[SeenNode]):
        now = int(time.time())
        newWithSignal = [a for a in newAddrs if a.connected]
        CURRENTLY_ON_WIFI.set(len(newWithSignal))

        actions = self.computeActions(newWithSignal)
        for action in actions:
            log.info("action: %s", action)
            action['created'] = datetime.datetime.now(tz.gettz('UTC'))
            self.mongo.insert_one(action)
            MAC_ON_WIFI.labels(mac=action['address'].lower()).set(1 if action['action'] == 'arrive' else 0)
        if now // 3600 > self.lastPollTime // 3600:
            log.info('hourly writes')
            for addr in newWithSignal:
                MAC_ON_WIFI.labels(mac=addr.mac.lower()).set(1)

        self.lastWithSignal = newWithSignal
        self.lastAddrs = newAddrs
        self.lastPollTime = now

        self.updateGraph(self.masterGraph)

    def computeActions(self, newWithSignal):
        actions = []

        def makeAction(addr: SeenNode, act: str):
            d = dict(
                sensor="wifi",
                address=addr.mac.upper(),  # mongo data is legacy uppercase
                action=act)
            if act == 'arrive':
                # this won't cover the possible case that you get on
                # wifi but don't have an ip yet. We'll record an
                # action with no ip and then never record your ip.
                d['ip'] = addr.ip
            return d

        for addr in newWithSignal:
            if addr.mac not in [r.mac for r in self.lastWithSignal]:
                actions.append(makeAction(addr, 'arrive'))

        for addr in self.lastWithSignal:
            if addr.mac not in [r.mac for r in newWithSignal]:
                actions.append(makeAction(addr, 'leave'))

        return actions

    def deltaSinceLastArrive(self, name):
        results = list(self.mongo.find({'name': name}).sort('created', DESCENDING).limit(1))
        if not results:
            return datetime.timedelta.max
        now = datetime.datetime.now(tz.gettz('UTC'))
        last = results[0]['created'].replace(tzinfo=tz.gettz('UTC'))
        return now - last

    def updateGraph(self, masterGraph):
        g = ConjunctiveGraph()
        ctx = DEV['wifi']

        # someday i may also record specific AP and their strength,
        # for positioning. But many users just want to know that the
        # device is connected to some bigasterisk AP.
        age = time.time() - self.lastPollTime
        if age > 10:
            raise ValueError("poll data is stale. age=%s" % age)

        for dev in self.lastAddrs:
            if not dev.connected:
                continue
            g.add((dev.uri, RDF.type, ROOM['NetworkedDevice'], ctx))
            g.add((dev.uri, ROOM['macAddress'], Literal(dev.mac), ctx))
            g.add((dev.uri, ROOM['ipAddress'], Literal(dev.ip), ctx))

            for s, p, o in dev.stmts:
                g.add((s, p, o, ctx))

            try:
                conn = whenConnected(self.mongo, dev.mac)
            except ValueError:
                traceback.print_exc()
                pass
            else:
                g.add((dev.uri, ROOM['connected'], Literal(conn), ctx))
        masterGraph.setToGraph(g)


# class RemoteSuspend(PrettyErrorHandler, cyclone.web.RequestHandler):

#     def post(self):
#         # windows is running shutter (https://www.den4b.com/products/shutter)
#         fetch('http://DESKTOP-GOU4AC4:8011/action', postdata={'id': 'Sleep'})


def main():
    log.setLevel(logging.INFO)
    masterGraph = PatchableGraph()
    mongo = Connection('mongodb.default.svc.cluster.local', 27017, tz_aware=True)['visitor']['visitor']

    config = ConjunctiveGraph()
    config.parse(open('private_config.n3'), format='n3')

    wifi = Wifi(config)
    poller = Poller(wifi, mongo, masterGraph)
    loop = background_loop.loop_forever(poller.poll, 10)

    app = Starlette(routes=[
        Route('/graph/wifi', StaticGraph(masterGraph)),
        Route('/graph/wifi/events', GraphEvents(masterGraph)),
    ],)

    app.add_middleware(PrometheusMiddleware, app_name='environment')
    app.add_route("/metrics", handle_metrics)
    return app


app = main()