Page 1 of 1

Presence Detection dzVents script

Posted: Saturday 15 March 2025 22:29
by HvdW
Hi,
The idea is mine, the heavy loading was done by DeepSeek and the finetuning by Claude.

Code: Select all

-- Presence Detection Script for Domoticz
-- This script detects devices on the network using ARP and categorizes them into:
-- 1. Detect: Devices to monitor (e.g., user devices).
-- 2. Ignore: Devices to exclude from monitoring.
-- 3. Unknown: New or unrecognized devices.
-- Devices are read from and written to CSV files for easy management.
-- In DEBUG mode, all actions are logged, and the text device shows detected and unknown devices.
-- In ERROR mode, file writing and reading actions occur only at maintenance time (03:00)
-- The text sensor is updated every timer event like 5 minutes showing to be detected and unknown devices.
-- Output is limited to 16 lines to avoid Domoticz display issues.
-- Designed by humans, scripted by AI.

-- Constants for ease of maintenance
local DETECT_FILE = '/home/pi/csvData/presence_detection_detect.csv'
local IGNORE_FILE = '/home/pi/csvData/presence_detection_ignore.csv'
local UNKNOWN_FILE = '/home/pi/csvData/presence_detection_unknown.csv'
local MAINTENANCE_TIME = '03:00' -- Time for maintenance tasks in LOG_ERROR mode
local SHOW_UNKNOWN_DEVICES = true -- Set to false to exclude unknown devices from the text sensor display
local INITIATE = false -- set up environment including csv files with headers
local HEADER = "device,IP-address,MAC-address,user-name,friendly-device-name" -- HEADER for the csv files

return {
    on = {
        timer = {
            'every 1 minutes' -- Run the script every 1 minute
        }
    },
    logging = {
        level = domoticz.LOG_DEBUG,  -- Change to LOG_ERROR for maintenance mode
        marker = 'PD' --'---- Presence Detection Script -----'
    },
    execute = function(domoticz)
        domoticz.log('Presence detection check started', domoticz.LOG_DEBUG)

        -- Text device where the results will be written
        local textDevice = domoticz.devices('Presence detected') -- Replace with your text device name

        if INITIATE then
            -- Function to initialize a file with the header
            local function initializeFile(filePath, header)
            local file = io.open(filePath, "r")
            if file then
                -- File already exists, do nothing
                file:close()
                print("File already exists: " .. filePath)
            else
                local file = io.open(filePath, "w")
                if file then
                    file:write(header .. "\n")
                    file:close()
                    print("Initialized file: " .. filePath)
                else
                    print("Error: Could not open file for writing: " .. filePath)
                end
            end
        end

        -- Initialize each file
        initializeFile(DETECT_FILE, HEADER)
        initializeFile(IGNORE_FILE, HEADER)
        initializeFile(UNKNOWN_FILE, HEADER)
        end

        -- Function to read a CSV file
        local function readCSVFile(filePath)
            local devices = {}
            local file = io.open(filePath, 'r')
            if not file then
                domoticz.log('Could not open CSV file: ' .. filePath, domoticz.LOG_ERROR)
                return devices
            end

            -- Skip the header line
            file:read()

            -- Read the file line by line
            for line in file:lines() do
                line = line:gsub('\r', '') -- Remove carriage returns
                local device, ip, mac, user, friendlyName = line:match('([^;]+);([^;]+);([^;]+);([^;]*);([^;]*)')
                if device and ip and mac then
                    table.insert(devices, {
                        device = device,
                        ip = ip,
                        mac = mac:lower(), -- Ensure MAC is in lowercase
                        user = user,
                        friendlyName = friendlyName
                    })
                end
            end

            file:close()
            return devices
        end

        -- Function to write a CSV file
        local function writeCSVFile(filePath, devices, header)
            local file = io.open(filePath, 'w')
            if not file then
                domoticz.log('Could not open CSV file for writing: ' .. filePath, domoticz.LOG_ERROR)
                return
            end

            -- Write the header
            file:write(header .. '\n')

            -- Write the devices
            for _, device in ipairs(devices) do
                file:write(string.format('%s;%s;%s;%s;%s\n', device.device, device.ip, device.mac, device.user or '', device.friendlyName or ''))
            end

            file:close()
            domoticz.log('CSV file updated successfully: ' .. filePath, domoticz.LOG_DEBUG)
        end

        -- Persistent variables to retain data between loops
        local detectDevices = detectDevices or readCSVFile(DETECT_FILE)
        local ignoreDevices = ignoreDevices or readCSVFile(IGNORE_FILE)
        local unknownDevices = unknownDevices or readCSVFile(UNKNOWN_FILE)

        -- Reload CSV files during maintenance time in ERROR mode
        if domoticz.settings.logLevel == domoticz.LOG_ERROR and os.date('%H:%M') == MAINTENANCE_TIME then
            detectDevices = readCSVFile(DETECT_FILE)
            ignoreDevices = readCSVFile(IGNORE_FILE)
            unknownDevices = readCSVFile(UNKNOWN_FILE)
            domoticz.log('CSV files reloaded at maintenance time', domoticz.LOG_DEBUG)
        end

        if domoticz.settings.logLevel == domoticz.LOG_DEBUG then
            -- Log the contents of the files for debugging
            domoticz.log('Detect devices:', domoticz.LOG_DEBUG)
            for _, device in ipairs(detectDevices) do
            domoticz.log(string.format('Device: %s, IP: %s, MAC: %s, User: %s, Friendly Name: %s', device.device, device.ip, device.mac, device.user or 'N/A', device.friendlyName or 'N/A'), domoticz.LOG_DEBUG)
            end

            domoticz.log('Ignore devices:', domoticz.LOG_DEBUG)
            for _, device in ipairs(ignoreDevices) do
                domoticz.log(string.format('Device: %s, IP: %s, MAC: %s, User: %s, Friendly Name: %s', device.device, device.ip, device.mac, device.user or 'N/A', device.friendlyName or 'N/A'), domoticz.LOG_DEBUG)
            end

            domoticz.log('Unknown devices:', domoticz.LOG_DEBUG)
            for _, device in ipairs(unknownDevices) do
                domoticz.log(string.format('Device: %s, IP: %s, MAC: %s, User: %s, Friendly Name: %s', device.device, device.ip, device.mac, device.user or 'N/A', device.friendlyName or 'N/A'), domoticz.LOG_DEBUG)
            end
        end

        -- Empty the ARP-cache every full hour
        if os.date('%M') == "00" then
            os.execute('sudo ip -s -s neigh flush all')
        end
        
        -- Execute `arp -a` to get the current ARP table
        local handle = io.popen('arp -a')
        local arpOutput = handle:read('*a')
        handle:close()

        -- Log the ARP table for debugging
        domoticz.log('ARP table result:', domoticz.LOG_DEBUG)
        domoticz.log(arpOutput, domoticz.LOG_DEBUG)

        -- Parse the ARP table and categorize devices
        local detectedDevices = {}
        local newUnknownDevices = {}
        
        for line in arpOutput:gmatch('[^\r\n]+') do
            domoticz.log('Processing ARP line: ' .. line, domoticz.LOG_DEBUG)
            
            -- Try multiple matching patterns to handle different ARP output formats
            local hostname, ip, mac
            
            -- Standard pattern: hostname (ip) at mac
            hostname, ip, mac = line:match('([^%s]+) %(([^)]+)%) at (%S+)')
            
            -- If standard pattern fails, try pattern for ? (ip) at mac
            if not mac then
                ip, mac = line:match('%? %(([^)]+)%) at (%S+)')
                hostname = "Unknown"
            end
            
            -- Ensure we have an IP and MAC (may be "<incomplete>")
            if ip and mac then
                -- Skip the [ether] part if it exists
                mac = mac:gsub('%s+%[ether%]', '')
                
                -- Skip "on eth0" or similar interface identifiers
                mac = mac:gsub('%s+on%s+%S+', '')
                
                -- If mac is "<incomplete>", generate a placeholder but still track the device
                local isIncomplete = mac:match('<incomplete>')
                if isIncomplete then
                    -- Use IP as a placeholder for incomplete MACs
                    mac = "incomplete_" .. ip:gsub('%D', '')
                else
                    mac = mac:lower() -- Ensure MAC is in lowercase
                end
                
                local found = false

                -- Check if the device is in the detect list
                for _, device in ipairs(detectDevices) do
                    if device.mac == mac then
                        table.insert(detectedDevices, device)
                        found = true
                        break
                    end
                end

                -- Check if the device is in the ignore list
                if not found then
                    for _, device in ipairs(ignoreDevices) do
                        if device.mac == mac then
                            found = true
                            break
                        end
                    end
                end

                -- If the device is not in detect or ignore, add it to the unknown list
                if not found then
                    local deviceName = hostname or "Unknown"
                    -- Check if it's a question mark hostname
                    if deviceName == "?" then deviceName = "Unknown" end
                    
                    table.insert(newUnknownDevices, {
                        device = deviceName,
                        ip = ip,
                        mac = mac,
                        user = 'Unknown',
                        friendlyName = isIncomplete and 'Incomplete MAC' or 'Unknown'
                    })
                    
                    domoticz.log(string.format('New unknown device found: %s, %s, %s', deviceName, ip, mac), domoticz.LOG_DEBUG)
                end
            else
                domoticz.log('Could not parse line: ' .. line, domoticz.LOG_DEBUG)
            end
        end

        -- Debug logging for new unknown devices
        domoticz.log('New unknown devices count: ' .. #newUnknownDevices, domoticz.LOG_DEBUG)
        for _, device in ipairs(newUnknownDevices) do
            domoticz.log(string.format('Unknown Device: %s, IP: %s, MAC: %s', device.device, device.ip, device.mac), domoticz.LOG_DEBUG)
        end

        -- Prepare text device output
        local textOutput = {}

        if domoticz.settings.logLevel == domoticz.LOG_DEBUG then
            -- In DEBUG mode, show detected and unknown devices with full details
            table.insert(textOutput, 'Detected devices:')
            for _, device in ipairs(detectedDevices) do
                table.insert(textOutput, string.format('%s, %s, %s, %s, %s', device.device, device.ip, device.mac, device.user or 'N/A', device.friendlyName or 'N/A'))
            end

            if #newUnknownDevices > 0 then
                table.insert(textOutput, 'Unknown devices:')
                for _, device in ipairs(newUnknownDevices) do
                    table.insert(textOutput, string.format('%s, %s, %s', device.device, device.ip, device.mac))
                end
            else
                table.insert(textOutput, 'No unknown devices')
            end
        else
            -- In ERROR mode, show detected devices with user and friendly name, and unknown devices with basic details
            table.insert(textOutput, 'Detected devices:')
            for _, device in ipairs(detectedDevices) do
                table.insert(textOutput, string.format('%s, %s', device.user or 'N/A', device.friendlyName or 'N/A'))
            end
            
            if SHOW_UNKNOWN_DEVICES and #newUnknownDevices > 0 then
                table.insert(textOutput, 'Unknown devices:')
                for _, device in ipairs(newUnknownDevices) do
                    table.insert(textOutput, string.format('%s, %s, %s', device.device, device.ip, device.mac))
                end
            end
        end

        -- Limit output to 16 lines
        local maxLines = 16
        local limitedOutput = {}
        for i = 1, math.min(#textOutput, maxLines) do
            table.insert(limitedOutput, textOutput[i])
        end
        if #textOutput > maxLines then
            table.insert(limitedOutput, '... (more devices detected)')
        end

        -- Update the text device
        textDevice.updateText(table.concat(limitedOutput, '\n'))

        -- Write the updated unknown devices to the unknown file (only at maintenance time in LOG_ERROR mode)
        if domoticz.settings.logLevel == domoticz.LOG_DEBUG or os.date('%H:%M') == MAINTENANCE_TIME then
            writeCSVFile(UNKNOWN_FILE, newUnknownDevices, 'device;IP-address;MAC-address;user-name;friendly-device-name')
        end
    end
}
On top of the file things are explained.
All you have to do is download the script and load it in your Domoticz.
Then add a new Text device with the name 'Presence detected' and off you go.
The files are created by the script by setting local INITIATE = true (don't forget to change it back to false after the files are created)
Set the time trigger back to 5 minutes when everything is OK and change LOG_DEBUG to LOG_ERROR (level = domoticz.LOG_ERROR)
To select the devices that are displayed install WinSCP on Windows.
Connect to your RPI and open the file presence_detection_unknown.csv
Select the devices that you want to be detected copy those lines to presence_detection_detect.csv and delete these lines from presence_detection_unknown.csv
The devices that can be ignored like your RPI, your computer etc. can be put in the file presence_detection_ignore.csv. Don't forget to delete these from presence_detection_unknown.csv
Once you get visitors in your house you'll find those devices in presence_detection_unknown.csv and in the text sensor 'Presence detected'. You can choose if you want to add those devices to your detect file or your ignore file.
The header of the files show how you can create nice output.
I did it like this:

Code: Select all

device,IP-address,MAC-address,user-name,friendly-device-name
a54.home;192.168.2.4;16:64:81:93:72:82;myName;Samsung A54
Just replacing the last two fields with myName and SamsungA54
The text before that <a54.home;192.168.2.4;16:64:87:95:69:80;> is created by arp and the script.

Re: Presence Detection dzVents script

Posted: Sunday 10 August 2025 9:05
by HvdW
I made the Presence detection more robust by using NMAP instead of ARP

Code: Select all

-- Presence Detection Script for Domoticz

local DETECT_FILE = '/home/pi/csvData/presence_detection_detect.csv'
local IGNORE_FILE = '/home/pi/csvData/presence_detection_ignore.csv'
local UNKNOWN_FILE = '/home/pi/csvData/presence_detection_unknown.csv'
local NMAP_OUTPUT_FILE = '/tmp/nmap.txt'  -- Path to the nmap output file created by crontab
local MAINTENANCE_TIME = '00:07' -- Time for maintenance tasks in LOG_ERROR mode

-- Initialize data structures
local function loadCSVFile(filePath)
    local devices = {}
    local file = io.open(filePath, "r")
    if file then
        -- Skip header line
        local header = file:read()
        for line in file:lines() do
            local device, ip, mac, username, friendlyName = line:match("([^;]+);([^;]+);([^;]+);([^;]+);([^;]+)")
            if mac then
                -- Normalize MAC address format for comparison
                mac = mac:lower():gsub('[^%x]', '')
                devices[mac] = {
                    device = device,
                    ip = ip,
                    username = username,
                    friendlyName = friendlyName,
                    macOriginal = mac
                }
            end
        end
        file:close()
    end
    return devices
end

return {
    on = {
        timer = {'every 5 minutes', 'at '..MAINTENANCE_TIME},
    },
    logging = {
        level = domoticz.LOG_ERROR,
        marker = 'PRESENCE',
    },
    data = {
        -- Persistent storage for todayDevices
        todayDevices = { initial = {} }
    },
    execute = function(domoticz, timer)
        local textDevice = domoticz.devices('Presence detected')
        local detectedDevices = {}
        local todayDevices = domoticz.data.todayDevices
        
        -- Load known and ignored devices
        local knownDevices = loadCSVFile(DETECT_FILE)
        local ignoredDevices = loadCSVFile(IGNORE_FILE)

        -- Check if nmap output file exists
        local nmapFile = io.open(NMAP_OUTPUT_FILE, "r")
        if not nmapFile then
            domoticz.log('Nmap output file not found: '..NMAP_OUTPUT_FILE, domoticz.LOG_ERROR)
            return
        end
        
        -- Read the entire file content to check if there's any data
        local fileContent = nmapFile:read("*all")
        nmapFile:close()
        
        if not fileContent or fileContent == "" then
            domoticz.log('Nmap output file is empty: '..NMAP_OUTPUT_FILE, domoticz.LOG_ERROR)
            return
        end
        
        -- Parse nmap scan results
        local foundMacAddresses = false
        local currentScan = {}
        local ipHostMap = {}
        
        -- Reopen the file for line-by-line processing
        nmapFile = io.open(NMAP_OUTPUT_FILE, "r")
        
        -- First pass: build IP-hostname mapping
        for line in nmapFile:lines() do
            if line:match("Nmap scan report") then
                local hostname = line:match("for ([^%(]+)") or "unknown"
                hostname = hostname:gsub("%s+$", "") -- Trim trailing spaces
                local ip = line:match("%(([0-9%.]+)%)") or line:match("for ([0-9%.]+)")
                
                if ip then
                    ipHostMap[ip] = hostname
                end
            end
        end
        
        -- Reset file pointer
        nmapFile:seek("set", 0)
        
        -- Second pass: extract MAC addresses and associate with IP/hostname
        local lastIP = nil
        
        for line in nmapFile:lines() do
            if line:match("Nmap scan report") then
                lastIP = line:match("%(([0-9%.]+)%)") or line:match("for ([0-9%.]+)")
            elseif line:match("MAC Address: ([0-9A-Fa-f:]+)") then
                foundMacAddresses = true
                local mac = line:match("MAC Address: ([0-9A-Fa-f:]+)")
                local vendor = line:match("MAC Address: [0-9A-Fa-f:]+ %((.+)%)")
                
                -- Normalize MAC address format for comparison
                local normalizedMac = mac:lower():gsub('[^%x]', '')
                
                -- Get hostname and IP from the mapping
                local ip = lastIP or "unknown"
                local hostname = ipHostMap[ip] or "unknown"
                
                -- Add device to scan results if not ignored
                if not ignoredDevices[normalizedMac] then
                    currentScan[normalizedMac] = {
                        mac = mac,
                        hostname = hostname,
                        ip = ip,
                        vendor = vendor or "unknown"
                    }
                    
                    -- Add to today's devices if new
                    if not todayDevices[normalizedMac] then
                        if knownDevices[normalizedMac] then
                            -- Known device
                            todayDevices[normalizedMac] = {
                                type = "known",
                                username = knownDevices[normalizedMac].username,
                                friendlyName = knownDevices[normalizedMac].friendlyName,
                                mac = mac,
                                hostname = hostname,
                                ip = ip
                            }
                        else
                            -- Unknown device
                            todayDevices[normalizedMac] = {
                                type = "unknown",
                                hostname = hostname,
                                mac = mac,
                                ip = ip,
                                vendor = vendor or "unknown"
                            }
                        end
                    end
                    
                    -- Add to detected devices for current scan
                    detectedDevices[normalizedMac] = todayDevices[normalizedMac]
                end
            end
        end
        nmapFile:close()
        
        -- Check if scan found any MAC addresses
        if not foundMacAddresses then
            domoticz.log('No MAC addresses found in nmap scan output', domoticz.LOG_ERROR)
            return
        end
        
        -- Prepare text for the sensor
        local detectedText = "Detected devices:\n"
        local todayText = "Devices today:\n"

        -- Sort and format detected devices (only known devices from detect.csv)
        local detectedList = {}
        for mac, device in pairs(detectedDevices) do
            if device.type == "known" then  -- Only include known devices
                table.insert(detectedList, device.username .. " (" .. device.friendlyName .. ")")
            end
        end
        table.sort(detectedList)

        -- Sort and format today's devices (both known and unknown, excluding ignored)
        local todayList = {}
        for _, device in pairs(todayDevices) do
            if device.type == "known" then
                table.insert(todayList, device.username .. " (" .. device.friendlyName .. ")")
            else
                table.insert(todayList, device.hostname .. " (" .. device.mac .. ")")
            end
        end
        table.sort(todayList)
        
        -- Update the text device
        if #detectedList == 0 then
            detectedText = detectedText .. "None"
        else
            detectedText = detectedText .. table.concat(detectedList, "\n")
        end
        
        if #todayList == 0 then
            todayText = todayText .. "None"
        else
            todayText = todayText .. table.concat(todayList, "\n")
        end
        
        textDevice.updateText(detectedText .. "\n\n" .. todayText)
        
        -- Maintenance task at specified time
        if os.date("%H:%M") == MAINTENANCE_TIME then
            domoticz.log('Running maintenance tasks at '..MAINTENANCE_TIME, domoticz.LOG_INFO)

            -- Save unknown devices to file
            local unknownFile = io.open(UNKNOWN_FILE, "a")
            if unknownFile then
                local currentDate = os.date("%Y-%m-%d")
        
                for mac, device in pairs(todayDevices) do
                    if device.type == "unknown" then
                        -- Check if not in known or ignored lists
                        if not knownDevices[mac] and not ignoredDevices[mac] then
                            -- Format: date;hostname;ip;mac;vendor
                            unknownFile:write(currentDate..";"..device.hostname..";"..device.ip..";"..device.mac..";"..device.vendor.."\n")
                        end
                    end
                end
        
                unknownFile:close()
                domoticz.log('Unknown devices saved to '..UNKNOWN_FILE, domoticz.LOG_INFO)
            else
                domoticz.log('Failed to open unknown devices file for writing: '..UNKNOWN_FILE, domoticz.LOG_ERROR)
            end
                
            -- Reset todayDevices at MAINTENANCE_TIME
            domoticz.log('Resetting today\'s devices list at '..MAINTENANCE_TIME, domoticz.LOG_INFO)
            domoticz.data.todayDevices = {}
            todayDevices = {}
        end
    end
}
The changes you need to make is editing crontab <crontab -e> and add this line <*/5 * * * * sudo nmap -sn 192.168.2.0/24 > /tmp/nmap.txt> where you need to change the IP range according your own LAN setup.