Sunday, June 26, 2016

Wifi device detection with Domoticz and OpenWRT

I've recently started using Domoticz to control my various home automation systems. One of the bits of information about the house that I wanted to gather was the presence of mobile phones on the wireless network. Both my partner and I always have our phones with us, so if they are present on the network then it's a good bet that we're at home. This allows constructing domoticz rules based on who's in the house.

There are many examples of scripts on the internet that show how to do this in Domoticz using either ping, arp-scan or a combination of the two. However the success of them seems to be varied at best. The main issue appears to be that mobile devices attempt to conserve power by not being 'active' (whatever that means) on the wireless network. I tried to make my scripts more accurate by adding more and more layers of complexity and complex rules. Eventually I decided the active scanning approach was doomed.

Luckily I run OpenWRT on both of my wireless access points. Logging into the OpenWRT boxes and running

/usr/sbin/iw dev wlan0 station dump
will give a dump of information about the various devices. The next question was how to get this information from the OpenWRT system to the system running Domoticz. I decided to use xinitd on OpenWRT, which allows binding the output of an arbitrary command to a TCP port. It's not at all secure, but I'm happy to take the risk as this isn't going to be facing the public internet. To configure OpenWRT do the following:
  1. Install xinitd:
    opkg update && opkg install xinitd
  2. Edit
    /etc/services
    to contain the following line:
    wlanap 8216/tcp
  3. Create
    /etc/xinetd.d/wlanap
    containing:

    service wlanap
    {
    socket_type     = stream
    protocol        = tcp
    wait            = no
    user            = root
    group           = root
    server          = /usr/sbin/dump_ap
    disable         = no
    only_from       = 192.168.XXX.XXX
    }


    Replacing
    XXX.XXX
    with the IP of the machine running Domoticz.
  4. Create
    /usr/sbin/dump_ap
    containing:

    #!/bin/sh
    /usr/sbin/iw dev wlan0 station dump
    Note: If your router has multiple wireless interfaces (e.g. 2.4GHz and 5GHz) then you should repeat the last line in the script for the other interfaces, such as wlan1.
  5. Mark it as executable:
    chmod a+x /usr/sbin/dump_ap
  6. Restart xinetd on OpenWRT
  7. Repeat for each OpenWRT box

It should be noted that the help for the iw tool does give the following warning:
Do NOT screenscrape this tool, we don't consider its output stable
. So every time I update OpenWRT I am going to be at risk of things breaking, but that will just add to the excitement of applying updates!

So the last piece in the puzzle was to write a script to periodically query my wireless access points and toggle virtual switches in Domoticz as appropriate. I created a couple of virtual switches in Domoticz for each of the devices of interest and then created a cronjob to run the following Python script periodically:
#!/usr/bin/env python
# Detects ARP addresses

import argparse
import json
import logging
import os
import re
import socket
import subprocess
import sys
import urllib2

log = logging.getLogger()

DEFAULT_ARP_SCAN = "/usr/bin/arp-scan"
DEFAULT_CONFIG_LOCATION = os.path.join(os.environ['HOME'], ".detect-arp", "config.json")

DOMOTICZ_URL = "http://localhost"

ARP_TO_IDX = {
    "12:34:56:78:9A:BC": 31, # Dave's Phone
    "DE:AD:BE:EF:00:12": 37, # Bob's Phone
}

WLANAP_HOSTS = ["AccessPoint1", "AccessPoint2"]
WLANAP_PORT = 8216
ARP_LINE_RE = re.compile(r"^Station ([0-9a-f:]{17}) \(on .+\)$")

def getArps():
    foundArp = set()
    for host in WLANAP_HOSTS:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((host, WLANAP_PORT))
        data = ""
        while True:
            dataFrag = s.recv(4096)
            if len(dataFrag) == 0:
                break
            data += dataFrag
        for line in data.split("\n"):
            m = ARP_LINE_RE.search(line)
            if m is None:
                continue
            arpAddr = m.group(1)
            log.debug("Found ARP %s", arpAddr)
            foundArp.add(arpAddr)
    return foundArp

def scan(args):
    log.debug("Starting scan")
    foundArp = getArps()
    
    # Update the switch state as needed
    log.debug("Toggling switches")
    for arpAddr, idx in ARP_TO_IDX.iteritems():
        # Get the current state
        stateUrl = "%s/json.htm?type=devices&rid=%d" % (
            DOMOTICZ_URL, idx)
        resp = urllib2.urlopen(stateUrl)
        jsonStr = resp.read()
        stateData = json.loads(jsonStr)
        if stateData.get("status", None) != "OK":
            log.err("Reply from '%s': %s", stateUrl, jsonStr)
            continue

        currentState = stateData["result"][0]["Status"]
        
        if arpAddr in foundArp:
            switchCmd = "On"
        else:
            switchCmd = "Off"
        if switchCmd == currentState:
            log.debug("Not switching %d due to unchanged state (%s)", idx, switchCmd)
            continue
        log.debug("Switching %s (%d) to %s", arpAddr, idx, switchCmd)
        switchUrl = "%s/json.htm?type=command¶m=switchlight&idx=%d&switchcmd=%s" % (
            DOMOTICZ_URL, idx, switchCmd)
        resp = urllib2.urlopen(switchUrl)
        data = resp.read()
        switchData = json.loads(data)
        if switchData.get("status", None) != "OK":
            log.err("Reply from '%s': %s", switchUrl, switchData)
            continue

def install(args):
    log.debug("Starting install")

def uninstall(args):
    log.debug("Starting uninstall")

def getParser():
    parser = argparse.ArgumentParser(description="Detects devices on the network based on ARP")
    parser.add_argument("--arpscan", dest="arpScanPath", default=DEFAULT_ARP_SCAN,
                        help="Location of arpscan, default %(default)s")
    parser.add_argument("--config", dest="configPath", default=DEFAULT_CONFIG_LOCATION,
                        help="Location of the config file, default %(default)s")
    parser.add_argument("-v", "--verbose", dest="verbose", action="store_true",
                        help="Enable verbose output")
    subParser = parser.add_subparsers(title="action", dest="action")

    scanParser = subParser.add_parser("scan", help="scan for devices")

    installParser = subParser.add_parser("install", help="install script into crontab")

    uninstallParser = subParser.add_parser("uninstall", help="uninstall script from crontab")

    return parser

def main():
    parser = getParser()
    args = parser.parse_args()
    if args.verbose:
        logging.basicConfig(level=logging.DEBUG)
    else:
        logging.basicConfig(level=logging.INFO)
    commandFn = globals()[args.action]
    commandFn(args)

if __name__ == "__main__":
    main()

Note: You'll want to customise the MAC address to switch indexes defined in
ARP_TO_IDX
and make sure that
WLANAP_HOSTS
contains each of the host names for your WiFi access points.

Creative Commons License
The words and photos on this webpage which are created by Toby Gray are licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 2.0 England & Wales License.