Radon - Airthings 2960 view plus (CO2, VOC, PM2.5, PM1, temp, humidity, pression and battery)

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

Moderator: leecollings

Post Reply
giton
Posts: 18
Joined: Tuesday 23 June 2020 22:35
Target OS: Raspberry Pi / ODroid
Domoticz version:
Contact:

Radon - Airthings 2960 view plus (CO2, VOC, PM2.5, PM1, temp, humidity, pression and battery)

Post by giton »

How to integrate airthings 2960 view plus in domoticz.

1) Buy your sensor, connect it to your wifi and configure it to get it active in the Airthings dashboard https://dashboard.airthings.com
2) Configure your account in the Airthings dashboard
  • Click 'Account
    • Change your 'User settings' to :
      • Language --> English
      • Radon --> Bq/m³
      • Temperature --> Celsius
      • Pressure --> hPa
      • VOC --> ppb
      • Date format --> dd.mm.yyyy
  • Click 'Devices'
    • Click on your device
    • Note the number on 10-digits near the 'View Plus' icon (information to be used to update your script later)
3) Create an API client request in Airthings dashboard
  • Click 'Integrations'
    • Click 'Request API Client'
      • Enter 'Name' --> 'Airthings-view-plus'
      • Enter 'Description' --> ''Airthings-view-plus @home'
      • Check 'read:device:current_values'
      • Select 'Confidential'
      • Select 'Flow type' --> Client credentials (machine-to-machine)
      • Note the 'Id' and the 'Secret' (information to be used to update your script later)
4) Configure Domoticz Hardware and devices
  • Create an new 'Hardware' with type 'Dummy'
  • Create 9 virtual Sensors with type and Axis Label' such as :
    • CO2 --> Custom sensor --> ppm
    • Battery --> Percentage
    • PM2.5 --> Custom sensor --> µg/m³
    • PM1 --> Custom sensor --> µg/m³
    • Pression --> Barometer
    • VOC --> Custom sensor --> ppb
    • Radon --> Bq/m³
    • Humidity --> Humidity
    • Temperature --> Temp
5) Create Python script to retrieve data from Airthings cloud
  • Create file named '/home/pi/domoticz/scripts/python/airthings_viewplus.py' with tis content:

Code: Select all

#!/usr/bin/env python3
"""
Airthings Business API integration for Domoticz.
Auto-refreshes token and updates Domoticz virtual sensors with latest device values.
Also saves retrieved data to a local JSON flat file for debugging or backup.
"""

import sys
import os
import time
import json
import requests

# === CONFIGURATION ===
CLIENT_ID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' # Replace this with your personnel CLIEND_ID
CLIENT_SECRET = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' # Replace this with your personnel CLIEND_SECRET
DEVICE_ID = 'xxxxxxxxxx' # Replace this with your personnel DEVICE_ID
TOKEN_FILE = '/home/pi/domoticz/scripts/python/airthings_token.json' # Adapt this if needed
DATA_FILE = '/home/pi/domoticz/scripts/python/airthings_data.json' # Adapt this if needed

DOMOTICZ_URL = 'http://127.0.0.1:8080'
DOMOTICZ_IDX = {
    'co2': 228, # Replace this with your idx device reference
    'humidity': 220,# Replace this with your idx device reference
    'radon': 222,# Replace this with your idx device reference
    'temperature': 219,# Replace this with your idx device reference
    'voc': 223,# Replace this with your idx device reference
    'pressure': 224,# Replace this with your idx device reference
    'pm1': 225,# Replace this with your idx device reference
    'pm25': 226,# Replace this with your idx device reference
    'battery': 227  # Replace this with your idx device reference
}

# === TOKEN MANAGEMENT ===
def save_token(token_data):
    token_data['timestamp'] = int(time.time())
    with open(TOKEN_FILE, 'w') as f:
        json.dump(token_data, f)

def load_token():
    if not os.path.exists(TOKEN_FILE):
        return None
    with open(TOKEN_FILE, 'r') as f:
        token_data = json.load(f)
    expires_in = token_data.get('expires_in', 3600)
    age = int(time.time()) - token_data.get('timestamp', 0)
    if age > expires_in - 60:  # refresh 1 min early
        return None
    return token_data.get('access_token')

def request_new_token():
    url = 'https://accounts-api.airthings.com/v1/token'
    payload = {
        'grant_type': 'client_credentials',
        'client_id': CLIENT_ID,
        'client_secret': CLIENT_SECRET,
        'scope': 'read:device:current_values'
    }
    headers = {'Content-Type': 'application/x-www-form-urlencoded'}
    response = requests.post(url, data=payload, headers=headers)
    response.raise_for_status()
    token_data = response.json()
    save_token(token_data)
    return token_data['access_token']

def get_token():
    token = load_token()
    if token:
        return token
    return request_new_token()

def refresh_token_if_needed(response):
    if response.status_code == 401:
        return request_new_token()
    return None

# === AIRTHINGS BUSINESS API ===
def get_latest_samples(token):
    url = f'https://ext-api.airthings.com/v1/devices/{DEVICE_ID}/latest-samples'
    headers = {'Authorization': f'Bearer {token}'}
    response = requests.get(url, headers=headers)
    if response.status_code == 401:
        token = refresh_token_if_needed(response)
        headers = {'Authorization': f'Bearer {token}'}
        response = requests.get(url, headers=headers)
    response.raise_for_status()
    return response.json()['data']

def get_device_info(token):
    url = f'https://ext-api.airthings.com/v1/devices/{DEVICE_ID}'
    headers = {'Authorization': f'Bearer {token}'}
    response = requests.get(url, headers=headers)
    if response.status_code == 401:
        token = refresh_token_if_needed(response)
        headers = {'Authorization': f'Bearer {token}'}
        response = requests.get(url, headers=headers)
    response.raise_for_status()
    return response.json()

# === SAVE RAW DATA ===
def save_airthings_data(samples, device_info, file_path=DATA_FILE):
    try:
        data = {
            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
            "samples": samples,
            "device_info": device_info
        }
        with open(file_path, 'w') as f:
            json.dump(data, f, indent=2)
    except Exception as e:
        print(f"Error saving data to {file_path}: {e}")

# === DOMOTICZ UPDATE ===
def update_domoticz(sensor, value):
    idx = DOMOTICZ_IDX[sensor]

    if sensor == 'temperature':
        url = f'{DOMOTICZ_URL}/json.htm?type=command&param=udevice&idx={idx}&nvalue=0&svalue={float(value):.1f}'
    elif sensor == 'humidity':
        url = f'{DOMOTICZ_URL}/json.htm?type=command&param=udevice&idx={idx}&nvalue={int(value)}&svalue=0'
    elif sensor == 'battery':
        url = f'{DOMOTICZ_URL}/json.htm?type=command&param=udevice&idx={idx}&nvalue=0&svalue={int(value)}'
    else:
        url = f'{DOMOTICZ_URL}/json.htm?type=command&param=udevice&idx={idx}&nvalue=0&svalue={value}'

    try:
        requests.get(url, timeout=5)
    except Exception as e:
        print(f"Error updating Domoticz idx {idx}: {e}")

# === MAIN ===
def main():
    token = get_token()
    try:
        samples = get_latest_samples(token)
        device_info = get_device_info(token)
    except requests.exceptions.HTTPError as e:
        print("Error fetching data from Airthings API:", e)
        return

    #save_airthings_data(samples, device_info)

    # Update Domoticz
    update_domoticz('co2', samples.get('co2', 0))
    update_domoticz('humidity', samples.get('humidity', 0))
    update_domoticz('radon', samples.get('radonShortTermAvg', 0))
    update_domoticz('temperature', samples.get('temp', 0))
    update_domoticz('voc', samples.get('voc', 0))
    update_domoticz('pressure', samples.get('pressure', 0))
    update_domoticz('pm1', samples.get('pm1', 0))
    update_domoticz('pm25', samples.get('pm25', 0))
    update_domoticz('battery', samples.get('battery', 0))

if __name__ == '__main__':
    main()
  • Adapt the '=== CONFIGURATION ===' section with your values
6) Trigger the script using DZvents .lua scirpt (make sure that 'EventSystem (Lua/Blockly/Scripts)' is enabled in Domotics ('Settings' > 'Other' > check 'EventSystem (Lua/Blockly/Scripts)' and click 'Apply Settings')
Create file named '/home/pi/domoticz/scripts/dzVents/scripts/airthings_trigger.lua' with this content:

Code: Select all

return {
    active = true,
    on = {
        timer = {'every 5 minutes'}
    },
    logging = {
        level = domoticz.LOG_ERROR,
        marker = "Airthings"
    },
    execute = function(domoticz)
        local pythonScript = "/home/pi/domoticz/scripts/python/airthings_viewplus.py"
        local handle = io.popen("python3 " .. pythonScript .. " 2>&1")
        local result = handle:read("*a")
        handle:close()

        if result ~= "" then
            domoticz.log("Error or output from script: " .. result, domoticz.LOG_ERROR)
        else
            domoticz.log("Airthings script executed successfully", domoticz.LOG_INFO)
        end
    end
}
Here a sample data your will get in file '/home/pi/domoticz/scripts/python/airthings_data.json' if you uncomment the line '#save_airthings_data(samples, device_info)' in the python script file :
{
"timestamp": "2025-10-16 05:00:01",
"samples": {
"time": 1760583439,
"battery": 100,
"co2": 727.0,
"humidity": 62.0,
"pm1": 8.0,
"pm25": 8.0,
"pressure": 990.0,
"radonShortTermAvg": 98.0,
"relayDeviceType": "hub",
"rssi": 0,
"temp": 20.7,
"voc": 167.0
},
"device_info": {
"id": "xxxxxxxxxx", # This has been replaced but it corresponds to your device serial number
"deviceType": "VIEW_PLUS",
"sensors": [
"radonShortTermAvg",
"temp",
"humidity",
"pressure",
"co2",
"voc",
"pm1",
"pm25"
],
"segment": {
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", # This has been replaced for anonymisation
"name": "View plus",
"started": "2025-10-13T08:57:31",
"active": true
},
"location": {
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", # This has been replaced for anonymisation
"name": "Mon domicile"
},
"productName": "View Plus"
}
}
Last edited by giton on Thursday 16 October 2025 13:23, edited 1 time in total.
User avatar
waltervl
Posts: 6689
Joined: Monday 28 January 2019 18:48
Target OS: Linux
Domoticz version: 2025.1
Location: NL
Contact:

Re: Radon - Airthings 2960 view plus (CO2, VOC, PM2.5, PM1, temp, humidity, pression and battery)

Post by waltervl »

Thanks!
I did not check it completely but you could probably skip the python part and do the data retrieval from dzvents (login to airthings, get the json and update the Domoticz dummy devices). Makes things easier...
So something for another user to optimize....
Domoticz running on Udoo X86 (on Ubuntu)
Devices/plugins: ZigbeeforDomoticz (with Xiaomi, Ikea, Tuya devices), Nefit Easy, Midea Airco, Omnik Solar, Goodwe Solar
giton
Posts: 18
Joined: Tuesday 23 June 2020 22:35
Target OS: Raspberry Pi / ODroid
Domoticz version:
Contact:

Re: Radon - Airthings 2960 view plus (CO2, VOC, PM2.5, PM1, temp, humidity, pression and battery)

Post by giton »

waltervl wrote: Thursday 16 October 2025 9:53 Thanks!
I did not check it completely but you could probably skip the python part and do the data retrieval from dzvents (login to airthings, get the json and update the Domoticz dummy devices). Makes things easier...
So something for another user to optimize....
Here a .lua script to be stored in '/home/pi/domoticz/scripts/dzVents/scripts/airthings.lua' that replaces the previous .lua script and the python script (domoticz may have to be restarted to get updated devices)
A pre-requisit is to install the 'dkjson' package with this command:

Code: Select all

sudo apt update
sudo apt install lua-dkjson

Code: Select all

local json = require("dkjson")

return {
    active = true,
    on = {
        timer = { 'every 5 minutes' }
    },
    logging = {
        level = domoticz.LOG_ERROR,
        marker = "Airthings"
    },

    execute = function(domoticz)
        -- === CONFIGURATION ===
        local CLIENT_ID     ='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' -- Replace this with your personnel CLIEND_ID
        local CLIENT_SECRET =  'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' -- Replace this with your personnel CLIEND_SECRET
        local DEVICE_ID     =  'xxxxxxxxxx' -- Replace this with your personnel DEVICE_ID

        local TOKEN_FILE = '/home/pi/domoticz/scripts/python/airthings_token.json'
        local DOMOTICZ_URL = 'http://127.0.0.1:8080'

        local IDX = {
            co2 = 228, --Replace this with your idx device reference
            humidity = 220, --Replace this with your idx device reference
            radon = 222, --Replace this with your idx device reference
            temperature = 219, --Replace this with your idx device reference
            voc = 223, --Replace this with your idx device reference
            pressure = 224, --Replace this with your idx device reference
            pm1 = 225, --Replace this with your idx device reference
            pm25 = 226, --Replace this with your idx device reference
            battery = 227 --Replace this with your idx device reference
        }

        -- === FUNCTIONS ===

        local function saveToken(data)
            data.timestamp = os.time()
            local file = io.open(TOKEN_FILE, "w")
            if file then
                file:write(json.encode(data))
                file:close()
            end
        end

        local function loadToken()
            local file = io.open(TOKEN_FILE, "r")
            if not file then return nil end
            local content = file:read("*a")
            file:close()
            local token_data, _, err = json.decode(content)
            if not token_data or err then return nil end

            local expires = token_data.expires_in or 3600
            local age = os.time() - (token_data.timestamp or 0)
            if age > (expires - 60) then
                return nil
            end
            return token_data.access_token
        end

        local function requestNewToken()
            local payload = string.format(
                "grant_type=client_credentials&client_id=%s&client_secret=%s&scope=read:device:current_values",
                CLIENT_ID, CLIENT_SECRET
            )

            local cmd = string.format(
                "curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' " ..
                "-d \"%s\" https://accounts-api.airthings.com/v1/token", payload
            )

            local handle = io.popen(cmd)
            local result = handle:read("*a")
            handle:close()

            local token_data = json.decode(result)
            if not token_data or not token_data.access_token then
                domoticz.log("Error retrieving new token", domoticz.LOG_ERROR)
                return nil
            end

            saveToken(token_data)
            return token_data.access_token
        end

        local function getToken()
            local token = loadToken()
            if token then return token end
            return requestNewToken()
        end

        local function fetchData(url, token)
            local cmd = string.format("curl -s -H 'Authorization: Bearer %s' %s", token, url)
            local handle = io.popen(cmd)
            local result = handle:read("*a")
            handle:close()
            if result == "" then return nil end
            local data = json.decode(result)
            return data
        end

        local function updateDomoticz(sensor, value)
            local idx = IDX[sensor]
            if not idx then return end
            local svalue = tostring(value)

            local url
            if sensor == 'temperature' then
                url = string.format("%s/json.htm?type=command&param=udevice&idx=%d&nvalue=0&svalue=%.1f", DOMOTICZ_URL, idx, value)
            elseif sensor == 'humidity' then
                url = string.format("%s/json.htm?type=command&param=udevice&idx=%d&nvalue=%d&svalue=0", DOMOTICZ_URL, idx, value)
            elseif sensor == 'battery' then
                url = string.format("%s/json.htm?type=command&param=udevice&idx=%d&nvalue=0&svalue=%d", DOMOTICZ_URL, idx, value)
            else
                url = string.format("%s/json.htm?type=command&param=udevice&idx=%d&nvalue=0&svalue=%s", DOMOTICZ_URL, idx, svalue)
            end

            os.execute(string.format("curl -s '%s' > /dev/null", url))
        end

        -- === MAIN LOGIC ===
        -- domoticz.log("Fetching Airthings data...", domoticz.LOG_INFO)
        local token = getToken()
        if not token then
            domoticz.log("No valid token; aborting", domoticz.LOG_ERROR)
            return
        end

        local baseUrl = "https://ext-api.airthings.com/v1/devices/" .. DEVICE_ID
        local samplesData = fetchData(baseUrl .. "/latest-samples", token)
        if not samplesData or not samplesData.data then
            domoticz.log("Error fetching Airthings samples", domoticz.LOG_ERROR)
            return
        end

        local s = samplesData.data
        --domoticz.log("Updating Domoticz with latest Airthings readings", domoticz.LOG_INFO)

        updateDomoticz('co2', s.co2 or 0)
        updateDomoticz('humidity', s.humidity or 0)
        updateDomoticz('radon', s.radonShortTermAvg or 0)
        updateDomoticz('temperature', s.temp or 0)
        updateDomoticz('voc', s.voc or 0)
        updateDomoticz('pressure', s.pressure or 0)
        updateDomoticz('pm1', s.pm1 or 0)
        updateDomoticz('pm25', s.pm25 or 0)
        updateDomoticz('battery', s.battery or 0)

        --domoticz.log("Airthings data update completed", domoticz.LOG_INFO)
    end
}
Last edited by giton on Thursday 16 October 2025 20:29, edited 1 time in total.
User avatar
waltervl
Posts: 6689
Joined: Monday 28 January 2019 18:48
Target OS: Linux
Domoticz version: 2025.1
Location: NL
Contact:

Re: Radon - Airthings 2960 view plus (CO2, VOC, PM2.5, PM1, temp, humidity, pression and battery)

Post by waltervl »

Why dkjson? Dzvents already has a JSON library...
And you can store the token in a user variable so no need to use a file for that.
And for fetch data do not use the external curl command but the dzvents asynchronous OpenUrl function. Much more reliable and not haltingvthe Domoticz system.

And for updating devices use the dzvents device set commands....
Domoticz running on Udoo X86 (on Ubuntu)
Devices/plugins: ZigbeeforDomoticz (with Xiaomi, Ikea, Tuya devices), Nefit Easy, Midea Airco, Omnik Solar, Goodwe Solar
giton
Posts: 18
Joined: Tuesday 23 June 2020 22:35
Target OS: Raspberry Pi / ODroid
Domoticz version:
Contact:

Re: Radon - Airthings 2960 view plus (CO2, VOC, PM2.5, PM1, temp, humidity, pression and battery)

Post by giton »

waltervl wrote: Thursday 16 October 2025 19:49 Why dkjson? Dzvents already has a JSON library...
And you can store the token in a user variable so no need to use a file for that.
And for fetch data do not use the external curl command but the dzvents asynchronous OpenUrl function. Much more reliable and not haltingvthe Domoticz system.

And for updating devices use the dzvents device set commands....
Here an updated version of the .lua script (pre-requisit: create uservariable named 'AirthingsToken' with variable type 'String' :

Code: Select all

return {
    active = true,

    on = {
        timer = { 'every 5 minutes' },
        httpResponses = { 'AirthingsTokenResponse', 'AirthingsDataResponse' }
    },

    logging = {
        level = domoticz.LOG_ERROR,
        marker = "Airthings"
    },

    execute = function(domoticz, item)	
	-- === CONFIGURATION ===
        local CLIENT_ID     ='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' -- Replace this with your personnel CLIEND_ID
        local CLIENT_SECRET =  'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' -- Replace this with your personnel CLIEND_SECRET
        local DEVICE_ID     =  'xxxxxxxxxx' -- Replace this with your personnel DEVICE_ID

        local TOKEN_VAR = 'AirthingsToken'  -- Token stored manually in Domoticz user variables

        local IDX = {
            co2 = 228, --Replace this with your idx device reference
            humidity = 220, --Replace this with your idx device reference
            radon = 222, --Replace this with your idx device reference
            temperature = 219, --Replace this with your idx device reference
            voc = 223, --Replace this with your idx device reference
            pressure = 224, --Replace this with your idx device reference
            pm1 = 225, --Replace this with your idx device reference
            pm25 = 226, --Replace this with your idx device reference
            battery = 227 --Replace this with your idx device reference
            }

        -- === TOKEN HANDLING ===
        local function loadToken()
            local var = domoticz.variables(TOKEN_VAR)
            if not var or var.value == '' then return nil end
            local token_data = domoticz.utils.fromJSON(var.value)
            if not token_data or not token_data.access_token then return nil end
            local expires = token_data.expires_in or 3600
            local age = os.time() - (token_data.timestamp or 0)
            if age > (expires - 60) then
                return nil
            end
            return token_data.access_token
        end

        local function saveToken(token_data)
            token_data.timestamp = os.time()
            local var = domoticz.variables(TOKEN_VAR)
            if var then
                var.set(domoticz.utils.toJSON(token_data))
            else
                domoticz.log("User variable '" .. TOKEN_VAR .. "' not found. Please create it manually (type String).", domoticz
.LOG_ERROR)
            end
        end

        local function requestNewToken()
            local payload = string.format(
                "grant_type=client_credentials&client_id=%s&client_secret=%s&scope=read:device:current_values",
                CLIENT_ID, CLIENT_SECRET
            )

            domoticz.openURL({
                url = "https://accounts-api.airthings.com/v1/token",
                method = "POST",
                headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
                postData = payload,
                callback = "AirthingsTokenResponse"
            })
        end

        local function fetchData(token)
            local url = "https://ext-api.airthings.com/v1/devices/" .. DEVICE_ID .. "/latest-samples"
            domoticz.openURL({
                url = url,
                method = "GET",
                headers = { ["Authorization"] = "Bearer " .. token },
                callback = "AirthingsDataResponse"
            })
        end
        
	local function humidityStatus(h)
	    if h < 30 then return 2
	    elseif h > 70 then return 3
	    else return 0
	    end
	end
        -- === HANDLE RESPONSES ===
        if item.isHTTPResponse then
            if item.callback == 'AirthingsTokenResponse' then
                if item.isError or item.statusCode ~= 200 then
                    domoticz.log("Error fetching token: " .. tostring(item.statusText), domoticz.LOG_ERROR)
                    return
                end
                local token_data = domoticz.utils.fromJSON(item.data)
                if token_data and token_data.access_token then
                    saveToken(token_data)
                    fetchData(token_data.access_token)
                else
                    domoticz.log("Invalid token response", domoticz.LOG_ERROR)
                end
                return
            end

            if item.callback == 'AirthingsDataResponse' then
                if item.isError or item.statusCode ~= 200 then
                    domoticz.log("Error fetching Airthings data: " .. tostring(item.statusText), domoticz.LOG_ERROR)
                    return
                end
                local samplesData = domoticz.utils.fromJSON(item.data)
                if not samplesData or not samplesData.data then
                    domoticz.log("Invalid Airthings data", domoticz.LOG_ERROR)
                    return
                end

                local s = samplesData.data

                if IDX.temperature and domoticz.devices(IDX.temperature) then
                    domoticz.devices(IDX.temperature).updateTemperature(s.temp or 0)
                end
		if IDX.humidity and domoticz.devices(IDX.humidity) then
		    domoticz.devices(IDX.humidity).updateHumidity(s.humidity or 0, humidityStatus(s.humidity or 0))
                end
                if IDX.co2 and domoticz.devices(IDX.co2) then
                    domoticz.devices(IDX.co2).updateCustomSensor(s.co2 or 0)
                end
                if IDX.radon and domoticz.devices(IDX.radon) then
                    domoticz.devices(IDX.radon).updateCustomSensor(s.radonShortTermAvg or 0)
                end
                if IDX.voc and domoticz.devices(IDX.voc) then
                    domoticz.devices(IDX.voc).updateCustomSensor(s.voc or 0)
                end
                if IDX.pressure and domoticz.devices(IDX.pressure) then
                    domoticz.devices(IDX.pressure).updateBarometer(s.pressure or 0)
                end
                if IDX.pm1 and domoticz.devices(IDX.pm1) then
                    domoticz.devices(IDX.pm1).updateCustomSensor(s.pm1 or 0)
                end
                if IDX.pm25 and domoticz.devices(IDX.pm25) then
                    domoticz.devices(IDX.pm25).updateCustomSensor(s.pm25 or 0)
                end
                if IDX.battery and domoticz.devices(IDX.battery) then
                    domoticz.devices(IDX.battery).updatePercentage(s.battery or 0)
                end
                return
            end
        end

        -- === MAIN EXECUTION (timer) ===
        if item.isTimer then
            local token = loadToken()
            if token then
                fetchData(token)
            else
                requestNewToken()
            end
        end
    end
}
User avatar
waltervl
Posts: 6689
Joined: Monday 28 January 2019 18:48
Target OS: Linux
Domoticz version: 2025.1
Location: NL
Contact:

Re: Radon - Airthings 2960 view plus (CO2, VOC, PM2.5, PM1, temp, humidity, pression and battery)

Post by waltervl »

Yes, this look more like it! Well done!
Domoticz running on Udoo X86 (on Ubuntu)
Devices/plugins: ZigbeeforDomoticz (with Xiaomi, Ikea, Tuya devices), Nefit Easy, Midea Airco, Omnik Solar, Goodwe Solar
giton
Posts: 18
Joined: Tuesday 23 June 2020 22:35
Target OS: Raspberry Pi / ODroid
Domoticz version:
Contact:

Re: Radon - Airthings 2960 view plus (CO2, VOC, PM2.5, PM1, temp, humidity, pression and battery)

Post by giton »

Here an updated version of the script to handle 2 Airthings view plus 2960 devices

(Restart Domoticz after configuration if you do not get your devices updated in Domoticz)

Once the devices have been created, you can update the following script like this :

Code: Select all

return {
    active = true,

    on = {
        timer = { 'every 5 minutes' },
        httpResponses = { 'AirthingsTokenResponse', 'AirthingsDataResponse1', 'AirthingsDataResponse2' }
    },

    logging = {
        level = domoticz.LOG_ERROR, -- use LOG_DEBUG for detailed output
        marker = "Airthings"
    },

    execute = function(domoticz, item)
        -- === CONFIGURATION ===
        local CLIENT_ID     ='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' -- Replace this with your personnel CLIEND_ID
        local CLIENT_SECRET =  'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' -- Replace this with your personnel CLIEND_SECRET

        -- Two Airthings devices (serial numbers)
        local DEVICE_IDS = {
            [1] = 'xxxxxxxxxx',  -- Replace this with your device #
            [2] = 'yyyyyyyyyy'   -- Replace this with your device #
        }

        -- Domoticz IDX mapping for each device (adjust to your setup)
        local IDX = {
            [1] = { temperature=219, humidity=220, co2=228, radon=222, voc=223, pressure=224, pm1=225, pm25=226, battery=229 },
            [2] = { temperature=238, humidity=237, co2=233, radon=236, voc=235, pressure=234, pm1=231, pm25=230, battery=232 }
        }

        local TOKEN_VAR = 'AirthingsToken'
        local API_BASE = 'https://ext-api.airthings.com/v1/devices/'

        -- === TOKEN HANDLING ===
        local function loadToken()
            local var = domoticz.variables(TOKEN_VAR)
            if not var or var.value == '' then return nil end
            local token_data = domoticz.utils.fromJSON(var.value)
            if not token_data or not token_data.access_token then return nil end
            local expires = token_data.expires_in or 3600
            local age = os.time() - (token_data.timestamp or 0)
            if age > (expires - 60) then
                return nil -- token expired or about to expire
            end
            return token_data.access_token
        end

        local function saveToken(token_data)
            token_data.timestamp = os.time()
            local var = domoticz.variables(TOKEN_VAR)
            if var then
                var.set(domoticz.utils.toJSON(token_data))
            else
                domoticz.log("User variable '" .. TOKEN_VAR .. "' not found. Please create it manually (String type).", domoticz.LOG_ERROR)
            end
        end

        local function requestNewToken()
            local payload = string.format(
                "grant_type=client_credentials&client_id=%s&client_secret=%s&scope=read:device:current_values",
                CLIENT_ID, CLIENT_SECRET
            )

            domoticz.openURL({
                url = "https://accounts-api.airthings.com/v1/token",
                method = "POST",
                headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
                postData = payload,
                callback = "AirthingsTokenResponse"
            })
        end

        -- === API DATA REQUEST ===
        local function fetchData(token, deviceIndex)
            local deviceID = DEVICE_IDS[deviceIndex]
            local callbackName = "AirthingsDataResponse" .. deviceIndex
            local url = API_BASE .. deviceID .. "/latest-samples"

            domoticz.openURL({
                url = url,
                method = "GET",
                headers = { ["Authorization"] = "Bearer " .. token },
                callback = callbackName
            })
        end

        -- === HELPER: Humidity status for Domoticz UI (comfort level) ===
        local function humidityStatus(h)
            if h < 30 then return 2   -- dry
            elseif h > 70 then return 3 -- humid
            else return 0 end         -- normal
        end

        -- === HELPER: Update Domoticz virtual sensors with Airthings data ===
        local function updateDeviceValues(index, data)
            local s = data.data
            local idxs = IDX[index]
            if not idxs then return end

            if idxs.temperature and domoticz.devices(idxs.temperature) then
                domoticz.devices(idxs.temperature).updateTemperature(s.temp or 0)
            end
            if idxs.humidity and domoticz.devices(idxs.humidity) then
                domoticz.devices(idxs.humidity).updateHumidity(s.humidity or 0, humidityStatus(s.humidity or 0))
            end
            if idxs.co2 and domoticz.devices(idxs.co2) then
                domoticz.devices(idxs.co2).updateCustomSensor(s.co2 or 0)
            end
            if idxs.radon and domoticz.devices(idxs.radon) then
                domoticz.devices(idxs.radon).updateCustomSensor(s.radonShortTermAvg or 0)
            end
            if idxs.voc and domoticz.devices(idxs.voc) then
                domoticz.devices(idxs.voc).updateCustomSensor(s.voc or 0)
            end
            if idxs.pressure and domoticz.devices(idxs.pressure) then
                domoticz.devices(idxs.pressure).updateBarometer(s.pressure or 0)
            end
            if idxs.pm1 and domoticz.devices(idxs.pm1) then
                domoticz.devices(idxs.pm1).updateCustomSensor(s.pm1 or 0)
            end
            if idxs.pm25 and domoticz.devices(idxs.pm25) then
                domoticz.devices(idxs.pm25).updateCustomSensor(s.pm25 or 0)
            end
            if idxs.battery and domoticz.devices(idxs.battery) then
                domoticz.devices(idxs.battery).updatePercentage(s.battery or 0)
            end

            domoticz.log("Airthings: device " .. index .. " updated successfully", domoticz.LOG_INFO)
        end

        -- === HANDLE API RESPONSES ===
        if item.isHTTPResponse then
            -- Handle token response
            if item.callback == 'AirthingsTokenResponse' then
                if item.isError or item.statusCode ~= 200 then
                    domoticz.log("Error fetching token: " .. tostring(item.statusText), domoticz.LOG_ERROR)
                    return
                end
                local token_data = domoticz.utils.fromJSON(item.data)
                if token_data and token_data.access_token then
                    saveToken(token_data)
                    -- Fetch both devices after successful token retrieval
                    fetchData(token_data.access_token, 1)
                    fetchData(token_data.access_token, 2)
                else
                    domoticz.log("Invalid token response", domoticz.LOG_ERROR)
                end
                return
            end

            -- Handle device data responses
            for i = 1, 2 do
              --i = 1 
                if item.callback == "AirthingsDataResponse" .. i then
                    if item.isError or item.statusCode ~= 200 then
                        domoticz.log("Error fetching Airthings data (device " .. i .. "): " .. tostring(item.statusText), domoticz.LOG_ERROR)
                        return
                    end
                    local samplesData = domoticz.utils.fromJSON(item.data)
                    if not samplesData or not samplesData.data then
                        domoticz.log("Invalid Airthings data for device " .. i, domoticz.LOG_ERROR)
                        return
                    end
                    updateDeviceValues(i, samplesData)
                    return
                end
            end
        end

        -- === MAIN EXECUTION (TIMER TRIGGER) ===
        if item.isTimer then
            local token = loadToken()
            if token then
                -- Use existing token
                fetchData(token, 1)
                fetchData(token, 2)
            else
                -- Request new token if missing or expired
                requestNewToken()
            end
        end
    end
}
Post Reply

Who is online

Users browsing this forum: No registered users and 1 guest