Presence Detection dzVents script

In this subforum you can show projects you have made, or you are busy with. Please create your own topic.

Moderator: leecollings

Post Reply
HvdW
Posts: 583
Joined: Sunday 01 November 2015 22:45
Target OS: Raspberry Pi / ODroid
Domoticz version: 2023.2
Location: Twente
Contact:

Presence Detection dzVents script

Post 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.
Bugs bug me.
Post Reply

Who is online

Users browsing this forum: No registered users and 0 guests