Merge branch 'main' of https://github.com/cipianpascu/openhab-addons into feature/s-bus

This commit is contained in:
Ciprian Pascu 2024-12-22 12:35:29 +02:00
commit 2c07129ee9
5626 changed files with 532223 additions and 69294 deletions

23
.github/stale.yml vendored
View File

@ -1,23 +0,0 @@
# Number of days of inactivity before an issue becomes stale (two month)
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed (another six month)
daysUntilClose: 180
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- "PR pending"
# Only issues with all of these labels are checked if stale.
onlyLabels:
- "awaiting feedback"
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
# Limit to only `issues``
only: issues

View File

@ -18,24 +18,24 @@ jobs:
strategy:
fail-fast: false
matrix:
java: [ '17' ]
maven: [ '3.9.4' ]
os: [ 'ubuntu-22.04' ]
java: [ '21' ]
maven: [ '3.9.9' ]
os: [ 'ubuntu-24.04' ]
name: Build (Java ${{ matrix.java }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
if: github.head_ref == ''
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Checkout merge
if: github.head_ref != ''
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
ref: refs/pull/${{github.event.pull_request.number}}/merge
- name: Set up Cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.m2/repository
@ -45,18 +45,18 @@ jobs:
${{ runner.os }}-maven-
- name: Set up Java ${{ matrix.java }}
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: ${{ matrix.java }}
- name: Set up Maven ${{ matrix.maven }}
uses: stCarolas/setup-maven@v4.5
uses: stCarolas/setup-maven@v5
with:
maven-version: ${{ matrix.maven }}
- name: Register Problem Matchers
if: ${{ matrix.java == '17' }}
if: ${{ matrix.java == '21' }}
id: problem_matchers
run: |
echo "::add-matcher::.github/openhab-compile-problems.json"
@ -64,7 +64,7 @@ jobs:
- name: Get Changed Files
if: github.head_ref != ''
id: files
uses: Ana06/get-changed-files@v2.2.0
uses: Ana06/get-changed-files@v2.3.0
with:
format: 'csv'
@ -81,22 +81,34 @@ jobs:
- name: Upload Build Log
if: ${{ always() && ((steps.build.outcome == 'success') || (steps.build.outcome == 'failure')) }}
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: build-log-java-${{ matrix.java }}-${{ matrix.os }}
path: build.log
- name: Upload SAT Summary Report
if: ${{ always() && ((steps.build.outcome == 'success') || (steps.build.outcome == 'failure')) }}
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: sat-summary-report
path: target/summary_report.html
- name: Report SAT Errors as Annotations
if: ${{ matrix.java == '17' && always() && ((steps.build.outcome == 'success') || (steps.build.outcome == 'failure')) }}
if: ${{ matrix.java == '21' && always() && ((steps.build.outcome == 'success') || (steps.build.outcome == 'failure')) }}
uses: ghys/checkstyle-github-action@main
with:
title: CheckStyle Violations
path: '**/checkstyle-result.xml'
mode: inline
- name: Verify Changed Files
uses: tj-actions/verify-changed-files@v20
id: verify-changed-files
- name: Fail on Changed Files
if: steps.verify-changed-files.outputs.changed_files != ''
env:
CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }}
run: |
echo "::error::Files have changed: $CHANGED_FILES"
exit 1

27
.github/workflows/stale-issues.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: 'Stale issues check'
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
name: Stale issues check
permissions:
issues: write
steps:
- name: Stale issues check
uses: actions/stale@v9
with:
days-before-issue-stale: 60
days-before-issue-close: 180
days-before-pr-stale: -1
days-before-pr-close: -1
only-labels: 'awaiting feedback'
stale-issue-label: 'stale'
exempt-issue-labels: 'pinned,security,PR pending'
stale-issue-message: >-
This issue has been automatically marked as stale because it has not had recent activity.
It will be closed if no further activity occurs. Thank you for your contributions.

3
.gitignore vendored
View File

@ -5,8 +5,7 @@
.DS_Store
.gradle
*.iml
npm-debug.log
.build.log
*.log
.metadata/
bin/

View File

@ -9,26 +9,29 @@
/bundles/org.openhab.automation.jrubyscripting/ @ccutrer @jimtng
/bundles/org.openhab.automation.jsscripting/ @jpg0 @florian-h05
/bundles/org.openhab.automation.jsscriptingnashorn/ @wborn
/bundles/org.openhab.automation.jythonscripting/ @openhab/add-ons-maintainers
/bundles/org.openhab.automation.jythonscripting/ @HolgerHees
/bundles/org.openhab.automation.pidcontroller/ @fwolter
/bundles/org.openhab.automation.pwm/ @fwolter
/bundles/org.openhab.binding.adorne/ @theiding
/bundles/org.openhab.binding.ahawastecollection/ @soenkekueper
/bundles/org.openhab.binding.airq/ @aurelio1
/bundles/org.openhab.binding.airgradient/ @austvik
/bundles/org.openhab.binding.airq/ @aurelio1 @fwolter
/bundles/org.openhab.binding.airquality/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.airvisualnode/ @3cky
/bundles/org.openhab.binding.alarmdecoder/ @bobadair @billfor
/bundles/org.openhab.binding.allplay/ @dominicdesu
/bundles/org.openhab.binding.amazondashbutton/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.amazonechocontrol/ @mgeramb
/bundles/org.openhab.binding.amberelectric/ @psmedley
/bundles/org.openhab.binding.ambientweather/ @mhilbush
/bundles/org.openhab.binding.amplipi/ @kaikreuzer
/bundles/org.openhab.binding.androiddebugbridge/ @GiviMAD
/bundles/org.openhab.binding.androidtv/ @morph166955
/bundles/org.openhab.binding.anel/ @paphko
/bundles/org.openhab.binding.anthem/ @mhilbush
/bundles/org.openhab.binding.asuswrt/ @wildcs
/bundles/org.openhab.binding.argoclima/ @mbronk
/bundles/org.openhab.binding.astro/ @gerrieg
/bundles/org.openhab.binding.asuswrt/ @wildcs
/bundles/org.openhab.binding.atlona/ @mlobstein
/bundles/org.openhab.binding.autelis/ @digitaldan
/bundles/org.openhab.binding.automower/ @maxpg
@ -47,6 +50,7 @@
/bundles/org.openhab.binding.bluetooth.generic/ @cpmeister
/bundles/org.openhab.binding.bluetooth.govee/ @cpmeister
/bundles/org.openhab.binding.bluetooth.grundfosalpha/ @tisoft
/bundles/org.openhab.binding.bluetooth.hdpowerview/ @andrewfg
/bundles/org.openhab.binding.bluetooth.radoneye/ @petero-dk
/bundles/org.openhab.binding.bluetooth.roaming/ @cpmeister
/bundles/org.openhab.binding.bluetooth.ruuvitag/ @ssalonen
@ -54,6 +58,7 @@
/bundles/org.openhab.binding.boschindego/ @jofleck @jlaur
/bundles/org.openhab.binding.boschshc/ @david-pace @GerdZanker
/bundles/org.openhab.binding.bosesoundtouch/ @marvkis @tratho
/bundles/org.openhab.binding.broadlink/ @dag81
/bundles/org.openhab.binding.broadlinkthermostat/ @flo-02-mu
/bundles/org.openhab.binding.bsblan/ @hypetsch
/bundles/org.openhab.binding.bticinosmarther/ @MrRonfo
@ -77,6 +82,7 @@
/bundles/org.openhab.binding.digitalstrom/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.dlinksmarthome/ @MikeJMajor
/bundles/org.openhab.binding.dmx/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.dolbycp/ @Cybso
/bundles/org.openhab.binding.dominoswiss/ @Friesoch
/bundles/org.openhab.binding.doorbird/ @mhilbush
/bundles/org.openhab.binding.draytonwiser/ @andrew-schofield
@ -91,15 +97,18 @@
/bundles/org.openhab.binding.ecovacs/ @maniac103
/bundles/org.openhab.binding.ecowatt/ @lolodomo
/bundles/org.openhab.binding.ekey/ @hmerk
/bundles/org.openhab.binding.electroluxair/ @jannegpriv
/bundles/org.openhab.binding.electroluxappliance/ @jannegpriv
/bundles/org.openhab.binding.elerotransmitterstick/ @vbier
/bundles/org.openhab.binding.elroconnects/ @mherwege
/bundles/org.openhab.binding.emotiva/ @espenaf
/bundles/org.openhab.binding.energenie/ @hmerk
/bundles/org.openhab.binding.energidataservice/ @jlaur
/bundles/org.openhab.binding.enigma2/ @gdolfen
/bundles/org.openhab.binding.enocean/ @fruggy83
/bundles/org.openhab.binding.enphase/ @Hilbrand
/bundles/org.openhab.binding.entsoe/ @jmelhus
/bundles/org.openhab.binding.enturno/ @klocsson
/bundles/org.openhab.binding.ephemeris/ @clinique
/bundles/org.openhab.binding.epsonprojector/ @mlobstein
/bundles/org.openhab.binding.etherrain/ @dfad1469
/bundles/org.openhab.binding.evcc/ @florian-h05
@ -107,14 +116,19 @@
/bundles/org.openhab.binding.exec/ @kgoderis
/bundles/org.openhab.binding.feed/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.feican/ @Hilbrand
/bundles/org.openhab.binding.fenecon/ @nixoso
/bundles/org.openhab.binding.fineoffsetweatherstation/ @Andy2003
/bundles/org.openhab.binding.flicbutton/ @pfink
/bundles/org.openhab.binding.flume/ @jsjames
/bundles/org.openhab.binding.fmiweather/ @ssalonen
/bundles/org.openhab.binding.folderwatcher/ @goopilot
/bundles/org.openhab.binding.folding/ @fa2k
/bundles/org.openhab.binding.foobot/ @airboxlab @Hilbrand
/bundles/org.openhab.binding.freeathome/ @andrasU
/bundles/org.openhab.binding.freebox/ @lolodomo
/bundles/org.openhab.binding.freeboxos/ @clinique
/bundles/org.openhab.binding.freecurrency/ @J-N-K
/bundles/org.openhab.binding.frenchgovtenergydata/ @clinique
/bundles/org.openhab.binding.fronius/ @trokohl
/bundles/org.openhab.binding.fsinternetradio/ @paphko
/bundles/org.openhab.binding.ftpupload/ @paulianttila
@ -127,8 +141,10 @@
/bundles/org.openhab.binding.gpio/ @nils-bauer
/bundles/org.openhab.binding.gpstracker/ @gbicskei
/bundles/org.openhab.binding.gree/ @markus7017
/bundles/org.openhab.binding.gridbox/ @benediktkuntz
/bundles/org.openhab.binding.groheondus/ @FlorianSW
/bundles/org.openhab.binding.groupepsa/ @arjanmels
/bundles/org.openhab.binding.growatt/ @andrewfg
/bundles/org.openhab.binding.guntamatic/ @MikeTheTux
/bundles/org.openhab.binding.haassohnpelletstove/ @chingon007
/bundles/org.openhab.binding.harmonyhub/ @digitaldan
@ -144,17 +160,19 @@
/bundles/org.openhab.binding.homematic/ @FStolte @gerrieg @mdicke2s
/bundles/org.openhab.binding.homewizard/ @Daniel-42
/bundles/org.openhab.binding.hpprinter/ @cossey
/bundles/org.openhab.binding.http/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.http/ @J-N-K
/bundles/org.openhab.binding.hue/ @cweitkamp @andrewfg
/bundles/org.openhab.binding.huesync/ @pgfeller
/bundles/org.openhab.binding.hydrawise/ @digitaldan
/bundles/org.openhab.binding.hyperion/ @tavalin
/bundles/org.openhab.binding.iammeter/ @lewei50
/bundles/org.openhab.binding.iaqualink/ @digitaldan
/bundles/org.openhab.binding.icalendar/ @daMihe
/bundles/org.openhab.binding.icalendar/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.icloud/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.ihc/ @paulianttila
/bundles/org.openhab.binding.insteon/ @robnielsen
/bundles/org.openhab.binding.insteon/ @jsetton
/bundles/org.openhab.binding.intesis/ @hmerk
/bundles/org.openhab.binding.iotawatt/ @PRosenb
/bundles/org.openhab.binding.ipcamera/ @Skinah
/bundles/org.openhab.binding.ipobserver/ @Skinah
/bundles/org.openhab.binding.ipp/ @peuter
@ -180,6 +198,7 @@
/bundles/org.openhab.binding.lgtvserial/ @fa2k
/bundles/org.openhab.binding.lgwebos/ @sprehn
/bundles/org.openhab.binding.lifx/ @wborn
/bundles/org.openhab.binding.linktap/ @dag81
/bundles/org.openhab.binding.linky/ @clinique @lolodomo
/bundles/org.openhab.binding.linuxinput/ @t-8ch
/bundles/org.openhab.binding.liquidcheck/ @marcelGoerentz
@ -199,9 +218,11 @@
/bundles/org.openhab.binding.mecmeter/ @kaikreuzer
/bundles/org.openhab.binding.melcloud/ @lucacalcaterra @paulianttila @thewiep
/bundles/org.openhab.binding.mercedesme/ @weymann
/bundles/org.openhab.binding.meteoalerte/ @clinique
/bundles/org.openhab.binding.meteoblue/ @9037568
/bundles/org.openhab.binding.meteofrance/ @clinique
/bundles/org.openhab.binding.meteostick/ @cdjackson
/bundles/org.openhab.binding.metofficedatahub/ @dag81
/bundles/org.openhab.binding.mffan/ @mark-brooks-180
/bundles/org.openhab.binding.miele/ @kgoderis @jlaur
/bundles/org.openhab.binding.mielecloud/ @BjoernLange
/bundles/org.openhab.binding.mihome/ @pboos
@ -213,22 +234,26 @@
/bundles/org.openhab.binding.modbus/ @ssalonen
/bundles/org.openhab.binding.modbus.e3dc/ @weymann
/bundles/org.openhab.binding.modbus.helioseasycontrols/ @bern77
/bundles/org.openhab.binding.modbus.kermi/ @KaaNee
/bundles/org.openhab.binding.modbus.sbc/ @fwolter
/bundles/org.openhab.binding.modbus.stiebeleltron/ @pail23
/bundles/org.openhab.binding.modbus.studer/ @giovannimirulla
/bundles/org.openhab.binding.modbus.sungrow/ @soenkekueper
/bundles/org.openhab.binding.modbus.sunspec/ @mrbig
/bundles/org.openhab.binding.monopriceaudio/ @mlobstein
/bundles/org.openhab.binding.mpd/ @stefanroellin
/bundles/org.openhab.binding.mqtt/ @ccutrer
/bundles/org.openhab.binding.mqtt.espmilighthub/ @Skinah
/bundles/org.openhab.binding.mqtt.fpp/ @computergeek1507
/bundles/org.openhab.binding.mqtt.generic/ @ccutrer
/bundles/org.openhab.binding.mqtt.homeassistant/ @antroids @ccutrer
/bundles/org.openhab.binding.mqtt.homie/ @ccutrer
/bundles/org.openhab.binding.mqtt.ruuvigateway/ @ssalonen
/bundles/org.openhab.binding.mycroft/ @dalgwen
/bundles/org.openhab.binding.mybmw/ @ntruchsess @mherwege @martingrassl
/bundles/org.openhab.binding.mycroft/ @dalgwen
/bundles/org.openhab.binding.mynice/ @clinique
/bundles/org.openhab.binding.mystrom/ @pail23
/bundles/org.openhab.binding.myuplink/ @alexf2015
/bundles/org.openhab.binding.nanoleaf/ @stefan-hoehn
/bundles/org.openhab.binding.neato/ @jjlauterbach
/bundles/org.openhab.binding.neeo/ @morph166955
@ -238,7 +263,6 @@
/bundles/org.openhab.binding.network/ @mettke
/bundles/org.openhab.binding.networkupstools/ @Hilbrand
/bundles/org.openhab.binding.nibeheatpump/ @paulianttila
/bundles/org.openhab.binding.nibeuplink/ @alexf2015
/bundles/org.openhab.binding.nikobus/ @crnjan
/bundles/org.openhab.binding.nikohomecontrol/ @mherwege
/bundles/org.openhab.binding.nobohub/ @espenaf
@ -266,8 +290,10 @@
/bundles/org.openhab.binding.orvibo/ @tavalin
/bundles/org.openhab.binding.panasonicbdp/ @mlobstein
/bundles/org.openhab.binding.paradoxalarm/ @theater
/bundles/org.openhab.binding.pegelonline/ @weymann
/bundles/org.openhab.binding.pentair/ @jsjames
/bundles/org.openhab.binding.phc/ @gnlpfjh
/bundles/org.openhab.binding.pihole/ @magx2
/bundles/org.openhab.binding.pilight/ @stefanroellin @niklasdoerfler
/bundles/org.openhab.binding.pioneeravr/ @Stratehm
/bundles/org.openhab.binding.pixometer/ @Confectrician
@ -287,12 +313,13 @@
/bundles/org.openhab.binding.pushsafer/ @appzer @cweitkamp
/bundles/org.openhab.binding.qbus/ @QbusKoen
/bundles/org.openhab.binding.qolsysiq/ @digitaldan
/bundles/org.openhab.binding.radiobrowser/ @skinah
/bundles/org.openhab.binding.radiothermostat/ @mlobstein
/bundles/org.openhab.binding.regoheatpump/ @crnjan
/bundles/org.openhab.binding.revogi/ @andibraeu
/bundles/org.openhab.binding.remoteopenhab/ @lolodomo
/bundles/org.openhab.binding.renault/ @dougculnane
/bundles/org.openhab.binding.resol/ @ramack
/bundles/org.openhab.binding.revogi/ @andibraeu
/bundles/org.openhab.binding.rfxcom/ @martinvw @paulianttila
/bundles/org.openhab.binding.rme/ @kgoderis
/bundles/org.openhab.binding.robonect/ @reyem
@ -300,7 +327,9 @@
/bundles/org.openhab.binding.rotel/ @lolodomo
/bundles/org.openhab.binding.russound/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.sagercaster/ @clinique
/bundles/org.openhab.binding.samsungtv/ @paulianttila
/bundles/org.openhab.binding.saicismart/ @tisoft @dougculnane
/bundles/org.openhab.binding.salus/ @magx2
/bundles/org.openhab.binding.samsungtv/ @NickWaterton
/bundles/org.openhab.binding.satel/ @druciak
/bundles/org.openhab.binding.semsportal/ @itb3
/bundles/org.openhab.binding.senechome/ @vctender @KorbinianP @eguib
@ -311,6 +340,7 @@
/bundles/org.openhab.binding.serial/ @MikeJMajor
/bundles/org.openhab.binding.serialbutton/ @kaikreuzer
/bundles/org.openhab.binding.shelly/ @markus7017
/bundles/org.openhab.binding.siemenshvac/ @lo92fr
/bundles/org.openhab.binding.siemensrds/ @andrewfg
/bundles/org.openhab.binding.silvercrestwifisocket/ @jmvaz
/bundles/org.openhab.binding.sinope/ @chaton78
@ -324,7 +354,9 @@
/bundles/org.openhab.binding.sncf/ @clinique
/bundles/org.openhab.binding.snmp/ @J-N-K
/bundles/org.openhab.binding.solaredge/ @alexf2015
/bundles/org.openhab.binding.solarforecast/ @weymann
/bundles/org.openhab.binding.solarlog/ @johannrichard
/bundles/org.openhab.binding.solarman/ @catalinsanda
/bundles/org.openhab.binding.solarmax/ @jamietownsend
/bundles/org.openhab.binding.solarwatt/ @sven-carstens
/bundles/org.openhab.binding.solax/ @theater
@ -339,6 +371,7 @@
/bundles/org.openhab.binding.speedtest/ @MikeTheTux
/bundles/org.openhab.binding.spotify/ @Hilbrand
/bundles/org.openhab.binding.squeezebox/ @digitaldan @mhilbush
/bundles/org.openhab.binding.sunsynk/ @leeC77
/bundles/org.openhab.binding.surepetcare/ @renescherer @HerzScheisse
/bundles/org.openhab.binding.synopanalyzer/ @clinique
/bundles/org.openhab.binding.systeminfo/ @mherwege
@ -351,6 +384,8 @@
/bundles/org.openhab.binding.teleinfo/ @Nokyyz @olivierkeke
/bundles/org.openhab.binding.tellstick/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.tesla/ @kgoderis
/bundles/org.openhab.binding.teslapowerwall/ @psmedley
/bundles/org.openhab.binding.teslascope/ @psmedley
/bundles/org.openhab.binding.tibber/ @kjoglum
/bundles/org.openhab.binding.tivo/ @mlobstein
/bundles/org.openhab.binding.touchwand/ @roieg
@ -373,8 +408,10 @@
/bundles/org.openhab.binding.verisure/ @jannegpriv
/bundles/org.openhab.binding.vesync/ @dag81
/bundles/org.openhab.binding.vigicrues/ @clinique
/bundles/org.openhab.binding.visualcrossing/ @magx2
/bundles/org.openhab.binding.vitotronic/ @steand
/bundles/org.openhab.binding.vizio/ @mlobstein
/bundles/org.openhab.binding.volumio/ @miloit
/bundles/org.openhab.binding.volvooncall/ @Jamstah
/bundles/org.openhab.binding.warmup/ @jamesmelville
/bundles/org.openhab.binding.weathercompany/ @mhilbush
@ -384,6 +421,7 @@
/bundles/org.openhab.binding.wemo/ @hmerk @jlaur
/bundles/org.openhab.binding.wifiled/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.windcentrale/ @marcelrv @wborn
/bundles/org.openhab.binding.wiz/ @ccutrer @frejos
/bundles/org.openhab.binding.wlanthermo/ @CSchlipp
/bundles/org.openhab.binding.wled/ @Skinah
/bundles/org.openhab.binding.wolfsmartset/ @BoBiene
@ -395,7 +433,6 @@
/bundles/org.openhab.binding.yamahareceiver/ @zarusz
/bundles/org.openhab.binding.yeelight/ @claell
/bundles/org.openhab.binding.yioremote/ @miloit
/bundles/org.openhab.binding.volumio/ @miloit
/bundles/org.openhab.binding.zoneminder/ @mhilbush
/bundles/org.openhab.binding.zway/ @pathec
/bundles/org.openhab.io.homekit/ @andylintner @ccutrer @yfre
@ -411,6 +448,7 @@
/bundles/org.openhab.persistence.mapdb/ @openhab/add-ons-maintainers
/bundles/org.openhab.persistence.mongodb/ @openhab/add-ons-maintainers
/bundles/org.openhab.persistence.rrd4j/ @openhab/add-ons-maintainers
/bundles/org.openhab.transform.basicprofiles/ @J-N-K
/bundles/org.openhab.transform.bin2json/ @paulianttila
/bundles/org.openhab.transform.exec/ @openhab/add-ons-maintainers
/bundles/org.openhab.transform.jinja/ @jochen314
@ -428,11 +466,13 @@
/bundles/org.openhab.voice.marytts/ @kaikreuzer
/bundles/org.openhab.voice.mimictts/ @dalgwen
/bundles/org.openhab.voice.picotts/ @FlorianSW
/bundles/org.openhab.voice.pipertts/ @GiviMAD
/bundles/org.openhab.voice.pollytts/ @openhab/add-ons-maintainers
/bundles/org.openhab.voice.rustpotterks/ @GiviMAD
/bundles/org.openhab.voice.voicerss/ @lolodomo
/bundles/org.openhab.voice.voskstt/ @GiviMAD
/bundles/org.openhab.voice.watsonstt/ @GiviMAD
/bundles/org.openhab.voice.whisperstt/ @GiviMAD
/itests/org.openhab.automation.groovyscripting.tests/ @wborn
/itests/org.openhab.automation.jsscriptingnashorn.tests/ @wborn
/itests/org.openhab.binding.astro.tests/ @gerrieg

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bom</groupId>
<artifactId>org.openhab.addons.reactor.bom</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.addons.bom.openhab-addons</artifactId>
@ -61,6 +61,11 @@
<artifactId>org.openhab.binding.ahawastecollection</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.airgradient</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.airq</artifactId>
@ -96,6 +101,11 @@
<artifactId>org.openhab.binding.amazonechocontrol</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.amberelectric</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.ambientweather</artifactId>
@ -126,6 +136,11 @@
<artifactId>org.openhab.binding.anthem</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.argoclima</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.astro</artifactId>
@ -261,6 +276,11 @@
<artifactId>org.openhab.binding.bosesoundtouch</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.broadlink</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.broadlinkthermostat</artifactId>
@ -376,6 +396,11 @@
<artifactId>org.openhab.binding.dmx</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.dolbycp</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.dominoswiss</artifactId>
@ -448,7 +473,7 @@
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.electroluxair</artifactId>
<artifactId>org.openhab.binding.electroluxappliance</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
@ -461,6 +486,11 @@
<artifactId>org.openhab.binding.elroconnects</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.emotiva</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.energenie</artifactId>
@ -486,11 +516,21 @@
<artifactId>org.openhab.binding.enphase</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.entsoe</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.enturno</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.ephemeris</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.epsonprojector</artifactId>
@ -526,6 +566,11 @@
<artifactId>org.openhab.binding.feican</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.fenecon</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.fineoffsetweatherstation</artifactId>
@ -536,6 +581,11 @@
<artifactId>org.openhab.binding.flicbutton</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.flume</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.fmiweather</artifactId>
@ -556,6 +606,11 @@
<artifactId>org.openhab.binding.foobot</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.freeathome</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.freebox</artifactId>
@ -566,6 +621,16 @@
<artifactId>org.openhab.binding.freeboxos</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.freecurrency</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.frenchgovtenergydata</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.fronius</artifactId>
@ -626,6 +691,11 @@
<artifactId>org.openhab.binding.gree</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.gridbox</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.groheondus</artifactId>
@ -636,6 +706,11 @@
<artifactId>org.openhab.binding.groupepsa</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.growatt</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.guntamatic</artifactId>
@ -721,6 +796,11 @@
<artifactId>org.openhab.binding.hue</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.huesync</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.hydrawise</artifactId>
@ -766,6 +846,11 @@
<artifactId>org.openhab.binding.intesis</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.iotawatt</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.ipcamera</artifactId>
@ -891,6 +976,11 @@
<artifactId>org.openhab.binding.lifx</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.linktap</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.linky</artifactId>
@ -986,21 +1076,31 @@
<artifactId>org.openhab.binding.mercedesme</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.meteoalerte</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.meteoblue</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.meteofrance</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.meteostick</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.metofficedatahub</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.mffan</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.miele</artifactId>
@ -1056,6 +1156,11 @@
<artifactId>org.openhab.binding.modbus.helioseasycontrols</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.modbus.kermi</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.modbus.sbc</artifactId>
@ -1071,6 +1176,11 @@
<artifactId>org.openhab.binding.modbus.studer</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.modbus.sungrow</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.modbus.sunspec</artifactId>
@ -1131,6 +1241,11 @@
<artifactId>org.openhab.binding.mystrom</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.myuplink</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.nanoleaf</artifactId>
@ -1176,11 +1291,6 @@
<artifactId>org.openhab.binding.nibeheatpump</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.nibeuplink</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.nikobus</artifactId>
@ -1316,6 +1426,11 @@
<artifactId>org.openhab.binding.paradoxalarm</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.pegelonline</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.pentair</artifactId>
@ -1326,6 +1441,11 @@
<artifactId>org.openhab.binding.phc</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.pihole</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.pilight</artifactId>
@ -1421,6 +1541,11 @@
<artifactId>org.openhab.binding.qolsysiq</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.radiobrowser</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.radiothermostat</artifactId>
@ -1486,6 +1611,16 @@
<artifactId>org.openhab.binding.sagercaster</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.saicismart</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.salus</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.samsungtv</artifactId>
@ -1541,6 +1676,11 @@
<artifactId>org.openhab.binding.shelly</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.siemenshvac</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.siemensrds</artifactId>
@ -1606,11 +1746,21 @@
<artifactId>org.openhab.binding.solaredge</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.solarforecast</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.solarlog</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.solarman</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.solarmax</artifactId>
@ -1681,6 +1831,11 @@
<artifactId>org.openhab.binding.squeezebox</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.sunsynk</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.surepetcare</artifactId>
@ -1741,6 +1896,16 @@
<artifactId>org.openhab.binding.tesla</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.teslapowerwall</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.teslascope</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.tibber</artifactId>
@ -1851,6 +2016,11 @@
<artifactId>org.openhab.binding.vigicrues</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.visualcrossing</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.vitotronic</artifactId>
@ -1911,6 +2081,11 @@
<artifactId>org.openhab.binding.windcentrale</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.wiz</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.wlanthermo</artifactId>
@ -2041,6 +2216,11 @@
<artifactId>org.openhab.persistence.rrd4j</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.transform.basicprofiles</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.transform.bin2json</artifactId>
@ -2126,6 +2306,11 @@
<artifactId>org.openhab.voice.picotts</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.voice.pipertts</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.voice.pollytts</artifactId>
@ -2151,6 +2336,11 @@
<artifactId>org.openhab.voice.watsonstt</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.voice.whisperstt</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bom</groupId>
<artifactId>org.openhab.addons.reactor.bom</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.addons.bom.openhab-core-index</artifactId>

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons</groupId>
<artifactId>org.openhab.addons.reactor</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<groupId>org.openhab.addons.bom</groupId>
@ -29,7 +29,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>1.8</version>
<version>3.1.0</version>
<inherited>false</inherited>
<executions>
<execution>

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bom</groupId>
<artifactId>org.openhab.addons.reactor.bom</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.addons.bom.runtime-index</artifactId>

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bom</groupId>
<artifactId>org.openhab.addons.reactor.bom</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.addons.bom.test-index</artifactId>

View File

@ -10,7 +10,7 @@ IF %ARGC% NEQ 3 (
exit /B 1
)
SET OpenhabVersion="4.2.0-SNAPSHOT"
SET OpenhabVersion="4.3.0-SNAPSHOT"
SET BindingIdInCamelCase=%~1
SET BindingIdInLowerCase=%BindingIdInCamelCase%

View File

@ -2,7 +2,7 @@
[ $# -lt 3 ] && { echo "Usage: $0 <BindingIdInCamelCase> <Author> <GitHub Username>"; exit 1; }
openHABVersion=4.2.0-SNAPSHOT
openHABVersion=4.3.0-SNAPSHOT
camelcaseId=$1
id=`echo $camelcaseId | tr '[:upper:]' '[:lower:]'`

View File

@ -1,6 +1,6 @@
# Groovy Scripting
This add-on provides support for [Groovy](https://groovy-lang.org/) 4.0.11 that can be used as a scripting language within automation rules and which eliminates the need to manually install Groovy.
This add-on provides support for [Groovy](https://groovy-lang.org/) 4.0.23 that can be used as a scripting language within automation rules and which eliminates the need to manually install Groovy.
## Creating Groovy Scripts
@ -15,12 +15,19 @@ If you create an empty file called `test.groovy`, you will see a log line with i
To enable debug logging, use the [console logging]({{base}}/administration/logging.html) commands to enable debug logging for the automation functionality:
```text
```shell
log:set DEBUG org.openhab.core.automation
```
For more information on the available APIs in scripts see the [JSR223 Scripting]({{base}}/configuration/jsr223.html) documentation.
## Code reuse
One can place *.groovy files with Groovy classes under `automation/groovy` configuration directory.
Those classes can be imported in JSR-223 scripts or the UI rules action with the usual Groovy `import` statement.
To apply shared code changes, one has to restart the `openHAB Core :: Bundles :: Automation` bundle on the Console or an openHAB instance altogether.
## Script Examples
Groovy scripts provide access to almost all the functionality in an openHAB runtime environment.

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.automation.groovyscripting</artifactId>
@ -16,7 +16,7 @@
<properties>
<bnd.importpackage>com.ibm.icu.*;resolution:=optional,groovy.runtime.metaclass;resolution:=optional,groovyjarjarantlr4.stringtemplate;resolution:=optional,org.abego.treelayout.*;resolution:=optional,org.apache.ivy.*;resolution:=optional,org.fusesource.jansi.*;resolution:=optional,org.stringtemplate.v4.*;resolution:=optional</bnd.importpackage>
<groovy.version>4.0.11</groovy.version>
<groovy.version>4.0.23</groovy.version>
</properties>
<dependencies>

View File

@ -0,0 +1,49 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.automation.groovyscripting.internal;
import java.io.File;
import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.control.customizers.CompilationCustomizer;
import org.openhab.core.OpenHAB;
import groovy.lang.GroovyClassLoader;
/**
* Customizes the {@link GroovyClassLoader} so that {@link CompilationCustomizer}s can be added which allows for
* importing additional classes via scopes.
*
* @author Wouter Born - Initial contribution
*/
public class CustomizableGroovyClassLoader extends GroovyClassLoader {
private static final String FILE_DIRECTORY = "automation" + File.separator + "groovy";
private CompilerConfiguration config;
public CustomizableGroovyClassLoader() {
this(CustomizableGroovyClassLoader.class.getClassLoader(), new CompilerConfiguration(), true);
}
public CustomizableGroovyClassLoader(ClassLoader parent, CompilerConfiguration config,
boolean useConfigurationClasspath) {
super(parent, config, useConfigurationClasspath);
this.config = config;
addClasspath(OpenHAB.getConfigFolder() + File.separator + FILE_DIRECTORY);
}
public void addCompilationCustomizers(CompilationCustomizer... customizers) {
config.addCompilationCustomizers(customizers);
}
}

View File

@ -12,22 +12,20 @@
*/
package org.openhab.automation.groovyscripting.internal;
import java.io.File;
import java.util.List;
import java.util.stream.Collectors;
import java.util.Map;
import java.util.stream.Stream;
import javax.script.ScriptEngine;
import org.codehaus.groovy.control.customizers.ImportCustomizer;
import org.codehaus.groovy.jsr223.GroovyScriptEngineImpl;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.OpenHAB;
import org.openhab.core.automation.module.script.AbstractScriptEngineFactory;
import org.openhab.core.automation.module.script.ScriptEngineFactory;
import org.osgi.service.component.annotations.Component;
import groovy.lang.GroovyClassLoader;
/**
* This is an implementation of a {@link ScriptEngineFactory} for Groovy.
*
@ -37,20 +35,11 @@ import groovy.lang.GroovyClassLoader;
@NonNullByDefault
public class GroovyScriptEngineFactory extends AbstractScriptEngineFactory {
private static final String FILE_DIRECTORY = "automation" + File.separator + "groovy";
private final org.codehaus.groovy.jsr223.GroovyScriptEngineFactory factory = new org.codehaus.groovy.jsr223.GroovyScriptEngineFactory();
private final List<String> scriptTypes = (List<String>) Stream.of(factory.getExtensions(), factory.getMimeTypes())
private final List<String> scriptTypes = Stream.of(factory.getExtensions(), factory.getMimeTypes())
.flatMap(List::stream) //
.collect(Collectors.toUnmodifiableList());
private final GroovyClassLoader gcl = new GroovyClassLoader(GroovyScriptEngineFactory.class.getClassLoader());
public GroovyScriptEngineFactory() {
String scriptDir = OpenHAB.getConfigFolder() + File.separator + FILE_DIRECTORY;
logger.debug("Adding script directory {} to the GroovyScriptEngine class path.", scriptDir);
gcl.addClasspath(scriptDir);
}
.toList();
@Override
public List<String> getScriptTypes() {
@ -58,10 +47,32 @@ public class GroovyScriptEngineFactory extends AbstractScriptEngineFactory {
}
@Override
public @Nullable ScriptEngine createScriptEngine(String scriptType) {
if (scriptTypes.contains(scriptType)) {
return new org.codehaus.groovy.jsr223.GroovyScriptEngineImpl(gcl);
public void scopeValues(ScriptEngine scriptEngine, Map<String, Object> scopeValues) {
ImportCustomizer importCustomizer = new ImportCustomizer();
for (Map.Entry<String, Object> entry : scopeValues.entrySet()) {
if (entry.getValue() instanceof Class<?> clazz) {
String canonicalName = clazz.getCanonicalName();
try {
// Only add imports for classes that are available to the classloader
getClass().getClassLoader().loadClass(canonicalName);
importCustomizer.addImport(entry.getKey(), canonicalName);
logger.debug("Added import for {} as {}", entry.getKey(), canonicalName);
} catch (ClassNotFoundException e) {
logger.debug("Unable to add import for {} as {}", entry.getKey(), canonicalName, e);
}
} else {
scriptEngine.put(entry.getKey(), entry.getValue());
}
}
return null;
GroovyScriptEngineImpl gse = (GroovyScriptEngineImpl) scriptEngine;
CustomizableGroovyClassLoader cl = (CustomizableGroovyClassLoader) gse.getClassLoader();
cl.addCompilationCustomizers(importCustomizer);
}
@Override
public @Nullable ScriptEngine createScriptEngine(String scriptType) {
return scriptTypes.contains(scriptType) ? new GroovyScriptEngineImpl(new CustomizableGroovyClassLoader())
: null;
}
}

View File

@ -49,6 +49,7 @@ If you're new to Ruby, you may want to check out [Ruby Basics](https://openhab.g
- [Script is Loaded](#script-is-loaded)
- [openHAB System Started](#openhab-system-started)
- [Cron Trigger](#cron-trigger)
- [DateTimeItem Trigger](#datetimeitem-trigger)
- [Other Triggers](#other-triggers)
- [Combining Multiple Triggers](#combining-multiple-triggers)
- [Combining Multiple Conditions](#combining-multiple-conditions)
@ -60,6 +61,7 @@ If you're new to Ruby, you may want to check out [Ruby Basics](https://openhab.g
- [Terse Rules](#terse-rules)
- [Early Exit From a Rule](#early-exit-from-a-rule)
- [Dynamic Generation of Rules](#dynamic-generation-of-rules)
- [Scenes and Scripts](#scenes-and-scripts)
- [Hooks](#hooks)
- [Calling Java From JRuby](#calling-java-from-jruby)
- [Full Documentation](#full-documentation)
@ -99,7 +101,7 @@ Additional [example rules are available](https://openhab.github.io/openhab-jruby
1. Go to `Settings -> Add-ons -> Automation` and install the jrubyscripting automation addon following the [openHAB instructions](https://www.openhab.org/docs/configuration/addons.html).
In openHAB 4.0+ the defaults are set so the next step can be skipped.
1. Go to `Settings -> Other Services -> JRuby Scripting`:
1. Go to `Settings -> Add-on Settings -> JRuby Scripting`:
- **Ruby Gems**: `openhab-scripting=~>5.0`
- **Require Scripts**: `openhab/dsl` (not required, but recommended)
@ -118,7 +120,7 @@ Additional [example rules are available](https://openhab.github.io/openhab-jruby
## Configuration
After installing this add-on, you will find configuration options in the openHAB portal under _Settings -> Other Services -> JRuby Scripting_.
After installing this add-on, you will find configuration options in the openHAB portal under _Settings -> Add-on Settings -> JRuby Scripting_.
Alternatively, JRuby configuration parameters may be set by creating a `jruby.cfg` file in `conf/services/`.
By default this add-on includes the [openhab-scripting](https://github.com/openhab/openhab-jruby) Ruby gem and automatically `require`s it.
@ -237,7 +239,7 @@ logger.info("Kitchen Light State: #{KitchenLight.state}")
Sending a notification:
```ruby
notify("romeo@montague.org", "Balcony door is open")
Notification.send("romeo@montague.org", "Balcony door is open")
```
Querying the status of a thing:
@ -262,12 +264,13 @@ When you use "Item event" as trigger (i.e. "[item] received a command", "[item]
This tables gives an overview of the `event` object for most common trigger types.
For full details, explore [OpenHAB::Core::Events](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Events.html).
| Property Name | Type | Trigger Types | Description | Rules DSL Equivalent |
| ------------- | -------------------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------- | ---------------------- |
| `state` | [State](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Types/State.html) or `nil` | `[item] changed`, `[item] was updated` | State that triggered event | `triggeringItem.state` |
| `was` | [State](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Types/State.html) or `nil` | `[item] changed` | Previous state of Item or Group that triggered event | `previousState` |
| `command` | [Command](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Types/Command.html) | `[item] received a command` | Command that triggered event | `receivedCommand` |
| `item` | [Item](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Items/Item.html) | all | Item that triggered event | `triggeringItem` |
| Property Name | Type | Trigger Types | Description | Rules DSL Equivalent |
| ------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------- |
| `state` | [State](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Types/State.html) or `nil` | `[item] changed`, `[item] was updated` | State that triggered event | `triggeringItem.state` |
| `was` | [State](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Types/State.html) or `nil` | `[item] changed` | Previous state of Item or Group that triggered event | `previousState` |
| `command` | [Command](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Types/Command.html) | `[item] received a command` | Command that triggered event | `receivedCommand` |
| `item` | [Item](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Items/Item.html) | All item related triggers | Item that triggered event | `triggeringItem` |
| `group` | [GroupItem](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/Items/Item.html) | `Member of [group] changed`, `Member of [group] was updated`, `Member of [group] received a command` | Group whose member triggered the event | `triggeringGroup` |
```ruby
logger.info(event.state == ON)
@ -473,6 +476,9 @@ My_Item.ensure << ON
logger.info("Turning off the light") if My_Item.ensure.off
```
See [ensure_states](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL.html#ensure_states-class_method), [ensure_states!](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL.html#ensure_states!-class_method),
[ensure](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL/Items/Ensure/Ensurable.html#ensure-instance_method).
##### Timed Commands
A [Timed Command](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL/Items/TimedCommand.html) is similar to the openHAB Item's [expire parameter](https://www.openhab.org/docs/configuration/items.html#parameter-expire) but it offers more flexibility.
@ -664,7 +670,7 @@ items.build do
# dimension Temperature inferred
number_item OutdoorTemp, format: "%.1f %unit%", unit: "°F"
# unit lx, dimension Illuminance, format "%s %unit%" inferred
number_item OutdoorBrightness, state: 10_000 | "lx"
end
@ -911,7 +917,7 @@ Furthermore, you can manipulate the managed timers using the built-in [timers](h
```ruby
# timers is a special object to access the timers created with an id
rule "cancel all timers" do
received_command Cancel_All_Timers, to: ON # Send a command to this item to cancel all timers
received_command Cancel_All_Timers, command: ON # Send a command to this item to cancel all timers
run do
gOutdoorLights.members.each do |item_as_timer_id|
timers.cancel(item_as_timer_id)
@ -920,7 +926,7 @@ rule "cancel all timers" do
end
rule "reschedule all timers" do
received_command Reschedule_All_Timers, to: ON # Send a command to this item to restart all timers
received_command Reschedule_All_Timers, command: ON # Send a command to this item to restart all timers
run do
gOutdoorLights.members.each do |item_as_timer_id|
timers.reschedule(item_as_timer_id)
@ -1148,7 +1154,7 @@ Time.now.between?("5am".."11pm")
Time.now.holiday? # => false
MonthDay.parse("12-25").holiday # => :christmas
1.day.from_now.next_holiday # => :thanksgiving
notify("It's #{Ephemeris.holiday_name(Date.today)}!") if Date.today.holiday?
Notification.send("It's #{Ephemeris.holiday_name(Date.today)}!") if Date.today.holiday?
Date.today.weekend? # => true
Date.today.in_dayset?(:school) # => false
@ -1462,9 +1468,9 @@ See [#updated](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL/Rules/Bu
```ruby
rule "Received a command" do
received_command DoorBell, to: ON
received_command DoorBell, command: ON
run do |event|
notify "Someone pressed the door bell"
Notification.send "Someone pressed the door bell"
play_sound "doorbell.mp3"
end
end
@ -1556,6 +1562,30 @@ end
See [#every](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL/Rules/BuilderDSL.html#every-instance_method)
#### DateTimeItem Trigger
To trigger based on the date and time stored in a DateTime item, use [at ItemName](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL/Rules/BuilderDSL.html#at-instance_method):
```ruby
rule "DateTime Trigger" do
at My_DateTimeItem
run do |event|
logger.info "Triggered by #{event.item} at #{event.item.state}"
end
end
```
To trigger based on only the _time_ part of a DateTime item, use [every :day, at: ItemName](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL/Rules/BuilderDSL.html#every-instance_method):
```ruby
rule "TimeOnly Trigger" do
every :day, at: My_DateTimeItem
run do |event|
logger.info "Triggered by #{event.item} at #{event.item.state}"
end
end
```
#### Other Triggers
There are more triggers supported by this library.
@ -1656,7 +1686,7 @@ rule "Check for offline things 15 minutes after openHAB had started" do
delay 15.minutes
run do
offline_things = things.select(&:offline?).map(&:uid).join(", ")
notify("Things that are still offline: #{offline_things}")
Notification.send("Things that are still offline: #{offline_things}")
end
end
```
@ -1668,7 +1698,7 @@ See [Execution Blocks](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL/
A rule with a trigger and an execution block can be created with just one line.
```ruby
received_command(My_Switch, to: ON) { My_Light.on }
received_command(My_Switch, command: ON) { My_Light.on }
```
See [Terse Rules](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL/Rules/Terse.html) for full details.
@ -1740,6 +1770,24 @@ virtual_switches.each do |switch|
end
```
### Scenes and Scripts
A `scene` can be created using the [.scene](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL.html#scene-class_method) method.
```ruby
scene "Movie", id: "movie", description: "Set up the theatre for movie watching" do
Theatre_Window_Blinds.down
Theatre_Screen_Curtain.up
Theatre_Mood_Light.on
Theatre_Light.off
Theatre_Projector.on
Theatre_Receiver.on
end
```
To create a `script`, use the [.script](https://openhab.github.io/openhab-jruby/main/OpenHAB/DSL.html#script-class_method) method.
Note that scripts can be executed with additional contexts.
### Hooks
File based scripts can also register [hooks](https://openhab.github.io/openhab-jruby/main/OpenHAB/Core/ScriptHandling.html) that will be called when the script has completed loading (`script_loaded`) and when it gets unloaded (`script_unloaded`).

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.automation.jrubyscripting</artifactId>
@ -15,8 +15,8 @@
<name>openHAB Add-ons :: Bundles :: Automation :: JRuby Scripting</name>
<properties>
<bnd.importpackage>com.sun.nio.*;resolution:=optional,com.sun.security.*;resolution:=optional,org.apache.tools.ant.*;resolution:=optional,org.bouncycastle.*;resolution:=optional,org.joda.*;resolution:=optional,sun.management.*;resolution:=optional,sun.nio.*;resolution:=optional,jakarta.annotation;resolution:=optional</bnd.importpackage>
<jruby.version>9.4.5.0</jruby.version>
<bnd.importpackage>com.sun.nio.*;resolution:=optional,com.sun.security.*;resolution:=optional,org.apache.tools.ant.*;resolution:=optional,org.bouncycastle.*;resolution:=optional,org.joda.*;resolution:=optional,sun.management.*;resolution:=optional,sun.nio.*;resolution:=optional,jakarta.annotation;resolution:=optional,jdk.crac.management;resolution:=optional</bnd.importpackage>
<jruby.version>9.4.9.0</jruby.version>
</properties>
<dependencies>

View File

@ -0,0 +1,64 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.automation.jrubyscripting.internal;
import java.util.Objects;
import javax.script.CompiledScript;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* This is a wrapper for {@link CompiledScript}.
*
* The purpose of this class is to intercept the call to eval and save the context into
* a global variable for use in the helper library.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class JRubyCompiledScriptWrapper extends CompiledScript {
private final CompiledScript compiledScript;
private static final String CONTEXT_VAR_NAME = "ctx";
private static final String GLOBAL_VAR_NAME = "$" + CONTEXT_VAR_NAME;
JRubyCompiledScriptWrapper(CompiledScript compiledScript) {
this.compiledScript = Objects.requireNonNull(compiledScript);
}
@Override
public Object eval(@Nullable ScriptContext context) throws ScriptException {
Object ctx = Objects.requireNonNull(context).getBindings(ScriptContext.ENGINE_SCOPE).get(CONTEXT_VAR_NAME);
if (ctx == null) {
return compiledScript.eval(context);
}
context.setAttribute(GLOBAL_VAR_NAME, ctx, ScriptContext.ENGINE_SCOPE);
try {
return compiledScript.eval(context);
} finally {
context.removeAttribute(GLOBAL_VAR_NAME, ScriptContext.ENGINE_SCOPE);
}
}
@Override
public ScriptEngine getEngine() {
return compiledScript.getEngine();
}
}

View File

@ -50,12 +50,12 @@ public class JRubyEngineWrapper implements Compilable, Invocable, ScriptEngine {
@Override
public CompiledScript compile(@Nullable String script) throws ScriptException {
return engine.compile(script);
return new JRubyCompiledScriptWrapper(engine.compile(script));
}
@Override
public CompiledScript compile(@Nullable Reader reader) throws ScriptException {
return engine.compile(reader);
return new JRubyCompiledScriptWrapper(engine.compile(reader));
}
@Override

View File

@ -379,7 +379,7 @@ public class JRubyScriptEngineConfiguration {
private final String defaultValue;
private final Optional<String> mappedTo;
private final Type type;
private Optional<String> value;
private @Nullable String value;
private OptionalConfigurationElement(String defaultValue) {
this(Type.OTHER, defaultValue, null);
@ -389,19 +389,19 @@ public class JRubyScriptEngineConfiguration {
this.type = type;
this.defaultValue = defaultValue;
this.mappedTo = Optional.ofNullable(mappedTo);
value = Optional.empty();
}
private String getValue() {
return value.orElse(defaultValue);
String value = this.value;
return value != null ? value : this.defaultValue;
}
private void setValue(String value) {
this.value = Optional.of(value);
private void setValue(@Nullable String value) {
this.value = value;
}
private void clearValue() {
this.value = Optional.empty();
this.value = null;
}
private Optional<String> mappedTo() {

View File

@ -26,7 +26,7 @@
<parameter name="gems" type="text" required="false" groupName="gems">
<label>Ruby Gems</label>
<description><![CDATA[A comma separated list of Ruby Gems to install. Versions may be constrained by separating with an
<tt>=</tt> and then the standard RubyGems version constraint, such as "<tt>openhab-scripting=~>5.0</tt>".
<tt>=</tt> and then the standard RubyGems version constraint, such as "<tt>openhab-scripting=~>5.0</tt>".
]]></description>
<default>openhab-scripting=~>5.0.0</default>
</parameter>

View File

@ -1,33 +0,0 @@
automation.config.jruby.check_update.label = Check for Gem Updates
automation.config.jruby.check_update.description = Check RubyGems for updates to the above gems when OpenHAB starts or JRuby settings are changed. Otherwise it will try to fulfill the requirements with locally installed gems, and you can manage them yourself with an external Ruby by setting the same GEM_HOME.
automation.config.jruby.dependency_tracking.label = Enable Dependency Tracking
automation.config.jruby.dependency_tracking.description = Dependency tracking allows your scripts to automatically reload when one of its dependencies is updated. You may want to disable dependency tracking if you plan on editing or updating a shared library, but don't want all your scripts to reload until you can test it.
automation.config.jruby.gem_home.label = GEM_HOME
automation.config.jruby.gem_home.description = Location Ruby Gems will be installed to and loaded from. Directory will be created if necessary. You can use <tt>{RUBY_ENGINE_VERSION}</tt>, <tt>{RUBY_ENGINE}</tt> and/or <tt>{RUBY_VERSION}</tt> replacements in this value to automatically point to a new directory when the addon is updated with a new version of JRuby. Defaults to "<tt>OPENHAB_CONF/automation/ruby/.gem/{RUBY_ENGINE_VERSION}</tt>" when not specified.
automation.config.jruby.gems.label = Ruby Gems
automation.config.jruby.gems.description = A comma separated list of Ruby Gems to install. Versions may be constrained by separating with an <tt>=</tt> and then the standard RubyGems version constraint, such as "<tt>openhab-scripting=~>5.0</tt>".
automation.config.jruby.group.environment.label = Ruby Environment
automation.config.jruby.group.environment.description = This group defines Ruby's environment.
automation.config.jruby.group.gems.label = Ruby Gems
automation.config.jruby.group.gems.description = This group defines the list of Ruby Gems to install.
automation.config.jruby.group.system.label = System Properties
automation.config.jruby.group.system.description = This group defines JRuby system properties.
automation.config.jruby.local_context.label = Context Instance Type
automation.config.jruby.local_context.description = The local context holds Ruby runtime, name-value pairs for sharing variables between Java and Ruby. See <a href="https://github.com/jruby/jruby/wiki/RedBridge#Context_Instance_Type">the documentation</a> for options and details.
automation.config.jruby.local_context.option.singleton = Singleton
automation.config.jruby.local_context.option.threadsafe = ThreadSafe
automation.config.jruby.local_context.option.singlethread = SingleThread
automation.config.jruby.local_context.option.concurrent = Concurrent
automation.config.jruby.local_variable.label = Local Variable Behavior
automation.config.jruby.local_variable.description = Defines how variables are shared between Ruby and Java. See <a href="https://github.com/jruby/jruby/wiki/RedBridge#local-variable-behavior-options">the documentation</a> for options and details.
automation.config.jruby.local_variable.option.transient = Transient
automation.config.jruby.local_variable.option.persistent = Persistent
automation.config.jruby.local_variable.option.global = Global
automation.config.jruby.require.label = Require Scripts
automation.config.jruby.require.description = A comma separated list of script names to be required by the JRuby Scripting Engine before running user scripts.
automation.config.jruby.rubylib.label = RUBYLIB
automation.config.jruby.rubylib.description = Search path for user libraries. Separate each path with a colon (semicolon in Windows). Defaults to "<tt>OPENHAB_CONF/automation/ruby/lib</tt>" when not specified.
# service
service.automation.jrubyscripting.label = JRuby Scripting

View File

@ -1,33 +0,0 @@
automation.config.jruby.check_update.label = Nach Gem Updates suchen
automation.config.jruby.check_update.description = Prüfen Sie RubyGems auf Aktualisierungen der oben genannten Gems, wenn OpenHAB startet oder JRuby-Einstellungen geändert werden. Andernfalls wird versucht, die Anforderungen mit lokal installierten Gems zu erfüllen. Sie können auch ein eigenes, externes Ruby einsetzen, indem Sie dasselbe GEM_HOME definieren.
automation.config.jruby.dependency_tracking.label = Abhängigkeitsverfolgung aktivieren
automation.config.jruby.dependency_tracking.description = Die Abhängigkeitsverfolgung ermöglicht es Ihren Skripten, automatisch neu zu laden, wenn eine ihrer Abhängigkeiten aktualisiert wird. Sie können die Abhängigkeitsverfolgung deaktivieren, wenn Sie eine gemeinsam genutzte Bibliothek bearbeiten oder aktualisieren möchten, aber nicht wollen, dass alle Skripte neu geladen werden, bis Sie sie testen können.
automation.config.jruby.gem_home.label = GEM_HOME
automation.config.jruby.gem_home.description = Speicherort Ruby Gems wird installiert und von dort geladen. Das Verzeichnis wird bei Bedarf erstellt. Sie können die Ersetzungen <tt>{RUBY_ENGINE_VERSION}</tt>, <tt>{RUBY_ENGINE}</tt> und/oder <tt>{RUBY_VERSION}</tt> in diesem Wert verwenden, um automatisch auf ein neues Verzeichnis zu verweisen, wenn das Addon wird mit einer neuen Version von JRuby aktualisiert. Standardmäßig „<tt>OPENHAB_CONF/automation/ruby/.gem/{RUBY_ENGINE_VERSION}</tt>“, wenn nicht angegeben.
automation.config.jruby.gems.label = Ruby Gems
automation.config.jruby.gems.description = Eine kommagetrennte Liste von zu installierenden Ruby Gems. Versionen können eingeschränkt werden, indem sie von <tt>\=</tt> getrennt werden, und dann die Standard RubyGems Versionsbeschränkung, wie "<tt>openhab-scripting\=~>5.0.0</tt>".
automation.config.jruby.group.environment.label = Ruby-Umgebung
automation.config.jruby.group.environment.description = Diese Gruppe definiert die Ruby Umgebung.
automation.config.jruby.group.gems.label = Ruby Gems
automation.config.jruby.group.gems.description = Diese Gruppe definiert die Liste der zu installierenden Ruby Gems.
automation.config.jruby.group.system.label = Systemeigenschaften
automation.config.jruby.group.system.description = Diese Gruppe definiert JRuby Systemeigenschaften.
automation.config.jruby.local_context.label = Context Instance Type
automation.config.jruby.local_context.description = Der lokale Kontext enthält Ruby Laufzeitumgebung, Namens-Wert-Paare für das Teilen von Variablen zwischen Java und Ruby. Siehe <a href\="https\://github.com/jruby/jruby/wiki/RedBridge\#Context_Instance_Type">die Dokumentation</a> für Optionen und Details.
automation.config.jruby.local_context.option.singleton = Singleton
automation.config.jruby.local_context.option.threadsafe = ThreadSafe
automation.config.jruby.local_context.option.singlethread = SingleThread
automation.config.jruby.local_context.option.concurrent = Concurrent
automation.config.jruby.local_variable.label = Lokales Variablenverhalten
automation.config.jruby.local_variable.description = Legt fest, wie Variablen zwischen Ruby und Java geteilt werden. Siehe <a href\="https\://github.com/jruby/jruby/wiki/RedBridge\#local-variable-behavior-options">die Dokumentation</a> für Optionen und Details.
automation.config.jruby.local_variable.option.transient = Transient
automation.config.jruby.local_variable.option.persistent = Persistent
automation.config.jruby.local_variable.option.global = Global
automation.config.jruby.require.label = Skripte anfordern
automation.config.jruby.require.description = Eine durch Kommata getrennte Liste von Skriptnamen, die von der JRuby Scripting Engine verlangt werden, bevor Benutzerskripte ausgeführt werden.
automation.config.jruby.rubylib.label = RUBYLIB
automation.config.jruby.rubylib.description = Suchpfad für Benutzerbibliotheken. Trennen Sie jeden Pfad mit einem Doppelpunkt (Semikolon unter Windows). Standardmäßig "<tt>OPENHAB_CONF/automation/ruby/lib</tt>" wenn nicht angegeben.
# service
service.automation.jrubyscripting.label = JRuby Scripting

View File

@ -1,30 +0,0 @@
# service
service.automation.jrubyscripting.label = JRuby szkriptek
# bundle config
automation.config.jruby.gem_home.label = GEM_HOME
automation.config.jruby.gem_home.description = A Ruby Gems (gyémántok) telepítési és betöltési helye, ha a gyémántok telepítése engedélyezve van. A mappa automatikusan létrehozásra kerül
automation.config.jruby.gems.label = Ruby gyémántok
automation.config.jruby.gems.description = Vesszővel elválasztott lista a telepítendő gyémántokról.
automation.config.jruby.group.environment.label = Ruby környezet
automation.config.jruby.group.environment.description = Ez a csoport adja meg a Ruby környezetet.
automation.config.jruby.group.gems.label = Ruby gyémántok
automation.config.jruby.group.gems.description = Ez a csoport adja meg a Ruby gyémántok telepítendő listáját.
automation.config.jruby.group.system.label = Rendszer tulajdonságok
automation.config.jruby.group.system.description = Ez a csoport adja meg a JRuby rendszer tulajdonságokat.
automation.config.jruby.local_context.label = A kontextus példány típusa
automation.config.jruby.local_context.description = A helyi kontextus tartalmazza a Ruby futtatási környezetét, név-érték párjait a Java és Ruby közti változók átadásához. A lehetőségek és részletek megtekintéséhez nyissa meg a következő linket\: https\://github.com/jruby/jruby/wiki/RedBridge\#Context_Instance_Type.
automation.config.jruby.local_context.option.singleton = Egyke (singleton)
automation.config.jruby.local_context.option.threadsafe = Szálbiztos
automation.config.jruby.local_context.option.singlethread = Egyszálú
automation.config.jruby.local_context.option.concurrent = Párhuzamos
automation.config.jruby.local_variable.label = Helyi változó viselkedés
automation.config.jruby.local_variable.description = Megadja, hogy a változók átadása a Ruby és a Java környezet között miként történjen. Részletek\: https\://github.com/jruby/jruby/wiki/RedBridge\#local-variable-behavior-options.
automation.config.jruby.local_variable.option.transient = Átmeneti
automation.config.jruby.local_variable.option.persistent = Tartós
automation.config.jruby.local_variable.option.global = Teljes körű
automation.config.jruby.rubylib.label = RUBYLIB
automation.config.jruby.rubylib.description = A felhasználói könyvtárak keresési útvonala. Elválasztó a kettőspont (Pontosvessző Windows alatt).

View File

@ -1,33 +0,0 @@
automation.config.jruby.check_update.label = Controlla per Aggiornamenti di Gem
automation.config.jruby.check_update.description = Controllare RubyGems per gli aggiornamenti alle gem di cui sopra quando OpenHAB inizia o le impostazioni JRuby vengono modificate. Altrimenti proverà a soddisfare i requisiti con gem installate localmente, e puoi gestirli da soli con un Ruby esterno impostando lo stesso GEM_HOME.
automation.config.jruby.dependency_tracking.label = Abilita Monitoraggio Delle Dipendenze
automation.config.jruby.dependency_tracking.description = Il monitoraggio delle dipendenze consente agli script di ricaricare automaticamente quando una delle sue dipendenze viene aggiornata. Si consiglia di disabilitare il tracciamento delle dipendenze se si prevede di modificare o aggiornare una libreria condivisa, ma non vuoi che tutti gli script siano ricaricati fino a quando non puoi provarlo.
automation.config.jruby.gem_home.label = GEM_HOME
automation.config.jruby.gem_home.description = Il percorso da dove le Ruby Gems saranno installate e caricate. La directory verrà creata se necessario. È possibile utilizare i valori <tt>{RUBY_ENGINE_VERSION}</tt>, <tt>{RUBY_ENGINE}</tt> e/o <tt>{RUBY_VERSION}</tt> come sostituzioni per puntare automaticamente a una nuova directory quando l''addon viene aggiornato con una nuova versione di JRuby. Il valore predefinito è "<tt>OPENHAB_CONF/automation/ruby/.gem/{RUBY_ENGINE_VERSION}</tt>" se non specificato.
automation.config.jruby.gems.label = Ruby Gems
automation.config.jruby.gems.description = Un elenco separato da virgola di Ruby Gems da installare. Le versioni possono essere vincolate separando con un <tt>\=</tt> e quindi il vincolo di versione standard di RubyGems, come "<tt>openhab-scripting\=~>5.</tt>".
automation.config.jruby.group.environment.label = Ambiente Di Ruby
automation.config.jruby.group.environment.description = Questo gruppo definisce l'ambiente di Ruby.
automation.config.jruby.group.gems.label = Ruby Gems
automation.config.jruby.group.gems.description = Questo gruppo definisce la lista di Ruby Gems da installare.
automation.config.jruby.group.system.label = Proprietà di sistema
automation.config.jruby.group.system.description = Questo gruppo definisce le proprietà del sistema JRuby.
automation.config.jruby.local_context.label = Tipo Context Instance
automation.config.jruby.local_context.description = Il contesto locale contiene le coppie di runtime Ruby, none-valore per la condivisione delle variabili tra Java e Ruby. Vedere <a href\="https\://github.com/jruby/jruby/wiki/RedBridge\#Context_Instance_Type">la documentazione</a> per le opzioni e i dettagli.
automation.config.jruby.local_context.option.singleton = Singleton
automation.config.jruby.local_context.option.threadsafe = Thread Sicura
automation.config.jruby.local_context.option.singlethread = Thread Singola
automation.config.jruby.local_context.option.concurrent = Concorrente
automation.config.jruby.local_variable.label = Comportamento Variabile Locale
automation.config.jruby.local_variable.description = Definisce come le variabili sono condivise tra Ruby e Java. Vedi <a href\="https\://github.com/jruby/jruby/wiki/RedBridge\#local-variable-behavior-options">la documentazione</a> per le opzioni e i dettagli.
automation.config.jruby.local_variable.option.transient = Transitorio
automation.config.jruby.local_variable.option.persistent = Persistente
automation.config.jruby.local_variable.option.global = Globale
automation.config.jruby.require.label = Richiede Scripts
automation.config.jruby.require.description = Un elenco separato da virgola di nomi di script che devono essere richiesti dal motore di scripting JRuby prima di eseguire gli script utente.
automation.config.jruby.rubylib.label = RUBYLIB
automation.config.jruby.rubylib.description = Percorso di ricerca per le librerie utente. Separare ogni percorso con due punti (con un punto e virgola in Windows). Predefinito "<tt>OPENHAB_CONF/automation/ruby/lib</tt>" se non specificato.
# service
service.automation.jrubyscripting.label = JRuby Scripting

View File

@ -0,0 +1,36 @@
# add-on
addon.jrubyscripting.name = JRuby Scripting
addon.jrubyscripting.description = This adds a JRuby script engine.
# add-on
automation.config.jrubyscripting.check_update.label = Check for Gem Updates
automation.config.jrubyscripting.check_update.description = Check RubyGems for updates to the above gems when OpenHAB starts or JRuby settings are changed. Otherwise it will try to fulfill the requirements with locally installed gems, and you can manage them yourself with an external Ruby by setting the same GEM_HOME.
automation.config.jrubyscripting.dependency_tracking.label = Enable Dependency Tracking
automation.config.jrubyscripting.dependency_tracking.description = Dependency tracking allows your scripts to automatically reload when one of its dependencies is updated. You may want to disable dependency tracking if you plan on editing or updating a shared library, but don't want all your scripts to reload until you can test it.
automation.config.jrubyscripting.gem_home.label = GEM_HOME
automation.config.jrubyscripting.gem_home.description = Location Ruby Gems will be installed to and loaded from. Directory will be created if necessary. You can use <tt>{RUBY_ENGINE_VERSION}</tt>, <tt>{RUBY_ENGINE}</tt> and/or <tt>{RUBY_VERSION}</tt> replacements in this value to automatically point to a new directory when the addon is updated with a new version of JRuby. Defaults to "<tt>OPENHAB_CONF/automation/ruby/.gem/{RUBY_ENGINE_VERSION}</tt>" when not specified.
automation.config.jrubyscripting.gems.label = Ruby Gems
automation.config.jrubyscripting.gems.description = A comma separated list of Ruby Gems to install. Versions may be constrained by separating with an <tt>=</tt> and then the standard RubyGems version constraint, such as "<tt>openhab-scripting=~>5.0</tt>".
automation.config.jrubyscripting.group.environment.label = Ruby Environment
automation.config.jrubyscripting.group.environment.description = This group defines Ruby's environment.
automation.config.jrubyscripting.group.gems.label = Ruby Gems
automation.config.jrubyscripting.group.gems.description = This group defines the list of Ruby Gems to install.
automation.config.jrubyscripting.group.system.label = System Properties
automation.config.jrubyscripting.group.system.description = This group defines JRuby system properties.
automation.config.jrubyscripting.local_context.label = Context Instance Type
automation.config.jrubyscripting.local_context.description = The local context holds Ruby runtime, name-value pairs for sharing variables between Java and Ruby. See <a href="https://github.com/jruby/jruby/wiki/RedBridge#Context_Instance_Type">the documentation</a> for options and details.
automation.config.jrubyscripting.local_context.option.singleton = Singleton
automation.config.jrubyscripting.local_context.option.threadsafe = ThreadSafe
automation.config.jrubyscripting.local_context.option.singlethread = SingleThread
automation.config.jrubyscripting.local_context.option.concurrent = Concurrent
automation.config.jrubyscripting.local_variable.label = Local Variable Behavior
automation.config.jrubyscripting.local_variable.description = Defines how variables are shared between Ruby and Java. See <a href="https://github.com/jruby/jruby/wiki/RedBridge#local-variable-behavior-options">the documentation</a> for options and details.
automation.config.jrubyscripting.local_variable.option.transient = Transient
automation.config.jrubyscripting.local_variable.option.persistent = Persistent
automation.config.jrubyscripting.local_variable.option.global = Global
automation.config.jrubyscripting.require.label = Require Scripts
automation.config.jrubyscripting.require.description = A comma separated list of script names to be required by the JRuby Scripting Engine before running user scripts.
automation.config.jrubyscripting.rubylib.label = RUBYLIB
automation.config.jrubyscripting.rubylib.description = Search path for user libraries. Separate each path with a colon (semicolon in Windows). Defaults to "<tt>OPENHAB_CONF/automation/ruby/lib</tt>" when not specified.

View File

@ -0,0 +1,36 @@
# add-on
addon.jrubyscripting.name = JRuby Scripting
addon.jrubyscripting.description = Questo aggiunge un motore script JRuby.
# add-on
automation.config.jrubyscripting.check_update.label = Controlla per Aggiornamenti di Gem
automation.config.jrubyscripting.check_update.description = Controllare RubyGems per gli aggiornamenti alle gem di cui sopra quando OpenHAB inizia o le impostazioni JRuby vengono modificate. Altrimenti proverà a soddisfare i requisiti con gem installate localmente, e puoi gestirli da soli con un Ruby esterno impostando lo stesso GEM_HOME.
automation.config.jrubyscripting.dependency_tracking.label = Abilita Monitoraggio Delle Dipendenze
automation.config.jrubyscripting.dependency_tracking.description = Il monitoraggio delle dipendenze consente agli script di ricaricare automaticamente quando una delle sue dipendenze viene aggiornata. Si consiglia di disabilitare il tracciamento delle dipendenze se si prevede di modificare o aggiornare una libreria condivisa, ma non vuoi che tutti gli script siano ricaricati fino a quando non puoi provarlo.
automation.config.jrubyscripting.gem_home.label = GEM_HOME
automation.config.jrubyscripting.gem_home.description = Il percorso da dove le Ruby Gems saranno installate e caricate. La directory verrà creata se necessario. È possibile utilizare i valori <tt>{RUBY_ENGINE_VERSION}</tt>, <tt>{RUBY_ENGINE}</tt> e/o <tt>{RUBY_VERSION}</tt> come sostituzioni per puntare automaticamente a una nuova directory quando l''addon viene aggiornato con una nuova versione di JRuby. Il valore predefinito è "<tt>OPENHAB_CONF/automation/ruby/.gem/{RUBY_ENGINE_VERSION}</tt>" se non specificato.
automation.config.jrubyscripting.gems.label = Ruby Gems
automation.config.jrubyscripting.gems.description = Un elenco separato da virgola di Ruby Gems da installare. Le versioni possono essere vincolate separando con un <tt>\\\=</tt> e quindi il vincolo di versione standard di RubyGems, come "<tt>openhab-scripting\\\=~>5.</tt>".
automation.config.jrubyscripting.group.environment.label = Ambiente Di Ruby
automation.config.jrubyscripting.group.environment.description = Questo gruppo definisce l'ambiente di Ruby.
automation.config.jrubyscripting.group.gems.label = Ruby Gems
automation.config.jrubyscripting.group.gems.description = Questo gruppo definisce la lista di Ruby Gems da installare.
automation.config.jrubyscripting.group.system.label = Proprietà di sistema
automation.config.jrubyscripting.group.system.description = Questo gruppo definisce le proprietà del sistema JRuby.
automation.config.jrubyscripting.local_context.label = Tipo Context Instance
automation.config.jrubyscripting.local_context.description = Il contesto locale contiene le coppie di runtime Ruby, none-valore per la condivisione delle variabili tra Java e Ruby. Vedere <a href\\\="https\\\://github.com/jruby/jruby/wiki/RedBridge\\\#Context_Instance_Type">la documentazione</a> per le opzioni e i dettagli.
automation.config.jrubyscripting.local_context.option.singleton = Singleton
automation.config.jrubyscripting.local_context.option.threadsafe = Thread Sicura
automation.config.jrubyscripting.local_context.option.singlethread = Thread Singola
automation.config.jrubyscripting.local_context.option.concurrent = Concorrente
automation.config.jrubyscripting.local_variable.label = Comportamento Variabile Locale
automation.config.jrubyscripting.local_variable.description = Definisce come le variabili sono condivise tra Ruby e Java. Vedi <a href\\\="https\\\://github.com/jruby/jruby/wiki/RedBridge\\\#local-variable-behavior-options">la documentazione</a> per le opzioni e i dettagli.
automation.config.jrubyscripting.local_variable.option.transient = Transitorio
automation.config.jrubyscripting.local_variable.option.persistent = Persistente
automation.config.jrubyscripting.local_variable.option.global = Globale
automation.config.jrubyscripting.require.label = Richiede Scripts
automation.config.jrubyscripting.require.description = Un elenco separato da virgola di nomi di script che devono essere richiesti dal motore di scripting JRuby prima di eseguire gli script utente.
automation.config.jrubyscripting.rubylib.label = RUBYLIB
automation.config.jrubyscripting.rubylib.description = Percorso di ricerca per le librerie utente. Separare ogni percorso con due punti (con un punto e virgola in Windows). Predefinito "<tt>OPENHAB_CONF/automation/ruby/lib</tt>" se non specificato.

View File

@ -313,6 +313,7 @@ See [openhab-js : items](https://openhab.github.io/openhab-js/items.html) for fu
- items : `object`
- .NAME ⇒ `Item`
- .existsItem(name) ⇒ `boolean`
- .getItem(name, nullIfMissing) ⇒ `Item`
- .getItems() ⇒ `Array[Item]`
- .getItemsByTag(...tagNames) ⇒ `Array[Item]`
@ -332,7 +333,7 @@ Calling `getItem(...)` or `...` returns an `Item` object with the following prop
- Item : `object`
- .rawItem ⇒ `HostItem`
- .history ⇒ [`ItemHistory`](#itemhistory)
- .persistence ⇒ [`ItemPersistence`](#itempersistence)
- .semantics ⇒ [`ItemSemantics`](https://openhab.github.io/openhab-js/items.ItemSemantics.html)
- .type ⇒ `string`
- .name ⇒ `string`
@ -351,6 +352,9 @@ Calling `getItem(...)` or `...` returns an `Item` object with the following prop
- .removeMetadata(namespace) ⇒ `object|null`
- .sendCommand(value): `value` can be a string, a [`time.ZonedDateTime`](#time) or a [`Quantity`](#quantity)
- .sendCommandIfDifferent(value) ⇒ `boolean`: `value` can be a string, a [`time.ZonedDateTime`](#time) or a [`Quantity`](#quantity)
- .sendIncreaseCommand(value) ⇒ `boolean`: `value` can be a number, or a [`Quantity`](#quantity)
- .sendDecreaseCommand(value) ⇒ `boolean`: `value` can be a number, or a [`Quantity`](#quantity)
- .sendToggleCommand(): Sends a command to flip the Item's state (e.g. if it is 'ON' an 'OFF' command is sent).
- .postUpdate(value): `value` can be a string, a [`time.ZonedDateTime`](#time) or a [`Quantity`](#quantity)
- .addGroups(...groupNamesOrItems)
- .removeGroups(...groupNamesOrItems)
@ -402,8 +406,8 @@ items.replaceItem({
tags: ['Lightbulb'],
channels: {
'binding:thing:device:hallway#light': {},
'binding:thing:device:livingroom#light': {
profile: 'system:follow'
'binding:thing:device:livingroom#light': {
profile: 'system:follow'
}
},
metadata: {
@ -432,62 +436,121 @@ items.replaceItem({
See [openhab-js : ItemConfig](https://openhab.github.io/openhab-js/global.html#ItemConfig) for full API documentation.
#### `ItemHistory`
#### `ItemPersistence`
Calling `Item.history` returns an `ItemHistory` object with the following functions:
Calling `Item.persistence` returns an `ItemPersistence` object with the following functions:
- ItemHistory :`object`
- .averageBetween(begin, end, serviceId) ⇒ `number | null`
- .averageSince(timestamp, serviceId) ⇒ `number | null`
- .changedBetween(begin, end, serviceId) ⇒ `boolean`
- ItemPersistence :`object`
- .averageSince(timestamp, serviceId) ⇒ `PersistedState | null`
- .averageUntil(timestamp, serviceId) ⇒ `PersistedState | null`
- .averageBetween(begin, end, serviceId) ⇒ `PersistedState | null`
- .changedSince(timestamp, serviceId) ⇒ `boolean`
- .deltaBetween(begin, end, serviceId) ⇒ `number | null`
- .deltaSince(timestamp, serviceId) ⇒ `number | null`
- .deviationBetween(begin, end, serviceId) ⇒ `number | null`
- .deviationSince(timestamp, serviceId) ⇒ `number | null`
- .evolutionRateBetween(begin, end, serviceId) ⇒ `number | null`
- .changedUntil(timestamp, serviceId) ⇒ `boolean`
- .changedBetween(begin, end, serviceId) ⇒ `boolean`
- .countSince(timestamp, serviceId) ⇒ `number`
- .countUntil(timestamp, serviceId) ⇒ `number`
- .countBetween(begin, end, serviceId) ⇒ `number`
- .countStateChangesSince(timestamp, serviceId) ⇒ `number`
- .countStateChangesUntil(timestamp, serviceId) ⇒ `number`
- .countStateChangesBetween(begin, end, serviceId) ⇒ `number`
- .deltaSince(timestamp, serviceId) ⇒ `PersistedState | null`
- .deltaUntil(timestamp, serviceId) ⇒ `PersistedState | null`
- .deltaBetween(begin, end, serviceId) ⇒ `PersistedState | null`
- .deviationSince(timestamp, serviceId) ⇒ `PersistedState | null`
- .deviationUntil(timestamp, serviceId) ⇒ `PersistedState | null`
- .deviationBetween(begin, end, serviceId) ⇒ `PersistedState | null`
- .evolutionRateSince(timestamp, serviceId) ⇒ `number | null`
- .getAllStatesBetween(begin, end, serviceId) ⇒ `Array[HistoricItem]`
- .getAllStatesSince(timestamp, serviceId) ⇒ `Array[HistoricItem]`
- .historicState(timestamp, serviceId) ⇒ `HistoricItem | null`
- .evolutionRateUntil(timestamp, serviceId) ⇒ `number | null`
- .evolutionRateBetween(begin, end, serviceId) ⇒ `number | null`
- .getAllStatesSince(timestamp, serviceId) ⇒ `Array[PersistedItem]`
- .getAllStatesUntil(timestamp, serviceId) ⇒ `Array[PersistedItem]`
- .getAllStatesBetween(begin, end, serviceId) ⇒ `Array[PersistedItem]`
- .lastUpdate(serviceId) ⇒ `ZonedDateTime | null`
- .latestState(serviceId) ⇒ `string | null`
- .maximumBetween(begin, end, serviceId) ⇒ `HistoricItem | null`
- .maximumSince(timestamp,serviceId) ⇒ `HistoricItem | null`
- .minimumSince(begin, end, serviceId) ⇒ `HistoricItem | null`
- .minimumSince(timestamp, serviceId) ⇒ `HistoricItem | null`
- .persist(serviceId)
- .previousState(skipEqual, serviceId) ⇒ `HistoricItem | null`
- .sumBetween(begin, end, serviceId) ⇒ `number | null`
- .sumSince(timestamp, serviceId) ⇒ `number | null`
- .updatedBetween(begin, end, serviceId) ⇒ `boolean`
- .nextUpdate(serviceId) ⇒ `ZonedDateTime | null`
- .lastChange(serviceId) ⇒ `ZonedDateTime | null`
- .nextChange(serviceId) ⇒ `ZonedDateTime | null`
- .maximumSince(timestamp, serviceId) ⇒ `PersistedItem | null`
- .maximumUntil(timestamp, serviceId) ⇒ `PersistedItem | null`
- .maximumBetween(begin, end, serviceId) ⇒ `PersistedItem | null`
- .minimumSince(timestamp, serviceId) ⇒ `PersistedItem | null`
- .minimumUntil(timestamp, serviceId) ⇒ `PersistedItem | null`
- .minimumBetween(begin, end, serviceId) ⇒ `PersistedItem | null`
- .medianSince(timestamp, serviceId) ⇒ `PersistedState | null`
- .medianUntil(timestamp, serviceId) ⇒ `PersistedState | null`
- .medianBetween(begin, end, serviceId) ⇒ `PersistedState | null`
- .persist(serviceId): Tells the persistence service to store the current Item state, which is then done asynchronously.
**Warning:** This has the side effect, that if the Item state changes shortly after `.persist` has been called, the new Item state will be persisted. See [JSDoc](https://openhab.github.io/openhab-js/items.ItemPersistence.html#persist) for a possible work-around.
- .persist(timestamp, state, serviceId): Tells the persistence service to store the given state at the given timestamp, which is then done asynchronously.
- .persist(timeSeries, serviceId): Tells the persistence service to store the given [`TimeSeries`](#timeseries), which is then done asynchronously.
- .persistedState(timestamp, serviceId) ⇒ `PersistedItem | null`
- .previousState(skipEqual, serviceId) ⇒ `PersistedItem | null`
- .nextState(skipEqual, serviceId) ⇒ `PersistedItem | null`
- .sumSince(timestamp, serviceId) ⇒ `PersistedState | null`
- .sumUntil(timestamp, serviceId) ⇒ `PersistedState | null`
- .sumBetween(begin, end, serviceId) ⇒ `PersistedState | null`
- .updatedSince(timestamp, serviceId) ⇒ `boolean`
- .varianceBetween(begin, end, serviceId) ⇒ `number | null`
- .varianceSince(timestamp, serviceId) ⇒ `number | null`
- .updatedUntil(timestamp, serviceId) ⇒ `boolean`
- .updatedBetween(begin, end, serviceId) ⇒ `boolean`
- .varianceSince(timestamp, serviceId) ⇒ `PersistedState | null`
- .varianceUntil(timestamp, serviceId) ⇒ `PersistedState | null`
- .varianceBetween(begin, end, serviceId) ⇒ `PersistedState | null`
Note: `serviceId` is optional, if omitted, the default persistence service will be used.
```javascript
var yesterday = new Date(new Date().getTime() - (24 * 60 * 60 * 1000));
var item = items.KitchenDimmer;
console.log('KitchenDimmer averageSince', item.history.averageSince(yesterday));
console.log('KitchenDimmer averageSince', item.persistence.averageSince(yesterday));
```
The `HistoricItem` object contains the following properties, representing Item state and the respective timestamp:
The `PersistedState` object contains the following properties, representing Item state:
- `state`: State as string
- `numericState`: State as number, if state can be represented as number, or `null` if that's not the case
- `quantityState`: Item state as [`Quantity`](#quantity) or `null` if state is not Quantity-compatible
- `rawState`: State as Java `State` object
The `PersistedItem` object extends `PersistedState` with the following properties, representing Item state and the respective timestamp:
- `timestamp`: Timestamp as [`time.ZonedDateTime`](#time)
- `instant`: Timestamp as [`time.Instant`](#time)
```javascript
var midnight = time.toZDT('00:00');
var historic = items.KitchenDimmer.history.maximumSince(midnight);
var historic = items.KitchenDimmer.persistence.maximumSince(midnight);
console.log('KitchenDimmer maximum was ', historic.state, ' at ', historic.timestamp);
```
See [openhab-js : ItemHistory](https://openhab.github.io/openhab-js/items.ItemHistory.html) for full API documentation.
See [openhab-js : ItemPersistence](https://openhab.github.io/openhab-js/items.ItemPersistence.html) for full API documentation.
#### `TimeSeries`
A `TimeSeries` is used to transport a set of states together with their timestamp.
It is usually used for persisting historic state or forecasts in a persistence service by using [`ItemPersistence.persist`](#itempersistence).
When creating a new `TimeSeries`, a policy must be chosen - it defines how the `TimeSeries` is persisted in a persistence service:
- `ADD` adds the content to the persistence, well suited for persisting historic data.
- `REPLACE` first removes all persisted elements in the timespan given by begin and end of the `TimeSeries`, well suited for persisting forecasts.
A `TimeSeries` object has the following properties and methods:
- `policy`: The persistence policy, either `ADD` or `REPLACE`.
- `begin`: Timestamp of the first element of the `TimeSeries`.
- `end`: Timestamp of the last element of the `TimeSeries`.
- `size`: Number of elements in the `TimeSeries`.
- `states`: States of the `TimeSeries` together with their timestamp and sorted by their timestamps.
Be aware that this returns a reference to the internal state array, so changes to the array will affect the `TimeSeries`.
- `add(timestamp, state)`: Add a given state to the `TimeSeries` at the given timestamp.
The following example shows how to create a `TimeSeries`:
```javascript
var timeSeries = new items.TimeSeries('ADD'); // Create a new TimeSeries with policy ADD
timeSeries.add(time.toZDT('2024-01-01T14:53'), Quantity('5 m')).add(time.toZDT().minusMinutes(2), Quantity('0 m')).add(time.toZDT().plusMinutes(5), Quantity('5 m'));
console.log(ts); // Let's have a look at the TimeSeries
items.getItem('MyDistanceItem').persistence.persist(timeSeries, 'influxdb'); // Persist the TimeSeries for the Item 'MyDistanceItem' using the InfluxDB persistence service
```
### Things
@ -531,7 +594,10 @@ thing.setEnabled(false);
The actions namespace allows interactions with openHAB actions.
The following are a list of standard actions.
Note that most of the actions currently do **not** provide type definitions and therefore auto-completion does not work.
**Warning:** Please be aware, that (unless not explicitly noted) there is **no** type conversion from Java to JavaScript types for the return values of actions.
Read the JavaDoc linked from the JSDoc to learn about the returned Java types.
Please note that most of the actions currently do **not** provide type definitions and therefore auto-completion does not work.
See [openhab-js : actions](https://openhab.github.io/openhab-js/actions.html) for full API documentation and additional actions.
@ -539,20 +605,27 @@ See [openhab-js : actions](https://openhab.github.io/openhab-js/actions.html) fo
See [openhab-js : actions.Audio](https://openhab.github.io/openhab-js/actions.html#.Audio) for complete documentation.
#### BusEvent
#### BusEvent Actions
See [openhab-js : actions.BusEvent](https://openhab.github.io/openhab-js/actions.html#.BusEvent) for complete documentation.
#### CoreUtil Actions
See [openhab-js : actions.CoreUtil](https://openhab.github.io/openhab-js/actions.html#.CoreUtil) for complete documentation.
The `CoreUtil` actions provide access to parts of the utilities included in openHAB core, see [org.openhab.core.util](https://www.openhab.org/javadoc/latest/org/openhab/core/util/package-summary).
These include several methods to convert between color types like HSB, RGB, sRGB, RGBW and XY.
#### Ephemeris Actions
See [openhab-js : actions.Ephemeris](https://openhab.github.io/openhab-js/actions.html#.Ephemeris) for complete documentation.
Ephemeris is a way to determine what type of day today or a number of days before or after today is. For example, a way to determine if today is a weekend, a bank holiday, someones birthday, trash day, etc.
Ephemeris is a way to determine what type of day today or a number of days before or after today is.
For example, a way to determine if today is a weekend, a public holiday, someones birthday, trash day, etc.
Additional information can be found on the [Ephemeris Actions Docs](https://www.openhab.org/docs/configuration/actions.html#ephemeris) as well as the [Ephemeris JavaDoc](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/ephemeris).
```javascript
// Example
var weekend = actions.Ephemeris.isWeekend();
```
@ -584,6 +657,15 @@ var response = actions.HTTP.sendHttpGetRequest('<url>');
Replace `<url>` with the request url.
#### Ping Actions
See [openhab-js : actions.Ping](https://openhab.github.io/openhab-js/actions.html#.Ping) for complete documentation.
```javascript
// Check if a host is reachable
var reachable = actions.Ping.checkVitality(host, port, timeout); // host: string, port: int, timeout: int
```
#### ScriptExecution Actions
The `ScriptExecution` actions provide the `callScript(string scriptName)` method, which calls a script located at the `$OH_CONF/scripts` folder, as well as the `createTimer` method.
@ -643,16 +725,6 @@ myTimer.reschedule(now.plusSeconds(5));
See [openhab-js : actions.ScriptExecution](https://openhab.github.io/openhab-js/actions.ScriptExecution.html) for complete documentation.
#### Semantics Actions
See [openhab-js : actions.Semantics](https://openhab.github.io/openhab-js/actions.html#.Semantics) for complete documentation.
#### Thing Actions
It is possible to get the actions for a Thing using `actions.Things.getActions(bindingId, thingUid)`, e.g. `actions.Things.getActions('network', 'network:pingdevice:pc')`.
See [openhab-js : actions.Things](https://openhab.github.io/openhab-js/actions.html#.Things) for complete documentation.
#### Transformation Actions
openHAB provides various [data transformation services](https://www.openhab.org/addons/#transform) which can translate between technical and human-readable values.
@ -671,45 +743,99 @@ See [openhab-js : actions.Voice](https://openhab.github.io/openhab-js/actions.ht
#### Cloud Notification Actions
Note: Optional action if [openHAB Cloud Connector](https://www.openhab.org/addons/integrations/openhabcloud/) is installed.
Requires the [openHAB Cloud Connector](https://www.openhab.org/addons/integrations/openhabcloud/) to be installed.
Notification actions may be placed in rules to send alerts to mobile devices registered with an [openHAB Cloud instance](https://github.com/openhab/openhab-cloud) such as [myopenHAB.org](https://myopenhab.org/).
For available actions have a look at the [Cloud Notification Actions Docs](https://www.openhab.org/docs/configuration/actions.html#cloud-notification-actions).
There are three different types of notifications:
- Broadcast Notifications: Sent to all registered devices and shown as notification on these devices.
- Standard Notifications: Sent to the registered devices of the specified user and shown as notification on his devices.
- Log Notifications: Only shown in the notification log, e.g. inside the Android and iOS Apps.
In addition to that, notifications can be updated later be re-using the same `referenceId` and hidden/removed either by `referenceId` or `tag`.
To send these three types of notifications, use the `notificationBuilder(message)` method of the `actions` namespace.
`message` is optional and may be omitted.
It returns a new `NotificationBuilder` object, which by default sends a broadcast notification and provides the following methods:
- `.logOnly()`: Send a log notification only.
- `.hide()`: Hides notification(s) with the specified `referenceId` or `tag` (`referenceId` has precedence over `tag`).
- `.addUserId(emailAddress)`: By adding the email address(es) of specific openHAB Cloud user(s), the notification is only sent to this (these) user(s).
To add multiple users, either call `addUserId` multiple times or pass mutiple emails as multiple params, e.g. `addUserId(emailAddress1, emailAddress2)`.
- `.withIcon(icon)`: Sets the icon of the notification.
- `.withTag(tag)`: Sets the tag of the notification. Used for grouping notifications and to hide/remove groups of notifications.
- `.withTitle(title)`: Sets the title of the notification.
- `.withReferenceId(referenceId)`: Sets the reference ID of the notification. If none is set, but it might be useful, a random UUID will be generated.
The reference ID can be used to update or hide the notification later by using the same reference ID again.
- `.withOnClickAction(action)`: Sets the action to be executed when the notification is clicked.
- `.withMediaAttachmentUrl(mediaAttachmentUrl)`: Sets the URL of a media attachment to be displayed with the notification. This URL must be reachable by the push notification client.
- `.addActionButton(label, action)`: Adds an action button to the notification. Please note that due to Android and iOS limitations, only three action buttons are supported.
- `.send()``string|null`: Sends the notification and returns the reference ID or `null` for log notifications and when hiding notifications.
The syntax for the `action` parameter is described in [openHAB Cloud Connector: Action Syntax](https://www.openhab.org/addons/integrations/openhabcloud/#action-syntax).
The syntax for the `mediaAttachmentUrl` parameter is described in [openHAB Cloud Connector](https://www.openhab.org/addons/integrations/openhabcloud/).
```javascript
// Example
actions.NotificationAction.sendNotification('<email>', '<message>'); // to a single myopenHAB user identified by e-mail
actions.NotificationAction.sendBroadcastNotification('<message>'); // to all myopenHAB users
// Send a simple broadcast notification
actions.notificationBuilder('Hello World!').send();
// Send a broadcast notification with icon, tag and title
actions.notificationBuilder('Hello World!')
.withIcon('f7:bell_fill').withTag('important').withTitle('Important Notification').send();
// Send a broadcast notification with icon, tag, title, media attachment URL and actions
actions.notificationBuilder('Hello World!')
.withIcon('f7:bell_fill').withTag('important').withTitle('Important Notification')
.withOnClickAction('ui:navigate:/page/my_floorplan_page').withMediaAttachmentUrl('http://example.com/image.jpg')
.addActionButton('Turn Kitchen Light ON', 'command:KitchenLights:ON').addActionButton('Turn Kitchen Light OFF', 'command:KitchenLights:OFF').send();
// Send a simple standard notification to two specific users
actions.notificationBuilder('Hello World!').addUserId('florian@example.com').addUserId('florian@example.org').send();
// Send a standard notification with icon, tag and title to two specific users
actions.notificationBuilder('Hello World!').addUserId('florian@example.com').addUserId('florian@example.org')
.withIcon('f7:bell_fill').withTag('important').withTitle('Important notification').send();
// Sends a simple log notification
actions.notificationBuilder('Hello World!').logOnly().send();
// Sends a simple log notification with icon and tag
actions.notificationBuilder('Hello World!').logOnly()
.withIcon('f7:bell_fill').withTag('important').send();
```
Replace `<email>` with the e-mail address of the user.
Replace `<message>` with the notification text.
See [openhab-js : actions.NotificationBuilder](https://openhab.github.io/openhab-js/actions.html#.notificationBuilder) for complete documentation.
### Cache
The cache namespace provides both a private and a shared cache that can be used to set and retrieve objects that will be persisted between subsequent runs of the same or between scripts.
The cache namespace provides both a private and a shared cache that can be used to set and retrieve data that will be persisted between subsequent runs of the same or between scripts.
The private cache can only be accessed by the same script and is cleared when the script is unloaded.
You can use it to e.g. store timers or counters between subsequent runs of that script.
You can use it to store primitives and objects, e.g. store timers or counters between subsequent runs of that script.
When a script is unloaded and its cache is cleared, all timers (see [`createTimer`](#createtimer)) stored in its private cache are automatically cancelled.
The shared cache is shared across all rules and scripts, it can therefore be accessed from any automation language.
The access to every key is tracked and the key is removed when all scripts that ever accessed that key are unloaded.
If that key stored a timer, the timer is cancelled.
If that key stored a timer, the timer will be cancelled.
You can use it to store primitives and **Java** objects, e.g. store timers or counters between multiple scripts.
Due to a multi-threading limitation in GraalJS (the JavaScript engine used by JavaScript Scripting), it is not recommended to store JavaScript objects in the shared cache.
Multi-threaded access to JavaScript objects will lead to script execution failure!
You can work-around that limitation by either serialising and deserialising JS objects or by switching to their Java counterparts.
Timers as created by [`createTimer`](#createtimer) can be stored in the shared cache.
The ids of timers and intervals as created by `setTimeout` and `setInterval` cannot be shared across scripts as these ids are local to the script where they were created.
See [openhab-js : cache](https://openhab.github.io/openhab-js/cache.html) for full API documentation.
- cache : <code>object</code>
- .private
- .get(key, defaultSupplier) ⇒ <code>Object | null</code>
- .put(key, value) ⇒ <code>Previous Object | null</code>
- .remove(key) ⇒ <code>Previous Object | null</code>
- .get(key, defaultSupplier) ⇒ <code>* | null</code>
- .put(key, value) ⇒ <code>Previous * | null</code>
- .remove(key) ⇒ <code>Previous * | null</code>
- .exists(key) ⇒ <code>boolean</code>
- .shared
- .get(key, defaultSupplier) ⇒ <code>Object | null</code>
- .put(key, value) ⇒ <code>Previous Object | null</code>
- .remove(key) ⇒ <code>Previous Object | null</code>
- .get(key, defaultSupplier) ⇒ <code>* | null</code>
- .put(key, value) ⇒ <code>Previous * | null</code>
- .remove(key) ⇒ <code>Previous * | null</code>
- .exists(key) ⇒ <code>boolean</code>
The `defaultSupplier` provided function will return a default value if a specified key is not already associated with a value.
@ -717,19 +843,17 @@ The `defaultSupplier` provided function will return a default value if a specifi
**Example** *(Get a previously set value with a default value (times = 0))*
```js
var counter = cache.private.get('counter', () => ({ 'times': 0 }));
console.log('Count', counter.times++);
var counter = cache.shared.get('counter', () => 0);
console.log('Counter: ' + counter);
```
**Example** *(Get a previously set object)*
**Example** *(Get a previously set value, modify and store it)*
```js
var counter = cache.private.get('counter');
if (counter === null) {
counter = { times: 0 };
cache.private.put('counter', counter);
}
console.log('Count', counter.times++);
counter++;
console.log('Counter: ' + counter);
cache.private.put('counter', counter);
```
### Time
@ -746,7 +870,7 @@ Examples:
var now = time.ZonedDateTime.now();
var yesterday = time.ZonedDateTime.now().minusHours(24);
var item = items.Kitchen;
console.log("averageSince", item.history.averageSince(yesterday));
console.log("averageSince", item.persistence.averageSince(yesterday));
```
```javascript
@ -755,6 +879,32 @@ actions.Exec.executeCommandLine(time.Duration.ofSeconds(20), 'echo', 'Hello Worl
See [JS-Joda](https://js-joda.github.io/js-joda/) for more examples and complete API usage.
#### Parsing and Formatting
Occasionally, one will need to parse a non-supported date time string or generate one from a ZonedDateTime.
To do this you will use [JS-Joda DateTimeFormatter and potentially your Locale](https://js-joda.github.io/js-joda/manual/formatting.html).
However, shipping all the locales with the openhab-js library would lead to an unacceptable large size.
Therefore, if you attempt to use the `DateTimeFormatter` and receive an error saying it cannot find your locale, you will need to manually install your locale and import it into your rule.
[JS-Joda Locales](https://github.com/js-joda/js-joda/tree/master/packages/locale#use-prebuilt-locale-packages) includes a list of all the supported locales.
Each locale consists of a two letter language indicator followed by a "-" and a two letter dialect indicator: e.g. "EN-US".
Installing a locale can be done through the command `npm install @js-joda/locale_de-de` from the *$OPENHAB_CONF/automation/js* folder.
To import and use a local into your rule you need to require it and create a `DateTimeFormatter` that uses it:
```javascript
var Locale = require('@js-joda/locale_de-de').Locale.GERMAN;
var formatter = time.DateTimeFormatter.ofPattern('dd.MM.yyyy HH:mm').withLocale(Locale);
```
#### `time.javaInstantToJsInstant()`
Converts a [`java.time.Instant`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Instant.html) to a JS-Joda [`Instant`](https://js-joda.github.io/js-joda/manual/Instant.html).
#### `time.javaZDTToJsZDT()`
Converts a [`java.time.ZonedDateTime`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/ZonedDateTime.html) to a JS-Joda [`ZonedDateTime`](https://js-joda.github.io/js-joda/manual/ZonedDateTime.html).
#### `time.toZDT()`
There will be times when this automatic conversion is not available (for example when working with date times within a rule).
@ -902,7 +1052,7 @@ The argument `value` can be a Quantity-compatible `Item`, a string, a `Quantity`
`value` strings have the `$amount $unit` format and must follow these rules:
- `$amount` is required with a number provided as string
- `$unit` is optional (unitless quantities are possible) and can have a prefix like `m` (milli) or `M` (mega)
- `$unit` is optional (unit-less quantities are possible) and can have a prefix like `m` (milli) or `M` (mega)
- `$unit` does not allow whitespaces.
- `$unit` does allow superscript, e.g. `²` instead of `^2`.
- `$unit` requires the `*` between two units to be present, although you usually omit it (which is mathematically seen allowed, but openHAB needs the `*`).
@ -1000,7 +1150,7 @@ See [openhab-js : rules](https://openhab.github.io/openhab-js/rules.html) for fu
### JSRule
JSRules provides a simple, declarative syntax for defining rules that will be executed based on a trigger condition
`JSRule` provides a simple, declarative syntax for defining rules that will be executed based on a trigger condition:
```javascript
var email = "juliet@capulet.org"
@ -1064,6 +1214,9 @@ triggers.DateTimeTrigger('MyDateTimeItem');
You can use `null` for a trigger parameter to skip its configuration.
You may use `SwitchableJSRule` to create a rule that can be enabled and disabled with a Switch Item.
As an extension to `JSRule`, its syntax is the same, however you can specify an Item name (using the `switchItemName` rule config property) if you don't like the automatically created Item's name.
See [openhab-js : triggers](https://openhab.github.io/openhab-js/triggers.html) in the API documentation for a full list of all triggers.
### Rule Builder
@ -1094,7 +1247,7 @@ Operations and conditions can also optionally take functions:
```javascript
rules.when().item("F1_light").changed().then(event => {
console.log(event);
console.log(event);
}).build("Test Rule", "My Test Rule");
```
@ -1106,11 +1259,11 @@ See [Examples](#rule-builder-examples) for further patterns.
- `when()`
- `or()`
- `.channel(channelName)` Specifies a channel event as a source for the rule to fire.
- `.triggered(event)` Trigger on a specific event name
- `.cron(cronExpression)` Specifies a cron schedule for the rule to fire.
- `.timeOfDay(time)` Specifies a time of day in `HH:mm` for the rule to fire.
- `.item(itemName)` Specifies an Item as the source of changes to trigger a rule.
- `.channel(channelName)`: Specifies a channel event as a source for the rule to fire.
- `.triggered(event)`: Trigger on a specific event name
- `.cron(cronExpression)`: Specifies a cron schedule for the rule to fire.
- `.timeOfDay(time)`: Specifies a time of day in `HH:mm` for the rule to fire.
- `.item(itemName)`: Specifies an Item as the source of changes to trigger a rule.
- `.for(duration)`
- `.from(state)`
- `.fromOn()`
@ -1121,7 +1274,7 @@ See [Examples](#rule-builder-examples) for further patterns.
- `.receivedCommand()`
- `.receivedUpdate()`
- `.changed()`
- `.memberOf(groupName)` Specifies a group Item as the source of changes to trigger the rule.
- `.memberOf(groupName)`: Specifies a group Item as the source of changes to trigger the rule.
- `.for(duration)`
- `.from(state)`
- `.fromOn()`
@ -1132,20 +1285,21 @@ See [Examples](#rule-builder-examples) for further patterns.
- `.receivedCommand()`
- `.receivedUpdate()`
- `.changed()`
- `.system()` Specifies a system event as a source for the rule to fire.
- `.system()`: Specifies a system event as a source for the rule to fire.
- `.ruleEngineStarted()`
- `.rulesLoaded()`
- `.startupComplete()`
- `.thingsInitialized()`
- `.userInterfacesStarted()`
- `.startLevel(level)`
- `.thing(thingName)` Specifies a Thing event as a source for the rule to fire.
- `.thing(thingName)`: Specifies a Thing event as a source for the rule to fire.
- `changed()`
- `updated()`
- `from(state)`
- `to(state)`
- `.dateTime(itemName)` Specifies a DateTime Item whose (optional) date and time schedule the rule to fire.
- `.timeOnly()` Only the time of the Item should be compared, the date should be ignored.
- `.dateTime(itemName)`: Specifies a DateTime Item whose (optional) date and time schedule the rule to fire.
- `.timeOnly()`: Only the time of the Item should be compared, the date should be ignored.
- `.withOffset(offset)`: The offset in seconds to add to the time of the DateTime Item.
Additionally, all the above triggers have the following functions:
@ -1158,6 +1312,8 @@ Additionally, all the above triggers have the following functions:
- `if(optionalFunction)`
- `.stateOfItem(itemName)`
- `is(state)`
- `isOn()`
- `isOff()`
- `in(state...)`
#### Rule Builder Operations
@ -1238,12 +1394,15 @@ This table gives an overview over the `event` object:
| `thingUID` | `Thing****Trigger` | UID of Thing that triggered event | N/A |
| `cronExpression` | `GenericCronTrigger` | Cron expression of the trigger | N/A |
| `time` | `TimeOfDayTrigger` | Time of day value of the trigger | N/A |
| `timeOnly` | `DateTimeTrigger` | Whether the trigger only considers the time part of the DateTime Item | N/A |
| `offset` | `DateTimeTrigger` | Offset in seconds added to the time of the DateTime Item | N/A |
| `eventType` | all except `PWMTrigger`, `PIDTrigger` | Type of event that triggered event (change, command, triggered, update, time) | N/A |
| `triggerType` | all except `PWMTrigger`, `PIDTrigger` | Type of trigger that triggered event | N/A |
| `eventClass` | all | Java class name of the triggering event | N/A |
| `module` | all | (user-defined or auto-generated) name of trigger | N/A |
| `raw` | all | Original contents of the event including data passed from a calling rule | N/A |
All properties are typeof `string`.
All properties are typeof `string` except for properties contained by `raw` which are unmodified from the original types.
Please note that when using `GenericEventTrigger`, the available properties depend on the chosen event types.
It is not possible for the openhab-js library to provide type conversions for all properties of all openHAB events, as those are too many.
@ -1290,7 +1449,7 @@ Follow these steps to create your own library (it's called a CommonJS module):
function someFunction () {
console.log('Hello from your personal library!');
}
module.exports = {
someProperty,
someFunction
@ -1298,7 +1457,7 @@ Follow these steps to create your own library (it's called a CommonJS module):
```
4. Tar it up by running `npm pack` from your library's folder.
5. Install it by running `npm install <name>-<version>.tgz` from the `automation/js` folder.
5. Install it by running `npm install <path-to-library-folder>/<name>-<version>.tgz` from the `automation/js` folder.
6. After you've installed it with `npm`, you can continue development of the library inside `node_modules`.
It is also possible to upload your library to [npm](https://npmjs.com) to share it with other users.
@ -1307,76 +1466,24 @@ If you want to get some advanced information, you can read [this blog post](http
### @runtime
One can access many useful utilities and types using `require("@runtime")`, e.g.
In most cases, the [Standard Library](#standard-library) provides pure-JS APIs to interact with the openHAB runtime.
Generally speaking, you should therefore prefer to use [Standard Library](#standard-library) provided by this library instead.
However, in some cases, e.g. when needing a [`HSBType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/hsbtype), one needs to access raw Java utilities and types.
This can be achieved by using `require('@runtime')`, e.g.
```javascript
var { ON, OFF, QuantityType } = require("@runtime");
var { ON, OFF, QuantityType } = require('@runtime');
// Alternative, more verbose way to achieve the same:
//
// var runtime = require("@runtime");
// var runtime = require('@runtime');
//
// var ON = runtime.ON;
// var OFF = runtime.OFF;
// var QuantityType = runtime.QuantityType;
```
| Variable | Description |
|-------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `State` | [`org.openhab.core.types.State`](https://www.openhab.org/javadoc/latest/org/openhab/core/types/state) |
| `Command` | [`org.openhab.core.types.Command`](https://www.openhab.org/javadoc/latest/org/openhab/core/types/command) |
| `URLEncoder` | [`java.net.URLEncoder`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/URLEncoder.html) |
| `File` | [`java.io.File`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/File.html) |
| `Files` | [`java.nio.file.Files`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/nio/file/Files.html) |
| `Path` | [`java.nio.file.Path`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/nio/file/Path.html) |
| `Paths` | [`java.nio.file.Paths`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/nio/file/Paths.html) |
| `IncreaseDecreaseType` | [`org.openhab.core.library.types.IncreaseDecreaseType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/increasedecreasetype) |
| `DECREASE` | `IncreaseDecreaseType` enum item |
| `INCREASE` | `IncreaseDecreaseType` enum item |
| `OnOffType` | [`org.openhab.core.library.types.OnOffType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/onofftype) |
| `ON` | `OnOffType` enum item |
| `OFF` | `OnOffType` enum item |
| `OpenClosedType` | [`org.openhab.core.library.types.OpenClosedType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/openclosedtype) |
| `OPEN` | `OpenClosedType` enum item |
| `CLOSED` | `OpenClosedType` enum item |
| `StopMoveType` | [`org.openhab.core.library.types.StopMoveType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/stopmovetype) |
| `STOP` | `StopMoveType` enum item |
| `MOVE` | `StopMoveType` enum item |
| `UpDownType` | [`org.openhab.core.library.types.UpDownType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/updowntype) |
| `UP` | `UpDownType` enum item |
| `DOWN` | `UpDownType` enum item |
| `UnDefType` | [`org.openhab.core.library.types.UnDefType`](https://www.openhab.org/javadoc/latest/org/openhab/core/types/undeftype) |
| `NULL` | `UnDefType` enum item |
| `UNDEF` | `UnDefType` enum item |
| `RefreshType` | [`org.openhab.core.library.types.RefreshType`](https://www.openhab.org/javadoc/latest/org/openhab/core/types/refreshtype) |
| `REFRESH` | `RefreshType` enum item |
| `NextPreviousType` | [`org.openhab.core.library.types.NextPreviusType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/nextprevioustype) |
| `NEXT` | `NextPreviousType` enum item |
| `PREVIOUS` | `NextPreviousType` enum item |
| `PlayPauseType` | [`org.openhab.core.library.types.PlayPauseType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/playpausetype) |
| `PLAY` | `PlayPauseType` enum item |
| `PAUSE` | `PlayPauseType` enum item |
| `RewindFastforwardType` | [`org.openhab.core.library.types.RewindFastforwardType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/rewindfastforwardtype) |
| `REWIND` | `RewindFastforwardType` enum item |
| `FASTFORWARD` | `RewindFastforwardType` enum item |
| `QuantityType` | [`org.openhab.core.library.types.QuantityType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/quantitytype) |
| `StringListType` | [`org.openhab.core.library.types.StringListType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/stringlisttype) |
| `RawType` | [`org.openhab.core.library.types.RawType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/rawtype) |
| `DateTimeType` | [`org.openhab.core.library.types.DateTimeType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/datetimetype) |
| `DecimalType` | [`org.openhab.core.library.types.DecimalType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/decimaltype) |
| `HSBType` | [`org.openhab.core.library.types.HSBType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/hsbtype) |
| `PercentType` | [`org.openhab.core.library.types.PercentType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/percenttype) |
| `PointType` | [`org.openhab.core.library.types.PointType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/pointtype) |
| `StringType` | [`org.openhab.core.library.types.StringType`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/stringtype) |
| `SIUnits` | [`org.openhab.core.library.unit.SIUnits`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/unit/siunits) |
| `ImperialUnits` | [`org.openhab.core.library.unit.ImperialUnits`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/unit/imperialunits) |
| `MetricPrefix` | [`org.openhab.core.library.unit.MetricPrefix`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/unit/metricprefix) |
| `Units` | [`org.openhab.core.library.unit.Units`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/unit/units) |
| `BinaryPrefix` | [`org.openhab.core.library.unit.BinaryPrefix`](https://www.openhab.org/javadoc/latest/org/openhab/core/library/unit/binaryprefix) |
| `ChronoUnit` | [`java.time.temporal.ChronoUnit`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/temporal/ChronoUnit.html) |
| `Duration` | [`java.time.Duration`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html) |
| `ZoneId` | [`java.time.ZoneId`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/ZoneId.html) |
| `ZonedDateTime` | [`java.time.ZonedDateTime`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/ZonedDateTime.html) |
A list of available utilities and types can be found in the [JSR223 Default Preset documentation](https://www.openhab.org/docs/configuration/jsr223.html#default-preset-importpreset-not-required).
`require("@runtime")` also defines "services" such as `items`, `things`, `rules`, `events`, `actions`, `ir`, `itemRegistry`.
`require('@runtime')` also defines "services" such as `items`, `things`, `rules`, `events`, `actions`, `ir`, `itemRegistry`.
You can use these services for backwards compatibility purposes or ease migration from JSR223 scripts.
Generally speaking, you should prefer to use [Standard Library](#standard-library) provided by this library instead.

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.automation.jsscripting</artifactId>
@ -22,32 +22,47 @@
!jdk.internal.reflect.*,
!jdk.vm.ci.services
</bnd.importpackage>
<graal.version>22.0.0.2</graal.version> <!-- DO NOT UPGRADE: 22.0.0.2 is the latest version working on armv7l / OpenJDK 11.0.16 & armv7l / Zulu 17.0.5+8 -->
<!-- Remember to check if the fix https://github.com/openhab/openhab-core/pull/4437 still works when upgrading GraalJS -->
<graaljs.version>24.1.1</graaljs.version>
<oh.version>${project.version}</oh.version>
<ohjs.version>openhab@4.7.3</ohjs.version>
<ohjs.version>openhab@5.8.1</ohjs.version>
</properties>
<build>
<plugins>
<!-- bundle the modular dependencies into an uber-JAR -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>embed-dependencies</id>
<phase>package</phase>
<goals>
<goal>unpack-dependencies</goal>
<goal>shade</goal>
</goals>
<configuration>
<excludes>META-INF/services/com.oracle.truffle.api.TruffleLanguage$Provider</excludes> <!-- we'll provide this -->
<artifactSet>
<excludes>
<exclude>org.lastnpe.eea:eea-all</exclude>
<exclude>org.apache.karaf.features:framework</exclude>
</excludes>
</artifactSet>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<!-- Transformer to merge module-info.class files, if needed -->
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<!-- bundle the openhab-js library -->
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.12.1</version>
<version>1.15.0</version>
<configuration>
<nodeVersion>v16.17.1</nodeVersion> <!-- DO NOT DOWNGRADE: NodeJS < 16 doesn't support Apple Silicon -->
<workingDirectory>target/js</workingDirectory>
@ -67,7 +82,7 @@
</goals>
<configuration>
<!--suppress UnresolvedMavenProperty -->
<arguments>install ${ohjs.version} webpack@^5.87.0 webpack-cli@^5.1.4</arguments> <!-- webpack & webpack-cli versions should match to the ones from openhab-js -->
<arguments>install ${ohjs.version} webpack@^5.94.0 webpack-cli@^5.1.4</arguments> <!-- webpack & webpack-cli versions should match to the ones from openhab-js -->
</configuration>
</execution>
<execution>
@ -114,6 +129,7 @@
</execution>
</executions>
</plugin>
<!-- run SAT -->
<plugin>
<groupId>org.openhab.tools.sat</groupId>
<artifactId>sat-plugin</artifactId>
@ -126,31 +142,29 @@
<dependencies>
<dependency>
<groupId>org.graalvm.truffle</groupId>
<artifactId>truffle-api</artifactId>
<version>${graal.version}</version>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>polyglot</artifactId>
<version>${graaljs.version}</version>
</dependency>
<!-- Graal JavaScript ScriptEngine JSR 223 support -->
<dependency>
<groupId>org.graalvm.js</groupId>
<artifactId>js-scriptengine</artifactId>
<version>${graal.version}</version>
</dependency>
<dependency>
<groupId>org.graalvm.sdk</groupId>
<artifactId>graal-sdk</artifactId>
<version>${graal.version}</version>
<version>${graaljs.version}</version>
</dependency>
<!-- Graal TRegex engine (internally used by Graal JavaScript engine) -->
<dependency>
<groupId>org.graalvm.regex</groupId>
<artifactId>regex</artifactId>
<version>${graal.version}</version>
<version>${graaljs.version}</version>
</dependency>
<dependency> <!-- this must come AFTER the regex lib -->
<groupId>org.graalvm.js</groupId>
<artifactId>js</artifactId>
<version>${graal.version}</version>
<!-- Graal JavaScript engine (depends on Graal TRegex engine, must be added after it) -->
<dependency>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>js-community</artifactId>
<version>${graaljs.version}</version>
<type>pom</type>
<scope>runtime</scope>
</dependency>
<!-- GraalJS changelog says that com.ibm.icu/icu4j is not required for GraalJS >= 22.0.0 as it moved to org.graalvm.truffle;
but GraalJS >= 22.2.0 requires it, so we'll need to add it when we upgrade -->
</dependencies>
</project>

View File

@ -12,11 +12,22 @@
*/
package org.openhab.automation.jsscripting.internal;
import static org.openhab.core.automation.module.script.ScriptTransformationService.OPENHAB_TRANSFORMATION_SCRIPT;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.stream.Collectors;
import javax.script.Compilable;
import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import org.eclipse.jdt.annotation.Nullable;
import org.graalvm.polyglot.PolyglotException;
import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable;
import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -24,26 +35,112 @@ import org.slf4j.LoggerFactory;
* Wraps ScriptEngines provided by Graal to provide error messages and stack traces for scripts.
*
* @author Jonathan Gilbert - Initial contribution
* @author Florian Hotze - Improve logger name, Fix memory leak caused by exception logging
*/
class DebuggingGraalScriptEngine<T extends ScriptEngine & Invocable & AutoCloseable>
extends InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable<T> {
class DebuggingGraalScriptEngine<T extends ScriptEngine & Invocable & AutoCloseable & Compilable & Lock>
extends InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable<T> implements Lock {
private static final Logger STACK_LOGGER = LoggerFactory
.getLogger("org.openhab.automation.script.javascript.stack");
private static final int STACK_TRACE_LENGTH = 5;
private @Nullable Logger logger;
public DebuggingGraalScriptEngine(T delegate) {
super(delegate);
}
@Override
protected void beforeInvocation() {
super.beforeInvocation();
// OpenhabGraalJSScriptEngine::beforeInvocation will be executed after
// DebuggingGraalScriptEngine::beforeInvocation, because GraalJSScriptEngineFactory::createScriptEngine returns
// a DebuggingGraalScriptEngine instance.
// We therefore need to synchronize logger setup here and cannot rely on the synchronization in
// OpenhabGraalJSScriptEngine.
delegate.lock();
try {
if (logger == null) {
initializeLogger();
}
} finally { // Make sure that Lock is unlocked regardless of an exception being thrown or not to avoid deadlocks
delegate.unlock();
}
}
@Override
public Exception afterThrowsInvocation(Exception e) {
Throwable cause = e.getCause();
// OPS4J Pax Logging holds a reference to the exception, which causes the OpenhabGraalJSScriptEngine to not be
// removed from heap by garbage collection and causing a memory leak.
// Therefore, don't pass the exceptions itself to the logger, but only their message!
if (cause instanceof IllegalArgumentException) {
STACK_LOGGER.error("Failed to execute script:", e);
}
if (cause instanceof PolyglotException) {
STACK_LOGGER.error("Failed to execute script:", cause);
logger.error("Failed to execute script: {}", stringifyThrowable(cause));
} else if (cause instanceof PolyglotException) {
logger.error("Failed to execute script: {}", stringifyThrowable(cause));
}
return e;
}
private String stringifyThrowable(Throwable throwable) {
String message = throwable.getMessage();
StackTraceElement[] stackTraceElements = throwable.getStackTrace();
String stackTrace = Arrays.stream(stackTraceElements).limit(STACK_TRACE_LENGTH)
.map(t -> " at " + t.toString()).collect(Collectors.joining(System.lineSeparator()))
+ System.lineSeparator() + " ... " + stackTraceElements.length + " more";
return (message != null) ? message + System.lineSeparator() + stackTrace : stackTrace;
}
/**
* Initializes the logger.
* This cannot be done on script engine creation because the context variables are not yet initialized.
* Therefore, the logger needs to be initialized on the first use after script engine creation.
*/
private void initializeLogger() {
ScriptContext ctx = delegate.getContext();
Object fileName = ctx.getAttribute("javax.script.filename");
Object ruleUID = ctx.getAttribute("ruleUID");
Object ohEngineIdentifier = ctx.getAttribute("oh.engine-identifier");
String identifier = "stack";
if (fileName != null) {
identifier = fileName.toString().replaceAll("^.*[/\\\\]", "");
} else if (ruleUID != null) {
identifier = ruleUID.toString();
} else if (ohEngineIdentifier != null) {
if (ohEngineIdentifier.toString().startsWith(OPENHAB_TRANSFORMATION_SCRIPT)) {
identifier = ohEngineIdentifier.toString().replaceAll(OPENHAB_TRANSFORMATION_SCRIPT, "transformation.");
}
}
logger = LoggerFactory.getLogger("org.openhab.automation.script.javascript." + identifier);
}
@Override
public void lock() {
delegate.lock();
}
@Override
public void lockInterruptibly() throws InterruptedException {
delegate.lockInterruptibly();
}
@Override
public boolean tryLock() {
return delegate.tryLock();
}
@Override
public boolean tryLock(long l, TimeUnit timeUnit) throws InterruptedException {
return delegate.tryLock(l, timeUnit);
}
@Override
public void unlock() {
delegate.unlock();
}
@Override
public Condition newCondition() {
return delegate.newCondition();
}
}

View File

@ -32,6 +32,8 @@ import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
@ -50,7 +52,7 @@ import org.openhab.automation.jsscripting.internal.fs.DelegatingFileSystem;
import org.openhab.automation.jsscripting.internal.fs.PrefixedSeekableByteChannel;
import org.openhab.automation.jsscripting.internal.fs.ReadOnlySeekableByteArrayChannel;
import org.openhab.automation.jsscripting.internal.fs.watch.JSDependencyTracker;
import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable;
import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable;
import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
import org.openhab.core.items.Item;
import org.openhab.core.library.types.QuantityType;
@ -69,27 +71,27 @@ import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine;
* {@link Lock} for multi-thread synchronization; globals and openhab-js injection code caching
*/
public class OpenhabGraalJSScriptEngine
extends InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable<GraalJSScriptEngine> {
extends InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable<GraalJSScriptEngine>
implements Lock {
private static final Logger LOGGER = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);
private static Source GLOBAL_SOURCE;
private static final Source GLOBAL_SOURCE;
static {
try {
GLOBAL_SOURCE = Source.newBuilder("js", getFileAsReader("node_modules/@jsscripting-globals.js"),
"@jsscripting-globals.js").cached(true).build();
} catch (IOException e) {
throw new RuntimeException("Failed to load @jsscripting-globals.js", e);
throw new IllegalStateException("Failed to load @jsscripting-globals.js", e);
}
}
private static Source OPENHAB_JS_SOURCE;
private static final Source OPENHAB_JS_SOURCE;
static {
try {
OPENHAB_JS_SOURCE = Source
.newBuilder("js", getFileAsReader("node_modules/@openhab-globals.js"), "@openhab-globals.js")
.cached(true).build();
} catch (IOException e) {
throw new RuntimeException("Failed to load @openhab-globals.js", e);
throw new IllegalStateException("Failed to load @openhab-globals.js", e);
}
}
private static final String OPENHAB_JS_INJECTION_CODE = "Object.assign(this, require('openhab'));";
@ -129,13 +131,15 @@ public class OpenhabGraalJSScriptEngine
v -> v.getMember("rawQtyType").as(QuantityType.class), HostAccess.TargetMappingPrecedence.LOW)
.build();
private final Logger logger = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);
/** {@link Lock} synchronization of multi-thread access */
private final Lock lock = new ReentrantLock();
private final JSRuntimeFeatures jsRuntimeFeatures;
// these fields start as null because they are populated on first use
private String engineIdentifier;
private @Nullable Consumer<String> scriptDependencyListener;
private String engineIdentifier; // this field is very helpful for debugging, please do not remove it
private boolean initialized = false;
private final boolean injectionEnabled;
@ -152,8 +156,6 @@ public class OpenhabGraalJSScriptEngine
this.injectionCachingEnabled = injectionCachingEnabled;
this.jsRuntimeFeatures = jsScriptServiceUtil.getJSRuntimeFeatures(lock);
LOGGER.debug("Initializing GraalJS script engine...");
delegate = GraalJSScriptEngine.create(ENGINE,
Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true)
.allowHostAccess(HOST_ACCESS)
@ -161,8 +163,8 @@ public class OpenhabGraalJSScriptEngine
.option("js.nashorn-compat", "true") // Enable Nashorn compat mode as openhab-js relies on
// accessors, see
// https://github.com/oracle/graaljs/blob/master/docs/user/NashornMigrationGuide.md#accessors
.option("js.ecmascript-version", "2022") // If Nashorn compat is enabled, it will enforce ES5
// compatibility, we want ECMA2022
.option("js.ecmascript-version", "2024") // If Nashorn compat is enabled, it will enforce ES5
// compatibility, we want ECMA2024
.option("js.commonjs-require", "true") // Enable CommonJS module support
.hostClassLoader(getClass().getClassLoader())
.fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {
@ -227,7 +229,10 @@ public class OpenhabGraalJSScriptEngine
protected void beforeInvocation() {
super.beforeInvocation();
logger.debug("Initializing GraalJS script engine...");
lock.lock();
logger.debug("Lock acquired before invocation.");
if (initialized) {
return;
@ -239,10 +244,11 @@ public class OpenhabGraalJSScriptEngine
}
// these are added post-construction, so we need to fetch them late
this.engineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
if (this.engineIdentifier == null) {
String localEngineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
if (localEngineIdentifier == null) {
throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings");
}
this.engineIdentifier = localEngineIdentifier;
ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx
.getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR);
@ -250,52 +256,54 @@ public class OpenhabGraalJSScriptEngine
throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings");
}
scriptDependencyListener = (Consumer<String>) ctx
.getAttribute("oh.dependency-listener"/* CONTEXT_KEY_DEPENDENCY_LISTENER */);
if (scriptDependencyListener == null) {
LOGGER.warn(
Consumer<String> localScriptDependencyListener = (Consumer<String>) ctx
.getAttribute(CONTEXT_KEY_DEPENDENCY_LISTENER);
if (localScriptDependencyListener == null) {
logger.warn(
"Failed to retrieve script script dependency listener from engine bindings. Script dependency tracking will be disabled.");
}
scriptDependencyListener = localScriptDependencyListener;
ScriptExtensionModuleProvider scriptExtensionModuleProvider = new ScriptExtensionModuleProvider(
scriptExtensionAccessor, lock);
// Wrap the "require" function to also allow loading modules from the ScriptExtensionModuleProvider
Function<Function<Object[], Object>, Function<String, Object>> wrapRequireFn = originalRequireFn -> moduleName -> scriptExtensionModuleProvider
.locatorFor(delegate.getPolyglotContext(), engineIdentifier).locateModule(moduleName)
.locatorFor(delegate.getPolyglotContext(), localEngineIdentifier).locateModule(moduleName)
.map(m -> (Object) m).orElseGet(() -> originalRequireFn.apply(new Object[] { moduleName }));
delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(REQUIRE_WRAPPER_NAME, wrapRequireFn);
delegate.put("require", wrapRequireFn.apply((Function<Object[], Object>) delegate.get("require")));
// Injections into the JS runtime
jsRuntimeFeatures.getFeatures().forEach((key, obj) -> {
LOGGER.debug("Injecting {} into the JS runtime...", key);
logger.debug("Injecting {} into the JS runtime...", key);
delegate.put(key, obj);
});
initialized = true;
try {
LOGGER.debug("Evaluating cached global script...");
logger.debug("Evaluating cached global script...");
delegate.getPolyglotContext().eval(GLOBAL_SOURCE);
if (this.injectionEnabled) {
if (this.injectionCachingEnabled) {
LOGGER.debug("Evaluating cached openhab-js injection...");
logger.debug("Evaluating cached openhab-js injection...");
delegate.getPolyglotContext().eval(OPENHAB_JS_SOURCE);
} else {
LOGGER.debug("Evaluating openhab-js injection from the file system...");
logger.debug("Evaluating openhab-js injection from the file system...");
eval(OPENHAB_JS_INJECTION_CODE);
}
}
LOGGER.debug("Successfully initialized GraalJS script engine.");
logger.debug("Successfully initialized GraalJS script engine.");
} catch (ScriptException e) {
LOGGER.error("Could not inject global script", e);
logger.error("Could not inject global script", e);
}
}
@Override
protected Object afterInvocation(Object obj) {
lock.unlock();
logger.debug("Lock released after invocation.");
return super.afterInvocation(obj);
}
@ -316,7 +324,7 @@ public class OpenhabGraalJSScriptEngine
* @param path a root path
* @return whether the given path is a node root directory
*/
private boolean isRootNodePath(Path path) {
private static boolean isRootNodePath(Path path) {
return path.startsWith(path.getRoot().resolve(NODE_DIR));
}
@ -327,7 +335,7 @@ public class OpenhabGraalJSScriptEngine
* @param path a root path, e.g. C:\node_modules\foo.js
* @return the class resource path for loading local modules
*/
private String nodeFileToResource(Path path) {
private static String nodeFileToResource(Path path) {
return "/" + path.subpath(0, path.getNameCount()).toString().replace('\\', '/');
}
@ -344,4 +352,36 @@ public class OpenhabGraalJSScriptEngine
return new InputStreamReader(ioStream);
}
@Override
public void lock() {
lock.lock();
logger.debug("Lock acquired.");
}
@Override
public void lockInterruptibly() throws InterruptedException {
lock.lockInterruptibly();
}
@Override
public boolean tryLock() {
return lock.tryLock();
}
@Override
public boolean tryLock(long l, TimeUnit timeUnit) throws InterruptedException {
return lock.tryLock(l, timeUnit);
}
@Override
public void unlock() {
lock.unlock();
logger.debug("Lock released.");
}
@Override
public Condition newCondition() {
return lock.newCondition();
}
}

View File

@ -48,9 +48,10 @@ public class JSScriptFileWatcher extends AbstractScriptFileWatcher {
@Override
protected Optional<String> getScriptType(Path scriptFilePath) {
String scriptType = super.getScriptType(scriptFilePath).orElse(null);
if (!scriptFilePath.startsWith(getWatchPath().resolve("node_modules")) && ("js".equals(scriptType))) {
return Optional.of(scriptType);
Optional<String> scriptType = super.getScriptType(scriptFilePath);
if (scriptType.isPresent() && !scriptFilePath.startsWith(getWatchPath().resolve("node_modules"))
&& ("js".equals(scriptType.get()))) {
return scriptType;
}
return Optional.empty();
}

View File

@ -15,6 +15,8 @@ package org.openhab.automation.jsscripting.internal.scriptengine;
import java.io.Reader;
import javax.script.Bindings;
import javax.script.Compilable;
import javax.script.CompiledScript;
import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
@ -29,11 +31,11 @@ import org.eclipse.jdt.annotation.NonNull;
*
* @author Jonathan Gilbert - Initial contribution
*/
public abstract class DelegatingScriptEngineWithInvocableAndAutocloseable<T extends ScriptEngine & Invocable & AutoCloseable>
implements ScriptEngine, Invocable, AutoCloseable {
public abstract class DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable<T extends ScriptEngine & Invocable & Compilable & AutoCloseable>
implements ScriptEngine, Invocable, Compilable, AutoCloseable {
protected @NonNull T delegate;
public DelegatingScriptEngineWithInvocableAndAutocloseable(@NonNull T delegate) {
public DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable(@NonNull T delegate) {
this.delegate = delegate;
}
@ -128,6 +130,16 @@ public abstract class DelegatingScriptEngineWithInvocableAndAutocloseable<T exte
return delegate.getInterface(o, aClass);
}
@Override
public CompiledScript compile(String s) throws ScriptException {
return delegate.compile(s);
}
@Override
public CompiledScript compile(Reader reader) throws ScriptException {
return delegate.compile(reader);
}
@Override
public void close() throws Exception {
delegate.close();

View File

@ -16,22 +16,24 @@ import java.io.Reader;
import java.lang.reflect.UndeclaredThrowableException;
import javax.script.Bindings;
import javax.script.Compilable;
import javax.script.CompiledScript;
import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptException;
/**
* Delegate allowing AOP-style interception of calls, either before Invocation, or upon a {@link ScriptException}.
* being thrown.
* Delegate allowing AOP-style interception of calls, either before Invocation, or upon a {@link ScriptException} being
* thrown.
*
* @param <T> The delegate class
* @author Jonathan Gilbert - Initial contribution
*/
public abstract class InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable<T extends ScriptEngine & Invocable & AutoCloseable>
extends DelegatingScriptEngineWithInvocableAndAutocloseable<T> {
public abstract class InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable<T extends ScriptEngine & Invocable & Compilable & AutoCloseable>
extends DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable<T> {
public InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable(T delegate) {
public InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable(T delegate) {
super(delegate);
}
@ -155,4 +157,28 @@ public abstract class InvocationInterceptingScriptEngineWithInvocableAndAutoClos
throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
}
}
@Override
public CompiledScript compile(String s) throws ScriptException {
try {
beforeInvocation();
return (CompiledScript) afterInvocation(super.compile(s));
} catch (ScriptException se) {
throw (ScriptException) afterThrowsInvocation(se);
} catch (Exception e) {
throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
}
}
@Override
public CompiledScript compile(Reader reader) throws ScriptException {
try {
beforeInvocation();
return (CompiledScript) afterInvocation(super.compile(reader));
} catch (ScriptException se) {
throw (ScriptException) afterThrowsInvocation(se);
} catch (Exception e) {
throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
}
}
}

View File

@ -27,6 +27,8 @@ import org.openhab.core.automation.module.script.action.Timer;
import org.openhab.core.scheduler.ScheduledCompletableFuture;
import org.openhab.core.scheduler.Scheduler;
import org.openhab.core.scheduler.SchedulerTemporalAdjuster;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A polyfill implementation of NodeJS timer functionality (<code>setTimeout()</code>, <code>setInterval()</code> and
@ -36,13 +38,15 @@ import org.openhab.core.scheduler.SchedulerTemporalAdjuster;
* Threadsafe reimplementation of the timer creation methods of {@link ScriptExecution}
*/
public class ThreadsafeTimers {
private final Logger logger = LoggerFactory.getLogger(ThreadsafeTimers.class);
private final Lock lock;
private final Scheduler scheduler;
private final ScriptExecution scriptExecution;
// Mapping of positive, non-zero integer values (used as timeoutID or intervalID) and the Scheduler
private final Map<Long, ScheduledCompletableFuture<Object>> idSchedulerMapping = new ConcurrentHashMap<>();
private AtomicLong lastId = new AtomicLong();
private String identifier = "noIdentifier";
private String identifier = "javascript";
public ThreadsafeTimers(Lock lock, ScriptExecution scriptExecution, Scheduler scheduler) {
this.lock = lock;
@ -81,11 +85,13 @@ public class ThreadsafeTimers {
public Timer createTimer(@Nullable String identifier, ZonedDateTime instant, Runnable closure) {
return scriptExecution.createTimer(identifier, instant, () -> {
lock.lock();
logger.debug("Lock acquired before timer execution");
try {
closure.run();
} finally { // Make sure that Lock is unlocked regardless of an exception is thrown or not to avoid
} finally { // Make sure that Lock is unlocked regardless of an exception being thrown or not to avoid
// deadlocks
lock.unlock();
logger.debug("Lock released after timer execution");
}
});
}
@ -99,22 +105,28 @@ public class ThreadsafeTimers {
* @return Positive integer value which identifies the timer created; this value can be passed to
* <code>clearTimeout()</code> to cancel the timeout.
*/
public long setTimeout(Runnable callback, Long delay) {
public long setTimeout(Runnable callback, long delay) {
long id = lastId.incrementAndGet();
ScheduledCompletableFuture<Object> future = scheduler.schedule(() -> {
lock.lock();
logger.debug("Lock acquired before timeout execution");
try {
callback.run();
idSchedulerMapping.remove(id);
} finally { // Make sure that Lock is unlocked regardless of an exception is thrown or not to avoid
} finally { // Make sure that Lock is unlocked regardless of an exception being thrown or not to avoid
// deadlocks
lock.unlock();
logger.debug("Lock released after timeout execution");
}
}, identifier + ".timeout." + id, Instant.now().plusMillis(delay));
idSchedulerMapping.put(id, future);
return id;
}
public long setTimeout(Runnable callback, double delay) {
return setTimeout(callback, Math.round(delay));
}
/**
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout"><code>clearTimeout()</code></a> polyfill.
* Cancels a timeout previously created by <code>setTimeout()</code>.
@ -138,21 +150,27 @@ public class ThreadsafeTimers {
* @return Numeric, non-zero value which identifies the timer created; this value can be passed to
* <code>clearInterval()</code> to cancel the interval.
*/
public long setInterval(Runnable callback, Long delay) {
public long setInterval(Runnable callback, long delay) {
long id = lastId.incrementAndGet();
ScheduledCompletableFuture<Object> future = scheduler.schedule(() -> {
lock.lock();
logger.debug("Lock acquired before interval execution");
try {
callback.run();
} finally { // Make sure that Lock is unlocked regardless of an exception is thrown or not to avoid
} finally { // Make sure that Lock is unlocked regardless of an exception being thrown or not to avoid
// deadlocks
lock.unlock();
logger.debug("Lock released after interval execution");
}
}, identifier + ".interval." + id, new LoopingAdjuster(Duration.ofMillis(delay)));
idSchedulerMapping.put(id, future);
return id;
}
public long setInterval(Runnable callback, double delay) {
return setInterval(callback, Math.round(delay));
}
/**
* <a href="https://developer.mozilla.org/en-US/docs/Web/API/clearInterval"><code>clearInterval()</code></a>
* polyfill.

View File

@ -1,2 +0,0 @@
com.oracle.truffle.regex.RegexLanguageProvider
com.oracle.truffle.js.lang.JavaScriptLanguageProvider

View File

@ -1,8 +1,15 @@
# add-on
addon.jsscripting.name = Javascript programok
addon.jsscripting.description = Ez a JavaScript (ECMAScript-2021) programozó motor.
# add-on
automation.config.jsscripting.injectionCachingEnabled.label = Az openHAB Javascript könyvtár betöltésének gyorsítótárazása
automation.config.jsscripting.injectionCachingEnabled.description = Az openHAB JavaScript könyvtár betöltésének gyorsítótárazását végzi<br>Kapcsolja ki, ha a könyvtárat a felhasználó helyi konfigurációs könyvtárából akarja betölteni "automation/node_modules". A kikapcsolás megnövelheti a betöltési időt, főleg gyengébb rendszereken.
automation.config.jsscripting.injectionCachingEnabled.option.true = Könyvtár betöltés gyorsítótárazása
automation.config.jsscripting.injectionCachingEnabled.option.false = Ne gyorsítótárazza a könyvtár betöltését
automation.config.jsscripting.injectionEnabled.label = Beépített teljes körű változók használata
automation.config.jsscripting.injectionEnabled.description = Az összes változó betöltése az OH szkript könyvtárból az összes közös szolgáltatási szabály számára, pl.\: elemek, dolgok, akciók, naplók, stb... <br> Ha kikapcsolja, az OH szkript könyvtárt betöltheti a "<i>require('openhab')</i>" paranccsal
automation.config.jsscripting.injectionEnabled.option.true = Beépített változók használata
automation.config.jsscripting.injectionEnabled.option.false = Ne használja a beépített változókat
# service
service.automation.jsscripting.label = JS szkriptek

View File

@ -0,0 +1,15 @@
# add-on
addon.jsscripting.name = JavaScript Scripting
addon.jsscripting.description = Dit voegt een JS (ECMAScript-2021) scriptengine toe.
# add-on
automation.config.jsscripting.injectionCachingEnabled.label = Cache openHAB JavaScript Bibliotheek Injectie
automation.config.jsscripting.injectionCachingEnabled.description = Cache de openHAB JavaScript bibliotheek injectie voor optimale prestaties.<br>Schakel deze optie uit om het laden van de bibliotheek uit de lokale gebruikersconfiguratie folder "automation/js/node_modules" toe te staan. Het uitschakelen van caching kan de laadtijd van scripts verhogen, vooral bij minder krachtige systemen.
automation.config.jsscripting.injectionCachingEnabled.option.true = Bibliotheek Injectie Cache
automation.config.jsscripting.injectionCachingEnabled.option.false = Geen Bibliotheek Injectie Cache
automation.config.jsscripting.injectionEnabled.label = Gebruik Ingebouwde Globale Variabelen
automation.config.jsscripting.injectionEnabled.description = Importeer alle variabelen uit de openHAB JavaScript bibliotheek in alle rules en scripts voor algemene services zoals items, things, actions, log, enz... <br> Als dit is uitgeschakeld, kan de openHAB JavaScript bibliotheek handmatig worden geïmporteerd met "<i>require('openhab')</i>"
automation.config.jsscripting.injectionEnabled.option.true = Ingebouwde Variabelen
automation.config.jsscripting.injectionEnabled.option.false = Geen Ingebouwde Variabelen

View File

@ -1,21 +1,37 @@
// ThreadsafeTimers is injected into the JS runtime
// The default identifier of the script has to be computed on script executon, as it is not available at the time of script compilation
(function (global) {
'use strict';
// Append the script file name OR rule UID depending on which is available
const defaultIdentifier = 'org.openhab.automation.script' + (globalThis['javax.script.filename'] ? '.file.' + globalThis['javax.script.filename'].replace(/^.*[\\\/]/, '') : globalThis.ruleUID ? '.ui.' + globalThis.ruleUID : '');
const System = Java.type('java.lang.System');
const formatRegExp = /%[sdj%]/g;
// Pass the defaultIdentifier to ThreadsafeTimers to enable naming of scheduled jobs
ThreadsafeTimers.setIdentifier(defaultIdentifier);
function createLogger (name = defaultIdentifier) {
return Java.type('org.slf4j.LoggerFactory').getLogger(name);
/**
* Gets the default identifier of the script.
* @returns {string}
*/
function getDefaultIdentifier () {
return 'org.openhab.automation.script' + (globalThis['javax.script.filename'] ? '.file.' + globalThis['javax.script.filename'].replace(/^.*[\\\/]/, '') : globalThis.ruleUID ? '.ui.' + globalThis.ruleUID : '');
}
// User configurable
let log = createLogger();
/**
* Gets a logger instance for given name.
* @param {string} [name] optional logger name, defaults to the default identifier of the script (see {@link getDefaultIdentifier})
* @returns {*} logger instance
*/
function getLogger (name) {
if (typeof name === 'string') {
log = Java.type('org.slf4j.LoggerFactory').getLogger(name);
}
if (log === null) {
log = Java.type('org.slf4j.LoggerFactory').getLogger(getDefaultIdentifier());
}
return log;
}
let log = null;
function stringify (value) {
try {
@ -86,7 +102,7 @@
const console = {
assert: function (expression, message) {
if (!expression) {
log.error(message);
getLogger().error(message);
}
},
@ -102,32 +118,32 @@
// update
counters[label] = ++counter;
log.debug(format.apply(null, [label + ':', counter]));
getLogger().debug(format.apply(null, [label + ':', counter]));
}
},
debug: function () {
log.debug(format.apply(null, arguments));
getLogger().debug(format.apply(null, arguments));
},
info: function () {
log.info(format.apply(null, arguments));
getLogger().info(format.apply(null, arguments));
},
log: function () {
log.info(format.apply(null, arguments));
getLogger().info(format.apply(null, arguments));
},
warn: function () {
log.warn(format.apply(null, arguments));
getLogger().warn(format.apply(null, arguments));
},
error: function () {
log.error(format.apply(null, arguments));
getLogger().error(format.apply(null, arguments));
},
trace: function () {
log.trace(new Error(format.apply(null, arguments)).stack.replace(/^Error: /, ''));
getLogger().trace(new Error(format.apply(null, arguments)).stack.replace(/^Error: /, ''));
},
time: function (label) {
@ -140,10 +156,10 @@
if (label) {
const now = System.currentTimeMillis();
if (timers.hasOwnProperty(label)) {
log.info(format.apply(null, [label + ':', (now - timers[label]) + 'ms']));
getLogger().info(format.apply(null, [label + ':', (now - timers[label]) + 'ms']));
delete timers[label];
} else {
log.info(format.apply(null, [label + ':', '<no timer>']));
getLogger().info(format.apply(null, [label + ':', '<no timer>']));
}
}
},
@ -151,23 +167,25 @@
// Allow user customizable logging names
// Be aware that a log4j2 required a logger defined for the logger name, otherwise messages won't be logged!
set loggerName (name) {
log = createLogger(name);
getLogger(name);
this._loggerName = name;
ThreadsafeTimers.setIdentifier(name);
},
get loggerName () {
return this._loggerName || defaultIdentifier;
return this._loggerName || getDefaultIdentifier();
}
};
// Polyfill common NodeJS functions onto the global object
globalThis.console = console;
globalThis.setTimeout = function (functionRef, delay, ...args) {
ThreadsafeTimers.setIdentifier(console.loggerName);
return ThreadsafeTimers.setTimeout(() => functionRef(...args), delay);
};
globalThis.clearTimeout = ThreadsafeTimers.clearTimeout;
globalThis.setInterval = function (functionRef, delay, ...args) {
ThreadsafeTimers.setIdentifier(console.loggerName);
return ThreadsafeTimers.setInterval(() => functionRef(...args), delay);
};
globalThis.clearInterval = ThreadsafeTimers.clearInterval;

View File

@ -1,2 +1,3 @@
# Please check here how to add suppressions https://maven.apache.org/plugins/maven-pmd-plugin/examples/violation-exclusions.html
org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable=AvoidThrowingNullPointerException,AvoidCatchingNPE
org.openhab.automation.jsscripting.internal.OpenhabGraalJSScriptEngine=UnusedPrivateField
org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable=AvoidThrowingNullPointerException,AvoidCatchingNPE

View File

@ -21,7 +21,7 @@ If you create an empty file called `test.nashornjs`, you will see a log line wit
To enable debug logging, use the [console logging]({{base}}/administration/logging.html) commands to enable debug logging for the automation functionality:
```text
```shell
log:set DEBUG org.openhab.core.automation
```

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.automation.jsscriptingnashorn</artifactId>

View File

@ -1,23 +1,16 @@
# Jython Scripting (DEPRECATED)
# Jython Scripting
::: tip Note:
Currently, the development of Jython stopped at version 2.7 with no definite timeline to support Python 3.x.
The 3rd party openHAB helper library for Jython is also no longer maintained.
We would not recommend using Jython scripting at this point in time.
For alternatives, check out the list of other supported [automation add-ons](https://www.openhab.org/addons/#automation).
:::
This add-on provides [Jython](https://www.jython.org/) 2.7.2 that can be used as a scripting language within automation rules and which eliminates the need to download Jython and create `EXTRA_JAVA_OPTS` entries for `bootclasspath`, `python.home` and `python.path`.
This add-on provides [Jython](https://www.jython.org/) 2.7 that can be used as a scripting language within automation rules and which eliminates the need to download Jython and create `EXTRA_JAVA_OPTS` entries for `bootclasspath`, `python.home` and `python.path`.
The `python.home` system property is set to the path of the add-on.
The `python.path` system property is set to `$OPENHAB_CONF/automation/lib/python`, but any existing `python.path` will be appended to it.
The `python.path` system property is set to `$OPENHAB_CONF/automation/jython/lib`, but any existing `python.path` will be appended to it.
## Creating Jython Scripts
When this add-on is installed, you can select Jython as a scripting language when creating a script action within the rule editor of the UI.
Alternatively, you can create scripts in the `automation/jsr223` configuration directory.
Alternatively, you can create scripts in the `automation/jython` configuration directory.
If you create an empty file called `test.py`, you will see a log line with information similar to:
```text

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.automation.jythonscripting</artifactId>
@ -16,14 +16,14 @@
<properties>
<bnd.fixupmessages><![CDATA["Classes found in the wrong directory","The default package '.' is not permitted by the Import-Package syntax"; restrict:=error; is:=warning]]></bnd.fixupmessages>
<bnd.importpackage>*blockhound*;resolution:=optional,com.cloudius.util;resolution:=optional,com.github.luben.zstd;resolution:=optional,com.informix.jdbc;resolution:=optional,com.jcraft.jzlib;resolution:=optional,com.ning.compress.*;resolution:=optional,com.oracle.svm.core.annotate;resolution:=optional,com.sun.management;resolution:=optional,custom_proxymaker.tests;resolution:=optional,jnr.*;resolution;resolution:=optional,*jpountz*;resolution:=optional,junit.framework;resolution:=optional,lzma.sdk.*;resolution:=optional,oracle.*;resolution:=optional,org.antlr.stringtemplate;resolution:=optional,org.apache.tools.*;resolution:=optional,org.brotli.dec;resolution:=optional,org.checkerframework.*;resolution:=optional,org.conscrypt;resolution:=optional,org.eclipse.jetty.*;resolution:=optional,org.hamcrest;resolution:=optional,org.jboss.marshalling;resolution:=optional,org.junit.*;resolution:=optional,org.python.apache.xml.resolver.*;resolution:=optional,org.python.google.*;resolution:=optional,org.python.netty.internal.tcnative;resolution:=optional,org.python.objectweb.asm.tree.*;resolution:=optional,org.python.proxies;resolution:=optional,org.tukaani.xz;resolution:=optional,sun.*;resolution:=optional</bnd.importpackage>
<bnd.importpackage>*blockhound*;resolution:=optional,com.cloudius.util;resolution:=optional,com.github.luben.zstd;resolution:=optional,com.informix.jdbc;resolution:=optional,com.jcraft.jzlib;resolution:=optional,com.ning.compress.*;resolution:=optional,com.oracle.svm.core.annotate;resolution:=optional,com.sun.management;resolution:=optional,custom_proxymaker.tests;resolution:=optional,jnr.*;resolution;resolution:=optional,*jpountz*;resolution:=optional,junit.framework;resolution:=optional,lzma.sdk.*;resolution:=optional,oracle.*;resolution:=optional,org.antlr.stringtemplate;resolution:=optional,org.apache.tools.*;resolution:=optional,org.brotli.dec;resolution:=optional,org.checkerframework.*;resolution:=optional,org.conscrypt;resolution:=optional,org.eclipse.jetty.*;resolution:=optional,org.hamcrest;resolution:=optional,org.jboss.marshalling;resolution:=optional,org.junit.*;resolution:=optional,org.python.apache.xml.resolver.*;resolution:=optional,org.python.google.*;resolution:=optional,org.python.netty.internal.tcnative;resolution:=optional,org.python.objectweb.asm.tree.*;resolution:=optional,org.python.proxies;resolution:=optional,org.tukaani.xz;resolution:=optional,sun.*;resolution:=optional,com.aayushatharva.brotli4j.*;resolution:=optional,javax.annotation.meta;resolution:=optional,org.python.bouncycastle.*;resolution:=optional</bnd.importpackage>
</properties>
<dependencies>
<dependency>
<groupId>org.python</groupId>
<artifactId>jython-standalone</artifactId>
<version>2.7.2</version>
<version>2.7.3</version>
<scope>compile</scope>
</dependency>
</dependencies>

View File

@ -10,15 +10,15 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.automation.jythonscripting;
package org.openhab.automation.jythonscripting.internal;
import java.io.File;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Stream;
import javax.script.ScriptEngine;
@ -36,93 +36,84 @@ import org.osgi.service.component.annotations.Deactivate;
*
* @author Scott Rushworth - Initial contribution
* @author Wouter Born - Initial contribution
* @author Holger Hees - Further development
*/
@Component(service = ScriptEngineFactory.class)
@NonNullByDefault
public class JythonScriptEngineFactory extends AbstractScriptEngineFactory {
private static final String PYTHON_CACHEDIR = "python.cachedir";
private static final String PYTHON_HOME = "python.home";
private static final String PYTHON_HOME_PATH = JythonScriptEngineFactory.class.getProtectionDomain().getCodeSource()
.getLocation().toString().replace("file:", "");
private static final String PYTHON_PATH = "python.path";
private static final String PYTHON_DEFAULT_PATH = Paths
.get(OpenHAB.getConfigFolder(), "automation", "jython", "lib").toString();
private static final String DEFAULT_PYTHON_PATH = Paths
.get(OpenHAB.getConfigFolder(), "automation", "lib", "python").toString();
private static final String PYTHON_CACHEDIR = "python.cachedir";
private static final String PYTHON_CACHEDIR_PATH = Paths
.get(OpenHAB.getUserDataFolder(), "cache", JythonScriptEngineFactory.class.getPackageName(), "cachedir")
.toString();
private static final String SCRIPT_TYPE = "py";
private static final javax.script.ScriptEngineManager ENGINE_MANAGER = new javax.script.ScriptEngineManager();
private static final org.python.jsr223.PyScriptEngineFactory factory = new org.python.jsr223.PyScriptEngineFactory();
private final List<String> scriptTypes = (List<String>) Stream.of(factory.getExtensions(), factory.getMimeTypes())
.flatMap(List::stream) //
.toList();
@Activate
public JythonScriptEngineFactory() {
logger.debug("Loading JythonScriptEngineFactory");
String pythonHome = JythonScriptEngineFactory.class.getProtectionDomain().getCodeSource().getLocation()
.toString().replace("file:", "");
System.setProperty(PYTHON_HOME, pythonHome);
System.setProperty(PYTHON_HOME, PYTHON_HOME_PATH);
Set<String> pythonPathList = new TreeSet<>(Arrays.asList(PYTHON_DEFAULT_PATH));
String existingPythonPath = System.getProperty(PYTHON_PATH);
if (existingPythonPath != null && !existingPythonPath.isEmpty()) {
pythonPathList.addAll(Arrays.asList(existingPythonPath.split(File.pathSeparator)));
}
System.setProperty(PYTHON_PATH, String.join(File.pathSeparator, pythonPathList));
System.setProperty(PYTHON_CACHEDIR, PYTHON_CACHEDIR_PATH);
logPythonPaths();
}
@Deactivate
public void cleanup() {
logger.debug("Unloading JythonScriptEngineFactory");
System.clearProperty(PYTHON_HOME);
String existingPythonPath = System.getProperty(PYTHON_PATH);
if (existingPythonPath == null || existingPythonPath.isEmpty()) {
System.setProperty(PYTHON_PATH, DEFAULT_PYTHON_PATH);
} else if (!existingPythonPath.contains(DEFAULT_PYTHON_PATH)) {
if (existingPythonPath != null && !existingPythonPath.isEmpty()) {
Set<String> newPythonPathList = new TreeSet<>(Arrays.asList(existingPythonPath.split(File.pathSeparator)));
newPythonPathList.add(DEFAULT_PYTHON_PATH);
newPythonPathList.remove(PYTHON_DEFAULT_PATH);
System.setProperty(PYTHON_PATH, String.join(File.pathSeparator, newPythonPathList));
}
System.setProperty(PYTHON_CACHEDIR, Paths
.get(OpenHAB.getUserDataFolder(), "cache", JythonScriptEngineFactory.class.getPackageName(), "cachedir")
.toString());
System.clearProperty(PYTHON_CACHEDIR);
logPythonPaths();
}
@Override
public List<String> getScriptTypes() {
return scriptTypes;
}
@Override
public @Nullable ScriptEngine createScriptEngine(String scriptType) {
if (!scriptTypes.contains(scriptType)) {
return null;
}
return factory.getScriptEngine();
}
private void logPythonPaths() {
logger.trace("{}: {}, {}: {}, {}: {}", //
PYTHON_HOME, System.getProperty(PYTHON_HOME), //
PYTHON_PATH, System.getProperty(PYTHON_PATH), //
PYTHON_CACHEDIR, System.getProperty(PYTHON_CACHEDIR));
}
@Override
public List<String> getScriptTypes() {
List<String> scriptTypes = new ArrayList<>();
for (javax.script.ScriptEngineFactory factory : ENGINE_MANAGER.getEngineFactories()) {
List<String> extensions = factory.getExtensions();
if (extensions.contains(SCRIPT_TYPE)) {
scriptTypes.addAll(extensions);
scriptTypes.addAll(factory.getMimeTypes());
}
}
return scriptTypes;
}
@Override
public @Nullable ScriptEngine createScriptEngine(String scriptType) {
ScriptEngine scriptEngine = ENGINE_MANAGER.getEngineByExtension(scriptType);
if (scriptEngine == null) {
scriptEngine = ENGINE_MANAGER.getEngineByMimeType(scriptType);
}
if (scriptEngine == null) {
scriptEngine = ENGINE_MANAGER.getEngineByName(scriptType);
}
return scriptEngine;
}
@Deactivate
public void removePythonPath() {
logger.debug("Unloading JythonScriptEngineFactory");
String existingPythonPath = System.getProperty(PYTHON_PATH);
if (existingPythonPath != null && existingPythonPath.contains(DEFAULT_PYTHON_PATH)) {
Set<String> newPythonPathList = new TreeSet<>(Arrays.asList(existingPythonPath.split(File.pathSeparator)));
newPythonPathList.remove(DEFAULT_PYTHON_PATH);
System.setProperty(PYTHON_PATH, String.join(File.pathSeparator, newPythonPathList));
}
System.clearProperty(PYTHON_HOME);
System.clearProperty(PYTHON_CACHEDIR);
logPythonPaths();
}
}

View File

@ -11,10 +11,11 @@
* SPDX-License-Identifier: EPL-2.0
*/
@org.osgi.annotation.bundle.Header(name = org.osgi.framework.Constants.DYNAMICIMPORT_PACKAGE, value = "*")
package org.openhab.automation.jythonscripting;
package org.openhab.automation.jythonscripting.internal;
/**
* Additional information for the Jython Scripting package
*
* @author Wouter Born - Initial contribution
* @author Holger Hees - Further development
*/

View File

@ -0,0 +1,58 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.automation.jythonscripting.internal.watch;
import java.io.File;
import java.nio.file.Path;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.automation.module.script.ScriptDependencyTracker;
import org.openhab.core.automation.module.script.ScriptEngineManager;
import org.openhab.core.automation.module.script.rulesupport.loader.AbstractScriptFileWatcher;
import org.openhab.core.automation.module.script.rulesupport.loader.ScriptFileWatcher;
import org.openhab.core.service.ReadyService;
import org.openhab.core.service.StartLevelService;
import org.openhab.core.service.WatchService;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* Monitors {@code <openHAB-conf>/automation/jython} for Jython files, but not libraries
*
* @author Holger Hees - Initial contribution
*/
@Component(immediate = true, service = { ScriptFileWatcher.class, ScriptDependencyTracker.Listener.class })
@NonNullByDefault
public class JythonScriptFileWatcher extends AbstractScriptFileWatcher {
private static final String FILE_DIRECTORY = "automation" + File.separator + "jython";
@Activate
public JythonScriptFileWatcher(
final @Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService,
final @Reference ScriptEngineManager manager, final @Reference ReadyService readyService,
final @Reference StartLevelService startLevelService) {
super(watchService, manager, readyService, startLevelService, FILE_DIRECTORY, true);
}
@Override
protected Optional<String> getScriptType(Path scriptFilePath) {
Optional<String> scriptType = super.getScriptType(scriptFilePath);
if (scriptType.isPresent() && !scriptFilePath.startsWith(getWatchPath().resolve("lib"))
&& ("py".equals(scriptType.get()))) {
return scriptType;
}
return Optional.empty();
}
}

View File

@ -1,11 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="pidcontroller" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
<addon:addon id="jythonscripting" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>automation</type>
<name>Jython Scripting (DEPRECATED)</name>
<name>Jython Scripting</name>
<description>This adds a Jython script engine.</description>
<connection>none</connection>
<service-id>org.openhab.automation.jythonscripting</service-id>
<config-description-ref uri="automation:jythonscripting"/>
</addon:addon>

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.automation.pidcontroller</artifactId>

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.automation.pwm</artifactId>

View File

@ -73,7 +73,7 @@ Remember that the host and port parameter are not needed in most cases.
As discussed above care is taken that the brightness channel only allows values from 1 to 100 by specifying a min and max value in the sitemap for the dimmers.
For this example to run on an openHAB version older than 2.5 Bedroom 1's Slider must be removed in the sitemap since older versions don't support the min/max setting.
## demo.things
## `demo.things` Example
```java
Bridge adorne:hub:home "Adorne Hub" [host="192.160.1.111", port=2113] {
@ -83,7 +83,7 @@ Bridge adorne:hub:home "Adorne Hub" [host="192.160.1.111", port=2113] {
}
```
## demo.items
## `demo.items` Example
```java
Switch LightBathroom {channel="adorne:switch:home:bathroom:power"}
@ -93,7 +93,7 @@ Switch LightBedroomSwitch2 {channel="adorne:dimmer:home:bedroom2:power"}
Dimmer LightBedroomDimmer2 {channel="adorne:dimmer:home:bedroom2:brightness"}
```
## demo.sitemap
## `demo.sitemap` Example
```perl
sitemap demo label="Adorne Binding Demo"

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.adorne</artifactId>

View File

@ -55,7 +55,7 @@ The thing **aha Waste Collection Schedule** provides four channels for the upcom
wasteCollection.things
```java
Thing ahawastecollection:collectionSchedule:wasteCollectionSchedule "aha Abfuhrkalender" [ commune="Isernhagen", street="67269@Rosmarinweg+/+Kirchhorst@Kirchhorst", houseNumber="10", houseNumberAddon="", collectionPlace="67269-0010+" ]
Thing ahawastecollection:collectionSchedule:wasteCollectionSchedule "aha Abfuhrkalender" [ commune="Isernhagen", street="67269@Rosmarinweg+/+Kirchhorst@Kirchhorst", houseNumber="10", houseNumberAddon="", collectionPlace="67269-0010+" ]
```
wasteCollection.items
@ -99,7 +99,6 @@ actions:
var papierDate = items['collectionDay_paper'].getZonedDateTime();
var restmuellDate = items['collectionDay_generalWaste'].getZonedDateTime();
// Check which waste types are collected on the next day
var biomuellCollection = biomuellDate.equals(tomorrow);
var leichtverpackungCollection = leichtverpackungDate.equals(tomorrow);
@ -125,7 +124,6 @@ actions:
toBeCollected.push('general waste');
}
// Send message (or something else) if at least one waste type is collected
if (toBeCollected.length > 0) {
var message = "Tomorrow the following waste will be collected:\n" + toBeCollected.join(', ');

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.ahawastecollection</artifactId>

View File

@ -14,7 +14,6 @@ package org.openhab.binding.ahawastecollection.internal;
import java.io.IOException;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.Date;
import java.util.Map;
@ -26,7 +25,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ahawastecollection.internal.CollectionDate.WasteType;
import org.openhab.core.cache.ExpiringCache;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.scheduler.CronScheduler;
import org.openhab.core.scheduler.ScheduledCompletableFuture;
@ -57,7 +55,6 @@ public class AhaWasteCollectionHandler extends BaseThingHandler {
private final Lock monitor = new ReentrantLock();
private final ExpiringCache<Map<WasteType, CollectionDate>> cache;
private final TimeZoneProvider timeZoneProvider;
private final Logger logger = LoggerFactory.getLogger(AhaWasteCollectionHandler.class);
private @Nullable AhaCollectionSchedule collectionSchedule;
@ -69,11 +66,10 @@ public class AhaWasteCollectionHandler extends BaseThingHandler {
private final ScheduledExecutorService executorService;
public AhaWasteCollectionHandler(final Thing thing, final CronScheduler scheduler,
final TimeZoneProvider timeZoneProvider, final AhaCollectionScheduleFactory scheduleFactory,
final AhaCollectionScheduleFactory scheduleFactory,
@Nullable final ScheduledExecutorService executorService) {
super(thing);
this.cronScheduler = scheduler;
this.timeZoneProvider = timeZoneProvider;
this.scheduleFactory = scheduleFactory;
this.cache = new ExpiringCache<>(Duration.ofMinutes(5), this::loadCollectionDates);
this.executorService = executorService == null ? this.scheduler : executorService;
@ -190,9 +186,7 @@ public class AhaWasteCollectionHandler extends BaseThingHandler {
final Date nextCollectionDate = collectionDate.getDates().get(0);
final ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(nextCollectionDate.toInstant(),
this.timeZoneProvider.getTimeZone());
this.updateState(channel.getUID(), new DateTimeType(zonedDateTime));
this.updateState(channel.getUID(), new DateTimeType(nextCollectionDate.toInstant()));
}
}

View File

@ -18,7 +18,6 @@ import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.scheduler.CronScheduler;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
@ -40,7 +39,6 @@ public class AhaWasteCollectionHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_SCHEDULE);
private final CronScheduler scheduler;
private final TimeZoneProvider timeZoneProvider;
@Override
public boolean supportsThingType(final ThingTypeUID thingTypeUID) {
@ -48,10 +46,8 @@ public class AhaWasteCollectionHandlerFactory extends BaseThingHandlerFactory {
}
@Activate
public AhaWasteCollectionHandlerFactory(final @Reference CronScheduler scheduler,
final @Reference TimeZoneProvider timeZoneProvider) {
public AhaWasteCollectionHandlerFactory(final @Reference CronScheduler scheduler) {
this.scheduler = scheduler;
this.timeZoneProvider = timeZoneProvider;
}
@Override
@ -59,8 +55,7 @@ public class AhaWasteCollectionHandlerFactory extends BaseThingHandlerFactory {
final ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_SCHEDULE.equals(thingTypeUID)) {
return new AhaWasteCollectionHandler(thing, this.scheduler, this.timeZoneProvider,
AhaCollectionScheduleImpl::new, null);
return new AhaWasteCollectionHandler(thing, this.scheduler, AhaCollectionScheduleImpl::new, null);
}
return null;
}

View File

@ -15,8 +15,6 @@ package org.openhab.binding.ahawastecollection.internal;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Date;
import java.util.Map;
@ -136,15 +134,14 @@ public class AhaWasteCollectionHandlerTest {
}).when(executorStub).execute(any(Runnable.class));
final AhaWasteCollectionHandler handler = new AhaWasteCollectionHandler(thing, createStubScheduler(),
ZoneId::systemDefault, new AhaCollectionScheduleStubFactory(), executorStub);
new AhaCollectionScheduleStubFactory(), executorStub);
handler.setCallback(callback);
handler.initialize();
return handler;
}
private static State getDateTime(final Date day) {
final ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(day.toInstant(), ZoneId.systemDefault());
return new DateTimeType(zonedDateTime);
return new DateTimeType(day.toInstant());
}
@Test

View File

@ -0,0 +1,119 @@
# AirGradient Binding
AirGradient provides open source and open hardware air quality monitors.
This binding reads air quality data from the AirGradient (https://www.airgradient.com/) API.
This API is documented at https://api.airgradient.com/public/docs/api/v1/
## Supported Things
![AirGradient sensors](doc/airgradient_sensors.png)
This binding supports all the different AirGradient sensors, providing most of the sensor data.
- `bridge`: Connection to the API
- `location`: Location in the API to read values for
## Discovery
Autodiscovery of locations is implemented.
Start by adding an AirGradient API thing.
When that is added and online, run a scan for new things in the AirGradient binding.
## Thing Configuration
This binding supports reading data both directly from AirGradient sensors and from the AirGradient API.
If you don't specify any path on the server, the binding will behave as if the hostname is the hostname of the AirGradient API server, and append paths and tokens for it.
The binding will adapt to the content type of the returned content to support different formats for getting data both from local and cloud installations.
| Name | Hostname | Content-Type | Parser |
|-------------------|-----------------------------------------------------------------|------------------------------|--------|
| API | Hostnames without any path (e.g., https://api.airgradient.com/) | application/json | JSON parser for the AirGradient API, correct paths will be appended to the calls |
| Local OpenMetrics | Hostnames with path (e.g., http://192.168.x.x/metrics) | application/openmetrics-text | OpenMetrics parser |
| Local Web | Hostnames with path (e.g., http://192.168.x.x/measures/current) | application/json | JSON parser for the AirGradient API, as if you returned the value of sendToServer() payload |
| Local Prometheus | Hostnames with path (e.g., http://192.168.x.x/measures) | text/plain | Prometheus parser for [Prometheus format](https://prometheus.io/docs/instrumenting/exposition_formats/) |
### AirGradient API
The connection to the API needs setup and configuration
1. Log in to the AirGradient Dashboard: https://app.airgradient.com/dashboard
2. Navigate to Place->Connectivity Settings from the upper left hamburger menu.
3. Enable API access, and take a copy of the Token, which will be used in the token setting to configure the connection to the API.
To add a location, you need to know the location ID. To get the location ID, you
1. Log in to the AirGradient Dashboard: https://app.airgradient.com/dashboard
2. Navigate to Locations from the upper left hamburger menu.
3. Here you will find a list of all of your sensors, with a location ID in the left column. Use that id when you add new Location things.
### `API` Thing Configuration
| Name | Type | Description | Default | Required | Advanced |
|-----------------|---------|---------------------------------------|------------------------------|----------|----------|
| token | text | Token to access the device | N/A | yes | no |
| hostname | text | Hostname or IP address of the API | https://api.airgradient.com/ | no | yes |
| refreshInterval | integer | Interval the device is polled in sec. | 600 | no | yes |
### `Location` Thing Configuration
| Name | Type | Description | Default | Required | Advanced |
|-----------------|---------|-------------------------------------------------------------------|---------|----------|----------|
| location | text | A number identifying the location id in the AirGradient Dashboard | N/A | yes | no |
## Channels
For more information about the data in the channels, please refer to the models in https://api.airgradient.com/public/docs/api/v1/
| Channel | Type | Read/Write | Description |
|--------------------|----------------------|------------|----------------------------------------------------------------------------------|
| pm01 | Number:Density | Read | Particulate Matter 1 (0.001mm) |
| pm02 | Number:Density | Read | Particulate Matter 2 (0.002mm) |
| pm10 | Number:Density | Read | Particulate Matter 10 (0.01mm) |
| pm003-count | Number:Dimensionless | Read | The number of particles with a diameter beyond 0.3 microns in 1 deciliter of air |
| rco2 | Number:Density | Read | Carbon dioxide PPM |
| tvoc | Number:Density | Read | Total Volatile Organic Compounds |
| atmp | Number:Temperature | Read | Ambient Temperature |
| rhum | Number:Dimensionless | Read | Relative Humidity Percentage |
| wifi | Number:Power | Read | Received signal strength indicator |
| uploads-since-boot | Number:Dimensionless | Read | Number of measure uploads since last reboot (boot) |
| leds | String | Read/Write | Sets the leds mode (off/co2/pm) |
| calibration | String | Write | Triggers co2 calibration on the device |
Some configuration channels are only available for local devices (for cloud devices use the AirGradient dashboard to configure these instead).
These configuration settings needs AirGradient firmware on the sensor of version 3.1.1 or later.
| Channel | Type | Read/Write | Description |
|-----------------------|----------------------|------------|----------------------------------------------------------------------------------|
| country-code | String | Read/Write | The ALPHA-2 Country code used for the device |
| pm-standard | String | Read/Write | Standard used for Parts per Million measurements (us-aqi or ugm3) |
| abc-days | Number:Days | Read/Write | Co2 calibration automatic baseline calibration days |
| tvoc-learning-offset | Number:Dimensionless | Read/Write | Time constant of long-term estimator for offset. |
| nox-learning-offset | Number:Dimensionless | Read/Write | Time constant of long-term estimator for offset. |
| mqtt-broker-url | String | Read/Write | MQTT Broker URL |
| temperature-unit | String | Read/Write | Temperature unit used on the display |
| configuration-control | String | Read/Write | Where the unit is configured from (local/cloud/both) |
| post-to-cloud | Switch | Read/Write | Send data to the AirGradient cloud |
| led-bar-brightness | Number:Dimensionless | Read/Write | Brightness of the LED bar |
| display-brightness | Number:Dimensionless | Read/Write | Brightness of the display |
| model | String | Read/Write | The model of the device (can be changed e.g. if you change sensors) |
| led-bar-test | String | Write | Trigger test of LED bar |
## Full Example
### Thing Configuration
```java
Bridge airgradient:airgradient-api:home "My Home" [ token="abc123...." ] {
Thing location "654321" "Outside" [ location="654321" ]
}
```
### Item Configuration
```java
Number:Density AirGradient_Location_PM2 "%.0f kg/m³" <density> {channel="airgradient:location:654321:pm2"}"
Number:Temperature AirGradient_Location_PM2 "Temperature [%.1f °C]" <temperature> {channel="airgradient:location:654321:atmp"}"
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -7,11 +7,11 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.electroluxair</artifactId>
<artifactId>org.openhab.binding.airgradient</artifactId>
<name>openHAB Add-ons :: Bundles :: ElectroluxAir Binding</name>
<name>openHAB Add-ons :: Bundles :: AirGradient Binding</name>
</project>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.meteoalerte-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<features name="org.openhab.binding.airgradient-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-meteoalerte" description="Meteo Alerte Binding" version="${project.version}">
<feature name="openhab-binding-airgradient" description="AirGradient Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.meteoalerte/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.airgradient/${project.version}</bundle>
</feature>
</features>

View File

@ -0,0 +1,91 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal;
import java.time.Duration;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link AirGradientBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class AirGradientBindingConstants {
public static final String BINDING_ID = "airgradient";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_API = new ThingTypeUID(BINDING_ID, "airgradient-api");
public static final ThingTypeUID THING_TYPE_LOCAL = new ThingTypeUID(BINDING_ID, "airgradient-local");
public static final ThingTypeUID THING_TYPE_LOCATION = new ThingTypeUID(BINDING_ID, "location");
// List of all Channel ids
public static final String CHANNEL_PM_01 = "pm01";
public static final String CHANNEL_PM_02 = "pm02";
public static final String CHANNEL_PM_10 = "pm10";
public static final String CHANNEL_PM_003_COUNT = "pm003-count";
public static final String CHANNEL_ATMP = "atmp";
public static final String CHANNEL_RHUM = "rhum";
public static final String CHANNEL_WIFI = "wifi";
public static final String CHANNEL_RCO2 = "rco2";
public static final String CHANNEL_TVOC = "tvoc";
public static final String CHANNEL_LEDS_MODE = "leds";
public static final String CHANNEL_CALIBRATION = "calibration";
public static final String CHANNEL_UPLOADS_SINCE_BOOT = "uploads-since-boot";
public static final String CHANNEL_COUNTRY_CODE = "country-code";
public static final String CHANNEL_PM_STANDARD = "pm-standard";
public static final String CHANNEL_ABC_DAYS = "abc-days";
public static final String CHANNEL_TVOC_LEARNING_OFFSET = "tvoc-learning-offset";
public static final String CHANNEL_NOX_LEARNING_OFFSET = "nox-learning-offset";
public static final String CHANNEL_MQTT_BROKER_URL = "mqtt-broker-url";
public static final String CHANNEL_TEMPERATURE_UNIT = "temperature-unit";
public static final String CHANNEL_CONFIGURATION_CONTROL = "configuration-control";
public static final String CHANNEL_POST_TO_CLOUD = "post-to-cloud";
public static final String CHANNEL_LED_BAR_BRIGHTNESS = "led-bar-brightness";
public static final String CHANNEL_DISPLAY_BRIGHTNESS = "display-brightness";
public static final String CHANNEL_MODEL = "model";
public static final String CHANNEL_LED_BAR_TEST = "led-bar-test";
// List of all properties
public static final String PROPERTY_NAME = "name";
// All configurations
public static final String CONFIG_LOCATION = "location";
public static final String CONFIG_API_TOKEN = "token";
public static final String CONFIG_API_HOST_NAME = "hostname";
public static final String CONFIG_API_REFRESH_INTERVAL = "refreshInterval";
// URLs for API
public static final String CURRENT_MEASURES_PATH = "/public/api/v1/locations/measures/current?token=%s";
public static final String CURRENT_MEASURES_LOCAL_PATH = "/measures/current";
public static final String LOCAL_CONFIG_PATH = "/config";
public static final String LEDS_MODE_PATH = "/public/api/v1/sensors/%s/config/leds/mode?token=%s";
public static final String CALIBRATE_CO2_PATH = "/public/api/v1/sensors/%s/co2/calibration?token=%s";
// Discovery
public static final Duration SEARCH_TIME = Duration.ofSeconds(15);
public static final boolean BACKGROUND_DISCOVERY = true;
public static final Duration DEFAULT_POLL_INTERVAL_LOCAL = Duration.ofSeconds(10);
// Media types
public static final String CONTENTTYPE_JSON = "application/json";
public static final String CONTENTTYPE_TEXT = "text/plain";
public static final String CONTENTTYPE_OPENMETRICS = "application/openmetrics-text";
// Communication
public static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
}

View File

@ -0,0 +1,87 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.airgradient.internal.handler.AirGradientAPIHandler;
import org.openhab.binding.airgradient.internal.handler.AirGradientLocalHandler;
import org.openhab.binding.airgradient.internal.handler.AirGradientLocationHandler;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AirGradientHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.airgradient", service = ThingHandlerFactory.class)
public class AirGradientHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_API, THING_TYPE_LOCATION,
THING_TYPE_LOCAL);
private final Logger logger = LoggerFactory.getLogger(AirGradientHandlerFactory.class);
private final HttpClient httpClient;
@Activate
public AirGradientHandlerFactory(final @Reference HttpClientFactory factory) {
logger.debug("Activating factory for: {}", SUPPORTED_THING_TYPES_UIDS);
this.httpClient = factory.getCommonHttpClient();
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
logger.debug("We support: {}", SUPPORTED_THING_TYPES_UIDS);
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_API.equals(thingTypeUID)) {
logger.debug("Creating Bridge Handler for {}", thingTypeUID);
return new AirGradientAPIHandler((Bridge) thing, httpClient);
}
if (THING_TYPE_LOCATION.equals(thingTypeUID)) {
logger.debug("Creating Location Handler for {}", thingTypeUID);
return new AirGradientLocationHandler(thing);
}
if (THING_TYPE_LOCAL.equals(thingTypeUID)) {
logger.debug("Creating Local Handler for {}", thingTypeUID);
return new AirGradientLocalHandler(thing, httpClient);
}
return null;
}
}

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.communication;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Exception for communication errors against AirGradient API or sensors.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class AirGradientCommunicationException extends Exception {
private static final long serialVersionUID = 1L;
public AirGradientCommunicationException(String message) {
super(message);
}
public AirGradientCommunicationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,67 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.communication;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airgradient.internal.model.Measure;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
/**
* Helper for parsing JSON.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class JsonParserHelper {
public static List<Measure> parseJson(Gson gson, String stringResponse) {
List<@Nullable Measure> measures = null;
if (stringResponse.startsWith("[")) {
// Array of measures, like returned from the AirGradients API
Type measuresType = new TypeToken<List<@Nullable Measure>>() {
}.getType();
measures = gson.fromJson(stringResponse, measuresType);
} else if (stringResponse.startsWith("{")) {
// Single measure e.g. if you read directly from the device
Type measureType = new TypeToken<Measure>() {
}.getType();
Measure measure = gson.fromJson(stringResponse, measureType);
measures = new ArrayList<>(1);
measures.add(measure);
}
if (measures != null) {
List<@Nullable Measure> nullableMeasuresWithoutNulls = measures.stream().filter(Objects::nonNull).toList();
List<Measure> measuresWithoutNulls = new ArrayList<>(nullableMeasuresWithoutNulls.size());
for (@Nullable
Measure m : nullableMeasuresWithoutNulls) {
if (m != null) {
measuresWithoutNulls.add(m);
}
}
return measuresWithoutNulls;
}
return Collections.emptyList();
}
}

View File

@ -0,0 +1,93 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.communication;
import java.util.Arrays;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.openhab.binding.airgradient.internal.prometheus.PrometheusMetric;
import org.openhab.binding.airgradient.internal.prometheus.PrometheusTextParser;
/**
* Helper for parsing Prometheus data.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class PrometheusParserHelper {
public static List<Measure> parsePrometheus(String stringResponse) {
List<PrometheusMetric> metrics = PrometheusTextParser.parse(stringResponse);
Measure measure = new Measure();
for (PrometheusMetric metric : metrics) {
if (metric.getMetricName().equals("pm01")) {
measure.pm01 = metric.getValue();
} else if (metric.getMetricName().equals("pm02")) {
measure.pm02 = metric.getValue();
} else if (metric.getMetricName().equals("pm10")) {
measure.pm10 = metric.getValue();
} else if (metric.getMetricName().equals("rco2")) {
measure.rco2 = metric.getValue();
} else if (metric.getMetricName().equals("atmp")) {
measure.atmp = metric.getValue();
} else if (metric.getMetricName().equals("rhum")) {
measure.rhum = metric.getValue();
} else if (metric.getMetricName().equals("tvoc")) {
measure.tvoc = metric.getValue();
} else if (metric.getMetricName().equals("nox")) {
measure.noxIndex = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_wifi_rssi_dbm")) {
measure.wifi = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_co2_ppm")) {
measure.rco2 = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_pm1_ugm3")) {
measure.pm01 = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_pm2d5_ugm3")) {
measure.pm02 = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_pm10_ugm3")) {
measure.pm10 = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_pm0d3_p100ml")) {
measure.pm003Count = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_tvoc_index")) {
measure.tvoc = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_tvoc_raw_index")) {
measure.tvocIndex = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_nox_index")) {
measure.noxIndex = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_temperature_degc")) {
measure.atmp = metric.getValue();
} else if (metric.getMetricName().equals("airgradient_humidity_percent")) {
measure.rhum = metric.getValue();
}
if (metric.getLabels().containsKey("id")) {
String id = metric.getLabels().get("id");
measure.serialno = id;
measure.locationId = id;
measure.locationName = id;
}
if (metric.getLabels().containsKey("airgradient_serial_number")) {
String id = metric.getLabels().get("airgradient_serial_number");
measure.serialno = id;
measure.locationId = id;
measure.locationName = id;
}
}
return Arrays.asList(measure);
}
}

View File

@ -0,0 +1,83 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.communication;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CALIBRATE_CO2_PATH;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CURRENT_MEASURES_PATH;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.LEDS_MODE_PATH;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.LOCAL_CONFIG_PATH;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.REQUEST_TIMEOUT;
import java.net.URI;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration;
/**
* Helper for doing rest calls to the API.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class RESTHelper {
public static @Nullable String generateMeasuresUrl(AirGradientAPIConfiguration apiConfig) {
if (apiConfig.hasCloudUrl()) {
return apiConfig.hostname + String.format(CURRENT_MEASURES_PATH, apiConfig.token);
} else {
return apiConfig.hostname;
}
}
public static @Nullable String generateConfigUrl(AirGradientAPIConfiguration apiConfig) {
URI uri = URI.create(apiConfig.hostname);
URI configUri = uri.resolve(LOCAL_CONFIG_PATH);
return configUri.toString();
}
public static @Nullable String generateCalibrationCo2Url(AirGradientAPIConfiguration apiConfig, String serialNo) {
if (apiConfig.hasCloudUrl()) {
return apiConfig.hostname + String.format(CALIBRATE_CO2_PATH, serialNo, apiConfig.token);
} else {
return generateConfigUrl(apiConfig);
}
}
public static @Nullable String generateGetLedsModeUrl(AirGradientAPIConfiguration apiConfig, String serialNo) {
if (apiConfig.hasCloudUrl()) {
return apiConfig.hostname + String.format(LEDS_MODE_PATH, serialNo, apiConfig.token);
} else {
return generateConfigUrl(apiConfig);
}
}
public static @Nullable Request generateRequest(HttpClient httpClient, @Nullable String url) {
return generateRequest(httpClient, url, HttpMethod.GET);
}
public static @Nullable Request generateRequest(HttpClient httpClient, @Nullable String url, HttpMethod method) {
if (url == null) {
return null;
}
Request request = httpClient.newRequest(url);
request.timeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
request.method(method);
return request;
}
}

View File

@ -0,0 +1,175 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.communication;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONTENTTYPE_JSON;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONTENTTYPE_OPENMETRICS;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONTENTTYPE_TEXT;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.REQUEST_TIMEOUT;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration;
import org.openhab.binding.airgradient.internal.model.LedMode;
import org.openhab.binding.airgradient.internal.model.LocalConfiguration;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
/**
* Helper for doing rest calls to the AirGradient API.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class RemoteAPIController {
private final Logger logger = LoggerFactory.getLogger(RemoteAPIController.class);
private final HttpClient httpClient;
private final Gson gson;
private final AirGradientAPIConfiguration apiConfig;
public RemoteAPIController(HttpClient httpClient, Gson gson, AirGradientAPIConfiguration apiConfig) {
this.httpClient = httpClient;
this.gson = gson;
this.apiConfig = apiConfig;
}
/**
* Return list of measures from AirGradient API.
*
* @return list of measures
* @throws AirGradientCommunicationException if unable to communicate with sensor or API.
*/
public List<Measure> getMeasures() throws AirGradientCommunicationException {
ContentResponse response = sendRequest(
RESTHelper.generateRequest(httpClient, RESTHelper.generateMeasuresUrl(apiConfig)));
if (response != null) {
String contentType = response.getMediaType();
logger.trace("Got measurements with status {}: {} ({})", response.getStatus(),
response.getContentAsString(), contentType);
if (HttpStatus.isSuccess(response.getStatus())) {
String stringResponse = response.getContentAsString().trim();
if (null != contentType) {
switch (contentType) {
case CONTENTTYPE_JSON:
return JsonParserHelper.parseJson(gson, stringResponse);
case CONTENTTYPE_TEXT:
return PrometheusParserHelper.parsePrometheus(stringResponse);
case CONTENTTYPE_OPENMETRICS:
return PrometheusParserHelper.parsePrometheus(stringResponse);
default:
logger.debug("Unhandled content type returned: {}", contentType);
}
}
}
}
return Collections.emptyList();
}
public @Nullable LocalConfiguration getConfig() throws AirGradientCommunicationException {
ContentResponse response = sendRequest(
RESTHelper.generateRequest(httpClient, RESTHelper.generateConfigUrl(apiConfig)));
if (response == null) {
return null;
}
logger.trace("Got configuration with status {}: {}", response.getStatus(), response.getContentAsString());
Type configType = new TypeToken<LocalConfiguration>() {
}.getType();
return gson.fromJson(response.getContentAsString(), configType);
}
public void setConfig(LocalConfiguration config) throws AirGradientCommunicationException {
Request request = httpClient.newRequest(RESTHelper.generateConfigUrl(apiConfig));
request.timeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
request.method(HttpMethod.PUT);
request.header(HttpHeader.CONTENT_TYPE, CONTENTTYPE_JSON);
String configJson = gson.toJson(config);
logger.debug("Setting configuration: {}", configJson);
request.content(new StringContentProvider(CONTENTTYPE_JSON, configJson, StandardCharsets.UTF_8));
sendRequest(request);
}
public void setLedMode(String serialNo, String mode) throws AirGradientCommunicationException {
Request request = httpClient.newRequest(RESTHelper.generateGetLedsModeUrl(apiConfig, serialNo));
request.timeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
request.method(HttpMethod.PUT);
request.header(HttpHeader.CONTENT_TYPE, CONTENTTYPE_JSON);
LedMode ledMode = new LedMode();
ledMode.mode = mode;
String modeJson = gson.toJson(ledMode);
logger.debug("Setting LEDS mode for {}: {}", serialNo, modeJson);
request.content(new StringContentProvider(CONTENTTYPE_JSON, modeJson, StandardCharsets.UTF_8));
sendRequest(request);
}
public void calibrateCo2(String serialNo) throws AirGradientCommunicationException {
logger.debug("Triggering CO2 calibration for {}", serialNo);
sendRequest(RESTHelper.generateRequest(httpClient, RESTHelper.generateCalibrationCo2Url(apiConfig, serialNo),
HttpMethod.POST));
}
private @Nullable ContentResponse sendRequest(@Nullable final Request request)
throws AirGradientCommunicationException {
if (request == null) {
throw new AirGradientCommunicationException("Unable to generate request");
}
@Nullable
ContentResponse response = null;
try {
response = request.send();
if (response != null) {
logger.trace("Response from {} ({}): {}", request.getURI(), response.getStatus(),
response.getContentAsString());
if (!HttpStatus.isSuccess(response.getStatus())) {
throw new AirGradientCommunicationException("Returned status code: " + response.getStatus());
}
} else {
throw new AirGradientCommunicationException("No response");
}
} catch (InterruptedException | ExecutionException | TimeoutException e) {
String message = e.getMessage();
if (message == null) {
message = "Communication error";
}
throw new AirGradientCommunicationException(message, e);
}
return response;
}
}

View File

@ -0,0 +1,58 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.config;
import java.net.URI;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link AirGradientAPIConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class AirGradientAPIConfiguration {
public String hostname = "";
public String token = "";
public int refreshInterval = 600;
public boolean isValid() {
// hostname must be entered and be a URI
if ("".equals(hostname)) {
return false;
}
try {
URI.create(hostname);
} catch (IllegalArgumentException iae) {
return false;
}
// token is optional
// refresh interval is positive integer
return (refreshInterval > 0);
}
/**
* Returns true if this is a URL against the cloud.
*
* @return true if this is a URL against the cloud API
*/
public boolean hasCloudUrl() {
URI url = URI.create(hostname);
return url.getPath().equals("/");
}
}

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link AirGradientLocationConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class AirGradientLocationConfiguration {
public String location = "";
}

View File

@ -0,0 +1,133 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.discovery;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.BACKGROUND_DISCOVERY;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONFIG_LOCATION;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.PROPERTY_NAME;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.SEARCH_TIME;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.THING_TYPE_LOCATION;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException;
import org.openhab.binding.airgradient.internal.handler.AirGradientAPIHandler;
import org.openhab.binding.airgradient.internal.handler.PollEventListener;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.BridgeHandler;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ServiceScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AirGradientLocationDiscoveryService} is responsible for discovering new locations
* that are not bound to any items.
*
* @author Jørgen Austvik - Initial contribution
*/
@Component(scope = ServiceScope.PROTOTYPE, service = AirGradientLocationDiscoveryService.class)
@NonNullByDefault
public class AirGradientLocationDiscoveryService extends AbstractThingHandlerDiscoveryService<AirGradientAPIHandler>
implements PollEventListener {
private final Logger logger = LoggerFactory.getLogger(AirGradientLocationDiscoveryService.class);
public AirGradientLocationDiscoveryService() {
super(AirGradientAPIHandler.class, Set.of(THING_TYPE_LOCATION), (int) SEARCH_TIME.getSeconds(),
BACKGROUND_DISCOVERY);
}
@Override
protected void startBackgroundDiscovery() {
logger.debug("Start AirGradient background discovery");
getApiHandler().addPollEventListener(this);
}
@Override
protected void stopBackgroundDiscovery() {
logger.debug("Stopping AirGradient background discovery");
getApiHandler().removePollEventListener(this);
}
@Override
public void pollEvent(List<Measure> measures) {
BridgeHandler bridge = getApiHandler().getThing().getHandler();
if (bridge == null) {
logger.debug("Missing bridge, can't discover sensors for unknown bridge.");
return;
}
ThingUID bridgeUid = bridge.getThing().getUID();
Set<String> registeredLocationIds = new HashSet<>(getApiHandler().getRegisteredLocationIds());
for (Measure measure : measures) {
String id = measure.getLocationId();
if (id.isEmpty()) {
// Local devices don't have location ID.
id = measure.getSerialNo();
}
String name = measure.getLocationName();
if (name.isEmpty()) {
name = "Sensor_" + measure.getSerialNo();
}
if (!registeredLocationIds.contains(id)) {
Map<String, Object> properties = new HashMap<>(5);
properties.put(PROPERTY_NAME, name);
properties.put(Thing.PROPERTY_FIRMWARE_VERSION, measure.getFirmwareVersion());
properties.put(Thing.PROPERTY_SERIAL_NUMBER, measure.getSerialNo());
String model = measure.getModel();
if (model != null) {
properties.put(Thing.PROPERTY_MODEL_ID, model);
}
properties.put(CONFIG_LOCATION, id);
ThingUID thingUID = new ThingUID(THING_TYPE_LOCATION, bridgeUid, id);
logger.debug("Adding location {} with id {} to bridge {} with location id {}", name, thingUID,
bridgeUid, measure.getLocationId());
DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withProperties(properties)
.withBridge(bridgeUid).withLabel(name).withRepresentationProperty(CONFIG_LOCATION).build();
thingDiscovered(discoveryResult);
}
}
}
@Override
protected void startScan() {
try {
List<Measure> measures = getApiHandler().getApiController().getMeasures();
pollEvent(measures);
} catch (AirGradientCommunicationException agce) {
logger.warn("Failed discovery due to communication exception: {}", agce.getMessage());
}
}
private AirGradientAPIHandler getApiHandler() {
return (@NonNull AirGradientAPIHandler) getThingHandler();
}
}

View File

@ -0,0 +1,122 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.discovery;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONFIG_API_HOST_NAME;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONFIG_API_REFRESH_INTERVAL;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONFIG_API_TOKEN;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CURRENT_MEASURES_LOCAL_PATH;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.DEFAULT_POLL_INTERVAL_LOCAL;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.THING_TYPE_LOCAL;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import javax.jmdns.ServiceInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingRegistry;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AirGradientMDNSDiscoveryParticipant} is responsible for discovering new and removed AirGradient sensors.
* It uses the
* central {@link org.openhab.core.config.discovery.mdns.internal.MDNSDiscoveryService}.
*
* @author Jørgen Austvik - Initial contribution
*/
@Component(configurationPid = "discovery.airgradient")
@NonNullByDefault
public class AirGradientMDNSDiscoveryParticipant implements MDNSDiscoveryParticipant {
private static final String SERVICE_TYPE = "_airgradient._tcp.local.";
private static final String MDNS_PROPERTY_SERIALNO = "serialno";
private static final String MDNS_PROPERTY_MODEL = "model";
private final Logger logger = LoggerFactory.getLogger(AirGradientMDNSDiscoveryParticipant.class);
protected final ThingRegistry thingRegistry;
@Activate
public AirGradientMDNSDiscoveryParticipant(final @Reference ThingRegistry thingRegistry) {
this.thingRegistry = thingRegistry;
}
@Override
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
return Set.of(THING_TYPE_LOCAL);
}
@Override
public String getServiceType() {
return SERVICE_TYPE;
}
@Override
public @Nullable DiscoveryResult createResult(ServiceInfo si) {
logger.debug("Discovered {} at {}: {}", si.getQualifiedName(), si.getURLs(), si.getNiceTextString());
String urls[] = si.getURLs();
if (urls == null || urls.length < 1) {
logger.debug("Not able to find URLs for {}, not autodetecting", si.getQualifiedName());
return null;
}
String hostName = urls[0] + CURRENT_MEASURES_LOCAL_PATH;
String model = si.getPropertyString(MDNS_PROPERTY_MODEL);
Map<String, Object> properties = new HashMap<>(4);
properties.put(CONFIG_API_TOKEN, "");
properties.put(CONFIG_API_HOST_NAME, hostName);
properties.put(CONFIG_API_REFRESH_INTERVAL, DEFAULT_POLL_INTERVAL_LOCAL.getSeconds());
properties.put(Thing.PROPERTY_MODEL_ID, model);
ThingUID thingUID = getThingUID(si);
if (thingUID == null) {
logger.debug("Failed creating thing as we couldn't create a UID for it (missing serialno)");
return null;
}
logger.debug("Autodiscovered API {} with id {} with host name {}. It is a {}", si.getName(), thingUID, hostName,
model);
DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withProperties(properties)
.withLabel(si.getName()).withRepresentationProperty(CONFIG_API_HOST_NAME).build();
return discoveryResult;
}
@Override
public @Nullable ThingUID getThingUID(ServiceInfo si) {
logger.debug("Getting thing ID for: App: {} Host: {} Name: {} Port: {} Serial: {}", si.getApplication(),
si.getHostAddresses(), si.getName(), si.getPort(), si.getPropertyString("serialno"));
String serialNo = si.getPropertyString(MDNS_PROPERTY_SERIALNO);
if (serialNo == null) {
return null;
}
return new ThingUID(THING_TYPE_LOCAL, serialNo);
}
}

View File

@ -0,0 +1,199 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException;
import org.openhab.binding.airgradient.internal.communication.RemoteAPIController;
import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration;
import org.openhab.binding.airgradient.internal.discovery.AirGradientLocationDiscoveryService;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The {@link AirGradientAPIHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class AirGradientAPIHandler extends BaseBridgeHandler {
private final Logger logger = LoggerFactory.getLogger(AirGradientAPIHandler.class);
private @Nullable ScheduledFuture<?> pollingJob;
private final HttpClient httpClient;
private final Gson gson;
private final Set<PollEventListener> pollListeners = new HashSet<>(1);
private @NonNullByDefault({}) RemoteAPIController apiController = null;
private @NonNullByDefault({}) AirGradientAPIConfiguration apiConfig = null;
public AirGradientAPIHandler(Bridge bridge, HttpClient httpClient) {
super(bridge);
this.httpClient = httpClient;
this.gson = new Gson();
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
pollingCode();
} else {
// This is read only
logger.warn("Received command {} for channel {}, but the API is read only", command.toString(),
channelUID.getId());
}
}
@Override
public void initialize() {
apiConfig = getConfigAs(AirGradientAPIConfiguration.class);
if (!apiConfig.isValid()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Need to set hostname to a valid URL. Refresh interval needs to be a positive integer.");
return;
}
apiController = new RemoteAPIController(httpClient, gson, apiConfig);
// set the thing status to UNKNOWN temporarily and let the background task decide for the real status.
// the framework is then able to reuse the resources from the thing handler initialization.
// we set this upfront to reliably check status updates in unit tests.
updateStatus(ThingStatus.UNKNOWN);
pollingJob = scheduler.scheduleWithFixedDelay(this::pollingCode, 0, apiConfig.refreshInterval,
TimeUnit.SECONDS);
}
private static String getMeasureId(Measure measure) {
String id = measure.getLocationId();
if (id.isEmpty()) {
// Local devices don't have location ID.
id = measure.getSerialNo();
}
return id;
}
protected void pollingCode() {
try {
List<Measure> measures = apiController.getMeasures();
updateStatus(ThingStatus.ONLINE);
triggerPollEvent(measures);
Map<String, Measure> measureMap = measures.stream()
.collect(Collectors.toMap((m) -> getMeasureId(m), (m) -> m));
for (Thing t : getThing().getThings()) {
if (t.getHandler() instanceof AirGradientLocationHandler handler) {
String locationId = handler.getLocationId();
@Nullable
Measure measure = measureMap.get(locationId);
if (measure != null) {
handler.setMeasurment(measure);
} else {
logger.debug("Could not find measures for location {}", locationId);
}
}
}
} catch (AirGradientCommunicationException agce) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
}
}
/**
* Return location ids we already have things for.
*
* @return location ids we already have things for.
*/
public List<String> getRegisteredLocationIds() {
List<Thing> things = getThing().getThings();
List<String> results = new ArrayList<>(things.size());
for (Thing t : things) {
if (t.getHandler() instanceof AirGradientLocationHandler handler) {
results.add(handler.getLocationId());
}
}
return results;
}
@Override
public void dispose() {
ScheduledFuture<?> pollingJob = this.pollingJob;
if (pollingJob != null) {
pollingJob.cancel(true);
this.pollingJob = null;
}
}
protected void setConfiguration(AirGradientAPIConfiguration config) {
this.apiConfig = config;
}
protected void setApiController(RemoteAPIController apiController) {
this.apiController = apiController;
}
public RemoteAPIController getApiController() {
return apiController;
}
// Event listening
public void addPollEventListener(PollEventListener listener) {
pollListeners.add(listener);
}
public void removePollEventListener(PollEventListener listener) {
pollListeners.remove(listener);
}
public void triggerPollEvent(List<Measure> measures) {
for (PollEventListener listener : pollListeners) {
listener.pollEvent(measures);
}
}
// Discovery
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(AirGradientLocationDiscoveryService.class);
}
}

View File

@ -0,0 +1,229 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException;
import org.openhab.binding.airgradient.internal.communication.RemoteAPIController;
import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration;
import org.openhab.binding.airgradient.internal.model.LocalConfiguration;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The {@link AirGradientAPIHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class AirGradientLocalHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(AirGradientLocalHandler.class);
private @Nullable ScheduledFuture<?> pollingJob;
private final HttpClient httpClient;
private final Gson gson;
private @NonNullByDefault({}) RemoteAPIController apiController = null;
private @NonNullByDefault({}) AirGradientAPIConfiguration apiConfig = null;
public AirGradientLocalHandler(Thing thing, HttpClient httpClient) {
super(thing);
this.httpClient = httpClient;
this.gson = new Gson();
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
logger.debug("Channel {}: {}", channelUID, command.toFullString());
if (command instanceof RefreshType) {
pollingCode();
} else if (CHANNEL_LEDS_MODE.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.ledBarMode = stringCommand.toFullString());
} else {
logger.warn("Received command {} for channel {}, but it needs a string command", command.toString(),
channelUID.getId());
}
} else if (CHANNEL_CALIBRATION.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
if ("co2".equals(stringCommand.toFullString())) {
updateConfiguration((var c) -> c.co2CalibrationRequested = true);
} else {
logger.warn(
"Received unknown command {} for calibration on channel {}, which we don't know how to handle",
command.toString(), channelUID.getId());
}
}
} else if (CHANNEL_TEMPERATURE_UNIT.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.temperatureUnit = stringCommand.toFullString());
}
} else if (CHANNEL_PM_STANDARD.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.pmStandard = stringCommand.toFullString());
}
} else if (CHANNEL_ABC_DAYS.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.abcDays = quantityCommand.longValue());
}
} else if (CHANNEL_TVOC_LEARNING_OFFSET.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.tvocLearningOffset = quantityCommand.longValue());
}
} else if (CHANNEL_NOX_LEARNING_OFFSET.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.noxLearningOffset = quantityCommand.longValue());
}
} else if (CHANNEL_MQTT_BROKER_URL.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.mqttBrokerUrl = stringCommand.toFullString());
}
} else if (CHANNEL_CONFIGURATION_CONTROL.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.configurationControl = stringCommand.toFullString());
}
} else if (CHANNEL_LED_BAR_BRIGHTNESS.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.ledBarBrightness = quantityCommand.longValue());
}
} else if (CHANNEL_DISPLAY_BRIGHTNESS.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.displayBrightness = quantityCommand.longValue());
}
} else if (CHANNEL_POST_TO_CLOUD.equals(channelUID.getId())) {
if (command instanceof OnOffType onOffCommand) {
updateConfiguration((var c) -> c.postDataToAirGradient = onOffCommand.equals(OnOffType.ON));
}
} else if (CHANNEL_MODEL.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.model = stringCommand.toFullString());
}
} else if (CHANNEL_LED_BAR_TEST.equals(channelUID.getId())) {
updateConfiguration((var c) -> c.ledBarTestRequested = true);
} else {
// This is read only
logger.warn("Received command {} for channel {}, which we don't know how to handle (type: {})",
command.toString(), channelUID.getId(), command.getClass());
}
}
@Override
public void initialize() {
apiConfig = getConfigAs(AirGradientAPIConfiguration.class);
if (!apiConfig.isValid()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Need to set hostname to a valid URL. Refresh interval needs to be a positive integer.");
return;
}
apiController = new RemoteAPIController(httpClient, gson, apiConfig);
// set the thing status to UNKNOWN temporarily and let the background task decide for the real status.
// the framework is then able to reuse the resources from the thing handler initialization.
// we set this upfront to reliably check status updates in unit tests.
updateStatus(ThingStatus.UNKNOWN);
pollingJob = scheduler.scheduleWithFixedDelay(this::pollingCode, 0, apiConfig.refreshInterval,
TimeUnit.SECONDS);
}
protected void pollingCode() {
try {
List<Measure> measures = apiController.getMeasures();
updateStatus(ThingStatus.ONLINE);
if (measures.size() != 1) {
logger.warn("Expecting single set of measures for local device, but got {} measures", measures.size());
return;
}
Measure measure = measures.get(0);
updateProperties(MeasureHelper.createProperties(measure));
Map<String, State> states = MeasureHelper.createStates(measure);
for (Map.Entry<String, State> entry : states.entrySet()) {
if (isLinked(entry.getKey())) {
updateState(entry.getKey(), entry.getValue());
}
}
LocalConfiguration localConfig = apiController.getConfig();
if (localConfig != null) {
// If we are able to read config, we add config channels
ThingBuilder builder = DynamicChannelHelper.updateThingWithConfigurationChannels(thing, editThing());
updateThing(builder.build());
updateProperties(ConfigurationHelper.createProperties(localConfig));
Map<String, State> configStates = ConfigurationHelper.createStates(localConfig);
for (Map.Entry<String, State> entry : configStates.entrySet()) {
if (isLinked(entry.getKey())) {
updateState(entry.getKey(), entry.getValue());
}
}
}
} catch (AirGradientCommunicationException agce) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
}
}
private void updateConfiguration(Consumer<LocalConfiguration> action) {
try {
LocalConfiguration config = new LocalConfiguration();
action.accept(config);
apiController.setConfig(config);
updateStatus(ThingStatus.ONLINE);
} catch (AirGradientCommunicationException agce) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
}
}
@Override
public void dispose() {
ScheduledFuture<?> pollingJob = this.pollingJob;
if (pollingJob != null) {
pollingJob.cancel(true);
this.pollingJob = null;
}
}
protected void setConfiguration(AirGradientAPIConfiguration config) {
this.apiConfig = config;
}
}

View File

@ -0,0 +1,160 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException;
import org.openhab.binding.airgradient.internal.config.AirGradientLocationConfiguration;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AirGradientAPIHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class AirGradientLocationHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(AirGradientLocationHandler.class);
private @NonNullByDefault({}) AirGradientLocationConfiguration locationConfig = null;
public AirGradientLocationHandler(Thing thing) {
super(thing);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
logger.debug("Channel {}: {}", channelUID, command.toFullString());
if (command instanceof RefreshType) {
Bridge bridge = getBridge();
if (bridge != null) {
if (bridge.getHandler() instanceof AirGradientAPIHandler handler) {
handler.pollingCode();
}
}
} else if (CHANNEL_LEDS_MODE.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
setLedModeOnDevice(stringCommand.toFullString());
} else {
logger.warn("Received command {} for channel {}, but it needs a string command", command.toString(),
channelUID.getId());
}
} else if (CHANNEL_CALIBRATION.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
if ("co2".equals(stringCommand.toFullString())) {
calibrateCo2OnDevice();
} else {
logger.warn(
"Received unknown command {} for calibration on channel {}, which we don't know how to handle",
command.toString(), channelUID.getId());
}
}
} else {
// This is read only
logger.warn("Received command {} for channel {}, which we don't know how to handle", command.toString(),
channelUID.getId());
}
}
private void setLedModeOnDevice(String mode) {
Bridge bridge = getBridge();
if (bridge != null) {
if (bridge.getHandler() instanceof AirGradientAPIHandler handler) {
try {
handler.getApiController().setLedMode(getSerialNo(), mode);
updateStatus(ThingStatus.ONLINE);
} catch (AirGradientCommunicationException agce) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
}
}
}
}
private void calibrateCo2OnDevice() {
Bridge bridge = getBridge();
if (bridge != null) {
if (bridge.getHandler() instanceof AirGradientAPIHandler handler) {
try {
handler.getApiController().calibrateCo2(getSerialNo());
updateStatus(ThingStatus.ONLINE);
} catch (AirGradientCommunicationException agce) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
}
}
}
}
@Override
public void initialize() {
// set the thing status to UNKNOWN temporarily and let the background task decide for the real status.
// the framework is then able to reuse the resources from the thing handler initialization.
// we set this upfront to reliably check status updates in unit tests.
updateStatus(ThingStatus.UNKNOWN);
locationConfig = getConfigAs(AirGradientLocationConfiguration.class);
Bridge controller = getBridge();
if (controller == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
} else if (ThingStatus.OFFLINE.equals(controller.getStatus())) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
} else {
updateStatus(ThingStatus.ONLINE);
}
}
public String getLocationId() {
return locationConfig.location;
}
public void setMeasurment(Measure measure) {
updateProperties(MeasureHelper.createProperties(measure));
Map<String, State> states = MeasureHelper.createStates(measure);
for (Map.Entry<String, State> entry : states.entrySet()) {
if (isLinked(entry.getKey())) {
updateState(entry.getKey(), entry.getValue());
}
}
}
/**
* Returns the serial number of this sensor.
*
* @return serial number of this sensor.
*/
private String getSerialNo() {
String serialNo = thing.getProperties().get(Thing.PROPERTY_SERIAL_NUMBER);
if (serialNo == null) {
serialNo = "";
}
return serialNo;
}
}

View File

@ -0,0 +1,84 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import java.util.HashMap;
import java.util.Map;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airgradient.internal.model.LocalConfiguration;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Thing;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* Helper class to reduce code duplication across things.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class ConfigurationHelper {
public static Map<String, String> createProperties(LocalConfiguration configuration) {
Map<String, String> properties = new HashMap<>(4);
String model = configuration.model;
if (model != null) {
properties.put(Thing.PROPERTY_MODEL_ID, model);
}
return properties;
}
public static final String CHANNEL_POST_TO_CLOUD = "post-to-cloud";
public static Map<String, State> createStates(LocalConfiguration configuration) {
Map<String, State> states = new HashMap<>(11);
states.put(CHANNEL_COUNTRY_CODE, toStringType(configuration.country));
states.put(CHANNEL_PM_STANDARD, toStringType(configuration.pmStandard));
states.put(CHANNEL_ABC_DAYS, toQuantityType(configuration.abcDays, Units.DAY));
states.put(CHANNEL_TVOC_LEARNING_OFFSET, toQuantityType(configuration.tvocLearningOffset, Units.ONE));
states.put(CHANNEL_NOX_LEARNING_OFFSET, toQuantityType(configuration.noxLearningOffset, Units.ONE));
states.put(CHANNEL_MQTT_BROKER_URL, toStringType(configuration.mqttBrokerUrl));
states.put(CHANNEL_TEMPERATURE_UNIT, toStringType(configuration.temperatureUnit));
states.put(CHANNEL_CONFIGURATION_CONTROL, toStringType(configuration.configurationControl));
states.put(CHANNEL_LED_BAR_BRIGHTNESS, toQuantityType(configuration.ledBarBrightness, Units.ONE));
states.put(CHANNEL_DISPLAY_BRIGHTNESS, toQuantityType(configuration.displayBrightness, Units.ONE));
states.put(CHANNEL_POST_TO_CLOUD, toOnOffType(configuration.postDataToAirGradient));
states.put(CHANNEL_MODEL, toStringType(configuration.model));
return states;
}
private static State toQuantityType(@Nullable Number value, Unit<?> unit) {
return value == null ? UnDefType.NULL : new QuantityType<>(value, unit);
}
private static State toStringType(@Nullable String value) {
return value == null ? UnDefType.NULL : StringType.valueOf(value);
}
private static State toOnOffType(@Nullable Boolean value) {
return value == null ? UnDefType.NULL : OnOffType.from(value);
}
}

View File

@ -0,0 +1,81 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link DynamicChannelHelper} is responsible for creating dynamic configuration channels.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class DynamicChannelHelper {
private record ConfigurationChannel(String id, String typeId, String itemType) {
}
private static final List<ConfigurationChannel> CHANNELS = new ArrayList<ConfigurationChannel>() {
{
add(new ConfigurationChannel(CHANNEL_COUNTRY_CODE, CHANNEL_COUNTRY_CODE, "String"));
add(new ConfigurationChannel(CHANNEL_PM_STANDARD, CHANNEL_PM_STANDARD, "String"));
add(new ConfigurationChannel(CHANNEL_ABC_DAYS, CHANNEL_ABC_DAYS, "Number"));
add(new ConfigurationChannel(CHANNEL_TVOC_LEARNING_OFFSET, CHANNEL_TVOC_LEARNING_OFFSET, "Number"));
add(new ConfigurationChannel(CHANNEL_NOX_LEARNING_OFFSET, CHANNEL_NOX_LEARNING_OFFSET, "Number"));
add(new ConfigurationChannel(CHANNEL_MQTT_BROKER_URL, CHANNEL_MQTT_BROKER_URL, "String"));
add(new ConfigurationChannel(CHANNEL_TEMPERATURE_UNIT, CHANNEL_TEMPERATURE_UNIT, "String"));
add(new ConfigurationChannel(CHANNEL_CONFIGURATION_CONTROL, CHANNEL_CONFIGURATION_CONTROL, "String"));
add(new ConfigurationChannel(CHANNEL_POST_TO_CLOUD, CHANNEL_POST_TO_CLOUD, "Switch"));
add(new ConfigurationChannel(CHANNEL_LED_BAR_BRIGHTNESS, CHANNEL_LED_BAR_BRIGHTNESS,
"Number:Dimensionless"));
add(new ConfigurationChannel(CHANNEL_DISPLAY_BRIGHTNESS, CHANNEL_DISPLAY_BRIGHTNESS,
"Number:Dimensionless"));
add(new ConfigurationChannel(CHANNEL_MODEL, CHANNEL_MODEL, "String"));
add(new ConfigurationChannel(CHANNEL_LED_BAR_TEST, CHANNEL_LED_BAR_TEST, "String"));
}
};
private static final Logger LOGGER = LoggerFactory.getLogger(DynamicChannelHelper.class);
public static ThingBuilder updateThingWithConfigurationChannels(Thing thing, ThingBuilder builder) {
for (ConfigurationChannel channel : CHANNELS) {
addLocalConfigurationChannel(thing, builder, channel);
}
return builder;
}
private static void addLocalConfigurationChannel(Thing originalThing, ThingBuilder builder,
ConfigurationChannel toAdd) {
ChannelUID channelId = new ChannelUID(originalThing.getUID(), toAdd.id);
if (originalThing.getChannel(channelId) == null) {
LOGGER.debug("Adding dynamic channel {} to {}", toAdd.id, originalThing.getUID());
ChannelTypeUID typeId = new ChannelTypeUID(BINDING_ID, toAdd.typeId);
Channel channel = ChannelBuilder.create(channelId, toAdd.itemType).withType(typeId).build();
builder.withChannel(channel);
}
}
}

View File

@ -0,0 +1,100 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import java.util.HashMap;
import java.util.Map;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Thing;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* Helper class to reduce code duplication across things.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class MeasureHelper {
public static Map<String, String> createProperties(Measure measure) {
Map<String, String> properties = new HashMap<>(4);
String firmwareVersion = measure.firmwareVersion;
if (firmwareVersion != null) {
properties.put(Thing.PROPERTY_FIRMWARE_VERSION, firmwareVersion);
}
String locationName = measure.locationName;
if (locationName != null) {
properties.put(PROPERTY_NAME, locationName);
}
String serialNo = measure.serialno;
if (serialNo != null) {
properties.put(Thing.PROPERTY_SERIAL_NUMBER, serialNo);
}
String model = measure.getModel();
if (model != null) {
properties.put(Thing.PROPERTY_MODEL_ID, model);
}
return properties;
}
public static Map<String, State> createStates(Measure measure) {
Map<String, State> states = new HashMap<>(11);
states.put(CHANNEL_ATMP, toQuantityType(measure.getTemperature(), SIUnits.CELSIUS));
states.put(CHANNEL_PM_003_COUNT, toQuantityType(measure.pm003Count, Units.ONE));
states.put(CHANNEL_PM_01, toQuantityType(measure.pm01, Units.MICROGRAM_PER_CUBICMETRE));
states.put(CHANNEL_PM_02, toQuantityType(measure.pm02, Units.MICROGRAM_PER_CUBICMETRE));
states.put(CHANNEL_PM_10, toQuantityType(measure.pm10, Units.MICROGRAM_PER_CUBICMETRE));
states.put(CHANNEL_RHUM, toQuantityType(measure.getHumidity(), Units.PERCENT));
states.put(CHANNEL_UPLOADS_SINCE_BOOT, toQuantityType(measure.getBootCount(), Units.ONE));
Double rco2 = measure.rco2;
if (rco2 != null) {
states.put(CHANNEL_RCO2, toQuantityType(rco2.longValue(), Units.PARTS_PER_MILLION));
}
Double tvoc = measure.tvoc;
if (tvoc != null) {
states.put(CHANNEL_TVOC, toQuantityType(tvoc.longValue(), Units.PARTS_PER_BILLION));
}
states.put(CHANNEL_WIFI, toQuantityType(measure.wifi, Units.DECIBEL_MILLIWATTS));
states.put(CHANNEL_LEDS_MODE, toStringType(measure.ledMode));
return states;
}
private static State toQuantityType(@Nullable Number value, Unit<?> unit) {
return value == null ? UnDefType.NULL : new QuantityType<>(value, unit);
}
private static State toStringType(@Nullable String value) {
return value == null ? UnDefType.NULL : StringType.valueOf(value);
}
}

View File

@ -0,0 +1,34 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.airgradient.internal.model.Measure;
/**
* Interface for listening to polls.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public interface PollEventListener {
/**
* Called when a poll has happened.
*
* @param measures Measures that has been read in a successful poll
*/
public void pollEvent(List<Measure> measures);
}

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.model;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Data model class for a single led mode from AirGradients API.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class LedMode {
@Nullable
public String mode;
}

View File

@ -0,0 +1,75 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.model;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Data model class for configuration from a local sensor.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class LocalConfiguration {
@Nullable
public String country; // ALPHA-2 Country code
@Nullable
public String pmStandard; // usaqi/ugm3
@Nullable
public String ledBarMode; // off, pm, co2
@Nullable
public Long abcDays; // Co2 calibration automatic baseline calibration days ( 0-200)
@Nullable
public Long tvocLearningOffset; // Time constant of long-term estimator for offset. Past events will be forgotten
// after about twice the learning time. Range 1..1000 [hours]
@Nullable
public Long noxLearningOffset; // Time constant of long-term estimator for offset. Past events will be forgotten
// after about twice the learning time. Range 1..1000 [hours]
@Nullable
public String mqttBrokerUrl;
@Nullable
public String temperatureUnit; // c/f
@Nullable
public String configurationControl; // local, cloud, both
@Nullable
public Boolean postDataToAirGradient;
@Nullable
public Long ledBarBrightness; // 0 - 100
@Nullable
public Long displayBrightness; // 0 - 100
@Nullable
public Boolean offlineMode; // Don't connect to wifi
@Nullable
public String model;
@Nullable
public Boolean co2CalibrationRequested; // TRIGGER: Calibration of Co2 sensor
@Nullable
public Boolean ledBarTestRequested; // TRIGGER: LEDs will run test sequence
}

View File

@ -0,0 +1,204 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.model;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Data model class for a single measurement from AirGradients API.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class Measure {
/**
* Returns a location id that is guaranteed to not be null.
*
* @return A non null location id.
*/
public String getLocationId() {
String loc = locationId;
if (loc != null) {
return loc;
}
return "";
}
/**
* Returns a location name that is guaranteed to not be null.
*
* @return A non null location name.
*/
public String getLocationName() {
String name = locationName;
return (name != null) ? name : "";
}
/**
* Returns a serial number that is guaranteed to not be null.
*
* @return A non null serial number.
*/
public String getSerialNo() {
String serial = serialno;
if (serial != null) {
return serial;
}
return "";
}
/**
* Returns a firmware version that is guaranteed to not be null.
*
* @return A non null firmware version.
*/
public String getFirmwareVersion() {
String fw = firmwareVersion;
if (fw != null) {
return fw;
}
return "";
}
public @Nullable String getModel() {
// model from cloud API
String m = model;
if (m != null) {
return m;
}
// model from local API
m = fwMode;
if (m != null) {
return m;
}
return null;
}
public @Nullable Long getBootCount() {
if (bootCount == null) {
return boot;
}
return bootCount;
}
public @Nullable Double getTemperature() {
if (atmpCompensated == null) {
return atmp;
}
return atmpCompensated;
}
public @Nullable Double getHumidity() {
if (rhumCompensated == null) {
return rhum;
}
return rhumCompensated;
}
@Nullable
public String locationId;
@Nullable
public String locationName;
@Nullable
public String serialno;
@Nullable
public Double pm01; // The raw PM 1 value in ug
@Nullable
public Double pm02; // The raw PM 2.5 value in ug
@Nullable
public Double pm10; // The raw PM 10 value in ug
@Nullable
public Double pm003Count; // The number of particles with a diameter beyond 0.3 microns in 1 deciliter of air
@Nullable
public Double atmp; // The ambient temperature in celsius
@Nullable
public Double atmpCompensated; // The ambient temperature, compensated for sensor inaccuracies
@Nullable
public Double rhum; // The relative humidity in percent
@Nullable
public Double rhumCompensated; // The relative humidity in percent, compensated for sensor inaccuracies
@Nullable
public Double rco2; // The CO2 value in ppm
@Nullable
public Double tvoc; // The TVOC value in ppb, provided in case that the sensor delivers an absolute value
@Nullable
public Double tvocIndex; // The value of the TVOC index, sensor model dependent
@Nullable
public Double tvocRaw; // Raw data from TVOC senosor
@Nullable
public Double noxIndex; // The value of the NOx index, sensor model dependent
@Nullable
public Double noxRaw; // Raw data from NOx sensor
@Nullable
public Double wifi; // The wifi signal strength in dBm
@Nullable
public Integer datapoints; // The number of datapoints, present only for aggregated data
@Nullable
public String timestamp; // Timestamp of the measures in ISO 8601 format with UTC offset, e.g. 2022-03-28T12:07:40Z
@Nullable
public String firmwareVersion; // The firmware version running on the device, e.g. "9.2.6", not present for averages
@Nullable
public String ledMode; // co2, pm, off, default
@Nullable
public String ledCo2Threshold1;
@Nullable
public String ledCo2Threshold2;
@Nullable
public String ledCo2ThresholdEnd;
@Nullable
public Long boot; // Number of times sensor has uploaded data since last reboot
@Nullable
public Long bootCount; // Same as boot, in firmwares > v3
@Nullable
public String fwMode; // Model of sensor from local API
@Nullable
public String model; // Model of sensor from cloud API
}

View File

@ -0,0 +1,124 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.prometheus;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Single metric from Prometheus.
*
* Based on specification in
* https://github.com/Showmax/prometheus-docs/blob/master/content/docs/instrumenting/exposition_formats.md
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class PrometheusMetric {
private final String metricName;
private final double value;
private final Instant timestamp;
private final Map<String, String> labels;
public PrometheusMetric(String metricName, double value, Instant timestamp, Map<String, String> labels) {
this.metricName = metricName;
this.value = value;
this.timestamp = timestamp;
this.labels = labels;
}
/**
* Parses a prometheus line.
*
* @param line The line to parse
* @return The information we are able to parse from the line
*/
public static @Nullable PrometheusMetric parse(String line) {
String trimmedLine = line.trim();
if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) {
return null;
}
String[] parts = trimmedLine.split("[{}]");
if (parts.length == 3) {
String[] valueParts = parts[2].trim().split("[\t ]+");
return switch (valueParts.length) {
case 1 -> new PrometheusMetric(parts[0], Double.parseDouble(valueParts[0]), Instant.MIN,
parseLabels(parts[1]));
case 2 -> new PrometheusMetric(parts[0], Double.parseDouble(valueParts[0]),
Instant.ofEpochMilli(Long.parseLong(valueParts[1])), parseLabels(parts[1]));
default -> null;
};
} else if (parts.length == 2) {
// no idea what this is
return null;
} else if (parts.length == 1) {
// no properties, parse on whitespace
parts = trimmedLine.split("[\t ]");
return switch (parts.length) {
case 3 -> new PrometheusMetric(parts[0], Double.parseDouble(parts[1]),
Instant.ofEpochMilli(Long.parseLong(parts[2])), new HashMap<>());
case 2 -> new PrometheusMetric(parts[0], Double.parseDouble(parts[1]), Instant.MIN, new HashMap<>());
default -> null; // No idea what this is
};
}
return null;
}
private static Map<String, String> parseLabels(String labelPart) {
String[] labels = labelPart.split(",");
Map<String, String> results = new HashMap<>(labels.length);
for (String label : labels) {
String parts[] = label.split("=");
if (parts.length != 2) {
continue;
}
String labelName = parts[0].trim();
String labelValue = parts[1].trim();
if (labelValue.startsWith("\"")) {
labelValue = labelValue.substring(1);
}
if (labelValue.endsWith("\"")) {
labelValue = labelValue.substring(0, labelValue.length() - 1);
}
results.put(labelName, labelValue);
}
return results;
}
public String getMetricName() {
return metricName;
}
public double getValue() {
return value;
}
public Instant getTimeStamp() {
return timestamp;
}
public Map<String, String> getLabels() {
return labels;
}
}

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.prometheus;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Prometheus text format parser.
*
* Based on specification in
* https://github.com/Showmax/prometheus-docs/blob/master/content/docs/instrumenting/exposition_formats.md
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class PrometheusTextParser {
public static List<PrometheusMetric> parse(String text) {
String[] lines = text.split("\\r?\\n");
List<PrometheusMetric> metrics = new ArrayList<>(lines.length);
for (String line : lines) {
@Nullable
PrometheusMetric metric = PrometheusMetric.parse(line);
if (metric != null) {
metrics.add(metric);
}
}
return metrics;
}
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="airgradient" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>AirGradient Binding</name>
<description>This is the binding for AirGradient air quality sensors.</description>
<connection>hybrid</connection>
<!-- <countries/> All -->
<discovery-methods>
<discovery-method>
<service-type>mdns</service-type>
<discovery-parameters>
<discovery-parameter>
<name>mdnsServiceType</name>
<value>_airgradient._tcp.local.</value>
</discovery-parameter>
</discovery-parameters>
</discovery-method>
</discovery-methods>
</addon:addon>

View File

@ -0,0 +1,50 @@
# add-on
addon.airgradient.name = AirGradient Binding
addon.airgradient.description = This is the binding for AirGradient air quality sensors.
# thing types
thing-type.airgradient.airgradient-api.label = AirGradient API
thing-type.airgradient.airgradient-api.description = Connection to the AirGradient API
thing-type.airgradient.location.label = AirGradient Location
thing-type.airgradient.location.description = AirGradient Location is where measurements are made
# thing types config
thing-type.config.airgradient.airgradient-api.hostname.label = Hostname
thing-type.config.airgradient.airgradient-api.hostname.description = Hostname or IP address of the API
thing-type.config.airgradient.airgradient-api.refreshInterval.label = Refresh Interval
thing-type.config.airgradient.airgradient-api.refreshInterval.description = Interval the device is polled in sec.
thing-type.config.airgradient.airgradient-api.token.label = Token
thing-type.config.airgradient.airgradient-api.token.description = Token to access the device
thing-type.config.airgradient.location.location.label = Location ID
thing-type.config.airgradient.location.location.description = ID of the location
# channel types
channel-type.airgradient.calibration.label = Calibration
channel-type.airgradient.calibration.description = Calibrate Sensors
channel-type.airgradient.calibration.command.option.co2 = co2
channel-type.airgradient.co2.label = CO2
channel-type.airgradient.co2.description = CarbonDioxide
channel-type.airgradient.leds-mode.label = LEDs Mode
channel-type.airgradient.leds-mode.description = Mode for the LEDs
channel-type.airgradient.leds-mode.state.option.default = default
channel-type.airgradient.leds-mode.state.option.off = off
channel-type.airgradient.leds-mode.state.option.pm = pm
channel-type.airgradient.leds-mode.state.option.co2 = co2
channel-type.airgradient.particle-count.label = Particle Count
channel-type.airgradient.particle-count.description = Count of particles in 1 decilitre of air
channel-type.airgradient.pm1.label = PM1
channel-type.airgradient.pm1.description = Particulate Matter 1 (0.001mm)
channel-type.airgradient.pm10.label = PM10
channel-type.airgradient.pm10.description = Particulate Matter 10 (0.01mm)
channel-type.airgradient.pm2.label = PM2
channel-type.airgradient.pm2.description = Particulate Matter 2 (0.002mm)
channel-type.airgradient.tvoc.label = TVOC
channel-type.airgradient.tvoc.description = Total Volatile Organic Compounds
channel-type.airgradient.uploads-since-boot.label = Upload count
channel-type.airgradient.uploads-since-boot.description = Number of uploads since last reboot (boot)
channel-type.airgradient.wifi.label = RSSI
channel-type.airgradient.wifi.description = Received signal strength indicator

View File

@ -0,0 +1,328 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="airgradient"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- A connection to the Cloud API which can have several locations (sensors) connected -->
<bridge-type id="airgradient-api">
<label>AirGradient API</label>
<description>Connection to the AirGradient Cloud API</description>
<representation-property>token</representation-property>
<config-description>
<parameter name="token" type="text" required="false">
<context>password</context>
<label>Token</label>
<description>Token to access the device</description>
</parameter>
<parameter name="hostname" type="text">
<context>network-address</context>
<label>Hostname</label>
<default>https://api.airgradient.com/</default>
<description>Hostname or IP address of the API</description>
<advanced>true</advanced>
</parameter>
<parameter name="refreshInterval" type="integer" unit="s" min="1">
<label>Refresh Interval</label>
<description>Interval the device is polled in sec.</description>
<default>600</default>
<advanced>true</advanced>
</parameter>
</config-description>
</bridge-type>
<!-- A sensor you communicate directly to over the local network -->
<thing-type id="airgradient-local">
<label>AirGradient Local Sensor</label>
<description>Direct network connection to a local AirGradient Sensor</description>
<channels>
<channel id="pm01" typeId="pm1"/>
<channel id="pm02" typeId="pm2"/>
<channel id="pm10" typeId="pm10"/>
<channel id="pm003-count" typeId="particle-count"/>
<channel id="atmp" typeId="system.outdoor-temperature"/>
<channel id="rhum" typeId="system.atmospheric-humidity"/>
<channel id="wifi" typeId="wifi"/>
<channel id="rco2" typeId="co2"/>
<channel id="tvoc" typeId="tvoc"/>
<channel id="leds" typeId="leds-mode"/>
<channel id="calibration" typeId="calibration"/>
<channel id="uploads-since-boot" typeId="uploads-since-boot"/>
<!-- These are added dynamically if the device supports them
<channel id="country-code" typeId="country-code"/>
<channel id="pm-standard" typeId="pm-standard"/>
<channel id="abc-days" typeId="abc-days"/>
<channel id="tvoc-learning-offset" typeId="tvoc-learning-offset"/>
<channel id="nox-learning-offset" typeId="nox-learning-offset"/>
<channel id="mqtt-broker-url" typeId="mqtt-broker-url"/>
<channel id="temperature-unit" typeId="temperature-unit"/>
<channel id="configuration-control" typeId="configuration-control"/>
<channel id="post-to-cloud" typeId="post-to-cloud"/>
<channel id="led-bar-brightness" typeId="led-bar-brightness"/>
<channel id="display-brightness" typeId="display-brightness"/>
<channel id="model" typeId="model"/>
<channel id="led-bar-test" typeId="led-bar-test"/>
-->
</channels>
<properties>
<property name="name"/>
<property name="firmwareVersion"/>
<property name="serialNumber"/>
<property name="modelId"/>
<property name="thingTypeVersion">1</property>
</properties>
<representation-property>serialNumber</representation-property>
<config-description>
<parameter name="hostname" type="text">
<context>network-address</context>
<label>Hostname</label>
<default>http://192.168.1.1:80/measures/current</default>
<description>Hostname or IP address of the API</description>
<advanced>false</advanced>
</parameter>
<parameter name="refreshInterval" type="integer" unit="s" min="1">
<label>Refresh Interval</label>
<description>Interval the device is polled in sec.</description>
<default>10</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<!-- Sensors are called locations in the Cloud API -->
<thing-type id="location">
<supported-bridge-type-refs>
<bridge-type-ref id="airgradient-api"/>
</supported-bridge-type-refs>
<label>AirGradient Location</label>
<description>AirGradient Location for data from the AirGradient Cloud API</description>
<channels>
<channel id="pm01" typeId="pm1"/>
<channel id="pm02" typeId="pm2"/>
<channel id="pm10" typeId="pm10"/>
<channel id="pm003-count" typeId="particle-count"/>
<channel id="atmp" typeId="system.outdoor-temperature"/>
<channel id="rhum" typeId="system.atmospheric-humidity"/>
<channel id="wifi" typeId="wifi"/>
<channel id="rco2" typeId="co2"/>
<channel id="tvoc" typeId="tvoc"/>
<channel id="leds" typeId="leds-mode"/>
<channel id="calibration" typeId="calibration"/>
<channel id="uploads-since-boot" typeId="uploads-since-boot"/>
</channels>
<properties>
<property name="name"/>
<property name="firmwareVersion"/>
<property name="serialNumber"/>
<property name="modelId"/>
<property name="thingTypeVersion">1</property>
</properties>
<representation-property>location</representation-property>
<config-description>
<parameter name="location" type="text" required="true">
<label>Location ID</label>
<description>ID of the location</description>
</parameter>
</config-description>
</thing-type>
<channel-type id="pm1">
<item-type>Number:Density</item-type>
<label>PM1</label>
<description>Particulate Matter 1 (0.001mm)</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="pm2">
<item-type>Number:Density</item-type>
<label>PM2</label>
<description>Particulate Matter 2 (0.002mm)</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="pm10">
<item-type>Number:Density</item-type>
<label>PM10</label>
<description>Particulate Matter 10 (0.01mm)</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="particle-count">
<item-type>Number:Dimensionless</item-type>
<label>Particle Count</label>
<description>Count of particles in 1 decilitre of air</description>
<state readOnly="true" pattern="%d"/>
</channel-type>
<channel-type id="wifi">
<item-type unitHint="dBm">Number:Power</item-type>
<label>RSSI</label>
<description>Received signal strength indicator</description>
<category>QualityOfService</category>
<state readOnly="true" pattern="%d dBm"/>
</channel-type>
<channel-type id="co2">
<item-type>Number:Dimensionless</item-type>
<label>CO2</label>
<description>CarbonDioxide</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="tvoc">
<item-type>Number:Dimensionless</item-type>
<label>TVOC</label>
<description>Total Volatile Organic Compounds</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="uploads-since-boot">
<item-type>Number:Dimensionless</item-type>
<label>Upload count</label>
<description>Number of uploads since last reboot (boot)</description>
<state readOnly="true" pattern="%d"/>
</channel-type>
<channel-type id="leds-mode">
<item-type>String</item-type>
<label>LEDs Mode</label>
<description>Mode for the LEDs</description>
<state readOnly="false">
<options>
<option value="default">default</option>
<option value="off">off</option>
<option value="pm">pm</option>
<option value="co2">co2</option>
</options>
</state>
</channel-type>
<channel-type id="calibration">
<item-type>String</item-type>
<label>Calibration</label>
<description>Calibrate Sensors</description>
<command>
<options>
<option value="co2">co2</option>
</options>
</command>
</channel-type>
<channel-type id="country-code">
<item-type>String</item-type>
<label>Country code</label>
<description>2 digit country code (ALPHA-2)</description>
<state readOnly="false"/>
</channel-type>
<channel-type id="pm-standard">
<item-type>String</item-type>
<label>Parts per Million Standard</label>
<description>Standard used for Parts per Million measurements</description>
<state readOnly="false">
<options>
<option value="us-aqi">USAqi</option>
<option value="ugm3">ugm3</option>
</options>
</state>
</channel-type>
<channel-type id="abc-days">
<item-type>Number</item-type>
<label>Automatic Baseline Calibration (Days)</label>
<description>Co2 calibration automatic baseline calibration days</description>
<state min="0" max="200" step="1" readOnly="false" pattern="%d %unit%"/>
</channel-type>
<channel-type id="tvoc-learning-offset">
<item-type>Number</item-type>
<label>TVOC learnings offset (hours)</label>
<description>Time constant of long-term estimator for offset. Past events will be forgotten after about twice the
learning time.</description>
<state min="0" max="1000" step="1" readOnly="false" pattern="%d %unit%"/>
</channel-type>
<channel-type id="nox-learning-offset">
<item-type>Number</item-type>
<label>NOX learnings offset (hours)</label>
<description>Time constant of long-term estimator for offset. Past events will be forgotten after about twice the
learning time.</description>
<state min="0" max="1000" step="1" readOnly="false" pattern="%d %unit%"/>
</channel-type>
<channel-type id="mqtt-broker-url">
<item-type>String</item-type>
<label>MQTT Broker URL</label>
<description>MQTT Broker URL</description>
<state readOnly="false"/>
</channel-type>
<channel-type id="temperature-unit">
<item-type>String</item-type>
<label>Temperature Unit</label>
<description>Temperature unit used on the display</description>
<state readOnly="false">
<options>
<option value="c">Celsius</option>
<option value="f">Fahrenheit</option>
</options>
</state>
</channel-type>
<channel-type id="configuration-control">
<item-type>String</item-type>
<label>Configuration Control</label>
<description>Where the unit is configured from</description>
<state readOnly="false">
<options>
<option value="both">Both</option>
<option value="local">Local</option>
<option value="cloud">Cloud</option>
</options>
</state>
</channel-type>
<channel-type id="post-to-cloud">
<item-type>Switch</item-type>
<label>Send to cloud</label>
<description>Send data to the AirGradient cloud</description>
<state readOnly="false"/>
</channel-type>
<channel-type id="led-bar-brightness">
<item-type>Number:Dimensionless</item-type>
<label>Led bar brightness</label>
<description>Brightness of the LED bar.</description>
<state min="0" max="100" step="1" readOnly="false" pattern="%d"/>
</channel-type>
<channel-type id="display-brightness">
<item-type>Number:Dimensionless</item-type>
<label>Display brightness</label>
<description>Brightness of the display.</description>
<state min="0" max="100" step="1" readOnly="false" pattern="%d"/>
</channel-type>
<channel-type id="model">
<item-type>String</item-type>
<label>Model</label>
<description>Model of the device</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="led-bar-test">
<item-type>String</item-type>
<label>LED Bar test</label>
<description>Test LED bar</description>
<state readOnly="false"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<update:update-descriptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:update="https://openhab.org/schemas/update-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/update-description/v1.0.0 https://openhab.org/schemas/update-description-1.0.0.xsd">
<thing-type uid="airgradient:airgradient-local">
<instruction-set targetVersion="1">
<update-channel id="wifi">
<type>airgradient:wifi</type>
</update-channel>
</instruction-set>
</thing-type>
<thing-type uid="airgradient:airgradient-location">
<instruction-set targetVersion="1">
<update-channel id="wifi">
<type>airgradient:wifi</type>
</update-channel>
</instruction-set>
</thing-type>
</update:update-descriptions>

View File

@ -0,0 +1,74 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.core.Is.is;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
/**
* @author Jørgen Austvik - Initial contribution
*/
@SuppressWarnings({ "null" })
@NonNullByDefault
public class AirGradientHandlerFactoryTest {
@Test
public void testSupportsThingTypes() {
HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class);
AirGradientHandlerFactory sut = new AirGradientHandlerFactory(httpClientFactoryMock);
assertThat(sut.supportsThingType(AirGradientBindingConstants.THING_TYPE_API), is(true));
assertThat(sut.supportsThingType(AirGradientBindingConstants.THING_TYPE_LOCATION), is(true));
assertThat(sut.supportsThingType(new ThingTypeUID("unknown", "thingtype")), is(false));
}
@Test
public void testCanCreateAPIHandler() {
HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class);
AirGradientHandlerFactory sut = new AirGradientHandlerFactory(httpClientFactoryMock);
Bridge bridgeMock = Mockito.mock(Bridge.class);
Mockito.when(bridgeMock.getThingTypeUID()).thenReturn(AirGradientBindingConstants.THING_TYPE_API);
assertThat(sut.createHandler(bridgeMock), is(notNullValue()));
}
@Test
public void testCanCreateLocationHandler() {
HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class);
AirGradientHandlerFactory sut = new AirGradientHandlerFactory(httpClientFactoryMock);
Thing thingMock = Mockito.mock(Thing.class);
Mockito.when(thingMock.getThingTypeUID()).thenReturn(AirGradientBindingConstants.THING_TYPE_LOCATION);
assertThat(sut.createHandler(thingMock), is(notNullValue()));
}
@Test
public void testCanCreateUnknownHandler() {
HttpClientFactory httpClientFactoryMock = Mockito.mock(HttpClientFactory.class);
AirGradientHandlerFactory sut = new AirGradientHandlerFactory(httpClientFactoryMock);
Thing thingMock = Mockito.mock(Thing.class);
Mockito.when(thingMock.getThingTypeUID()).thenReturn(new ThingTypeUID("unknown", "thingtype"));
assertThat(sut.createHandler(thingMock), is(nullValue()));
}
}

View File

@ -0,0 +1,119 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import static org.eclipse.jdt.annotation.Checks.requireNonNull;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.openhab.binding.airgradient.internal.communication.RemoteAPIController;
import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.binding.ThingHandlerCallback;
import com.google.gson.Gson;
/**
* @author Jørgen Austvik - Initial contribution
*/
@SuppressWarnings({ "null" })
@NonNullByDefault
public class AirGradientAPIHandlerTest {
private static final AirGradientAPIConfiguration TEST_CONFIG = new AirGradientAPIConfiguration() {
{
hostname = "abc123";
token = "def456";
}
};
private static final String MULTI_CONTENT = """
[
{"locationId":1234,"locationName":"Some Name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":null,"tvoc":null,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null},
{"locationId":4321,"locationName":"Some other name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":null,"tvoc":null,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null}
]""";
@Nullable
private AirGradientAPIHandler sut;
@Nullable
Bridge bridge;
@Nullable
HttpClient httpClientMock;
@Nullable
Request requestMock;
@BeforeEach
public void setUp() {
bridge = Mockito.mock(Bridge.class);
httpClientMock = Mockito.mock(HttpClient.class);
requestMock = Mockito.mock(Request.class);
sut = new AirGradientAPIHandler(requireNonNull(bridge), requireNonNull(httpClientMock));
sut.setConfiguration(TEST_CONFIG);
sut.setApiController(new RemoteAPIController(requireNonNull(httpClientMock), new Gson(), TEST_CONFIG));
}
@Test
public void testGetRegisteredNone() {
var res = sut.getRegisteredLocationIds();
assertThat(res, is(empty()));
}
@Test
public void testPollNoData() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getStatus()).thenReturn(500);
ThingHandlerCallback callbackMock = Mockito.mock(ThingHandlerCallback.class);
sut.setCallback(callbackMock);
sut.pollingCode();
verify(callbackMock).statusUpdated(requireNonNull(bridge), new ThingStatusInfo(ThingStatus.OFFLINE,
ThingStatusDetail.COMMUNICATION_ERROR, "Returned status code: 500"));
}
@Test
public void testPollHasData() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getStatus()).thenReturn(200);
Mockito.when(response.getContentAsString()).thenReturn(MULTI_CONTENT);
ThingHandlerCallback callbackMock = Mockito.mock(ThingHandlerCallback.class);
sut.setCallback(callbackMock);
sut.pollingCode();
verify(callbackMock).statusUpdated(requireNonNull(bridge),
new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null));
}
}

View File

@ -0,0 +1,132 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import static org.eclipse.jdt.annotation.Checks.requireNonNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandlerCallback;
import org.openhab.core.types.UnDefType;
/**
* @author Jørgen Austvik - Initial contribution
*/
@SuppressWarnings({ "null" })
@NonNullByDefault
public class AirGradientLocationHandlerTest {
private static final Measure TEST_MEASURE = new Measure() {
{
locationId = "12345";
locationName = "Location name";
pm01 = 2d;
pm02 = 3d;
pm10 = 4d;
pm003Count = 636d;
atmp = 19.63;
rhum = null;
rco2 = 455d;
tvoc = 51.644928;
wifi = -59d;
timestamp = "2024-01-07T11:28:56.000Z";
serialno = "ecda3b1a2a50";
firmwareVersion = "12345";
tvocIndex = 1d;
noxIndex = 2d;
}
};
private static final Measure TEST_MEASURE_V3_1_1 = new Measure() {
{
locationId = "12345";
locationName = "Location name";
timestamp = "2024-01-07T11:28:56.000Z";
serialno = "ecda3b1a2a50";
firmwareVersion = "3.1.1";
atmpCompensated = 24.2;
rhumCompensated = 36d;
bootCount = 16l;
}
};
@Nullable
private AirGradientLocationHandler sut;
@Nullable
private ThingHandlerCallback callbackMock;
@Nullable
private Thing thing;
@BeforeEach
public void setUp() {
callbackMock = Mockito.mock(ThingHandlerCallback.class);
Mockito.when(callbackMock.isChannelLinked(any(ChannelUID.class))).thenReturn(true);
thing = Mockito.mock(Thing.class);
sut = new AirGradientLocationHandler(requireNonNull(thing));
sut.setCallback(callbackMock);
Mockito.when(thing.getUID()).thenReturn(new ThingUID(THING_TYPE_LOCATION, "1234"));
}
@Test
public void testSetMeasure() {
sut.setCallback(callbackMock);
sut.setMeasurment(TEST_MEASURE);
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_WIFI),
new QuantityType<>("-59 dBm"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_PM_01),
new QuantityType<>("2 µg/m³"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_PM_02),
new QuantityType<>("3 µg/m³"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_PM_10),
new QuantityType<>("4 µg/m³"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_PM_003_COUNT),
new QuantityType<>("636"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_ATMP),
new QuantityType<>("19.63 °C"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_RHUM), UnDefType.NULL);
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_RCO2),
new QuantityType<>("455 ppm"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_TVOC),
new QuantityType<>("51 ppb"));
}
// Firmware Version 3.1.1 has slight changes in the Json
@Test
public void testSetMeasureVersion3_1_1() {
sut.setCallback(callbackMock);
sut.setMeasurment(TEST_MEASURE_V3_1_1);
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_ATMP),
new QuantityType<>("24.2 °C"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_RHUM),
new QuantityType<>("36 %"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_UPLOADS_SINCE_BOOT),
new QuantityType<>("16"));
}
}

View File

@ -0,0 +1,291 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.handler;
import static org.eclipse.jdt.annotation.Checks.requireNonNull;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.closeTo;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.mockito.ArgumentMatchers.anyString;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException;
import org.openhab.binding.airgradient.internal.communication.RemoteAPIController;
import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration;
import com.google.gson.Gson;
/**
* @author Jørgen Austvik - Initial contribution
*/
@SuppressWarnings({ "null" })
@NonNullByDefault
public class RemoteApiControllerTest {
private static final AirGradientAPIConfiguration TEST_CONFIG = new AirGradientAPIConfiguration() {
{
hostname = "abc123";
token = "def456";
}
};
private static final String SINGLE_CONFIG = """
{"country":"NO","pmStandard":"ugm3","ledBarMode":"off","abcDays":8,"tvocLearningOffset":12,"noxLearningOffset":12,"mqttBrokerUrl":"https://192.168.1.1/mqtt","temperatureUnit":"c","configurationControl":"both","postDataToAirGradient":true,"ledBarBrightness":100,"displayBrightness":100,"offlineMode":false,"model":"I-9PSL"}
""";
private static final String SINGLE_CONTENT = """
{"locationId":4321,"locationName":"Some other name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":456,"tvoc":51.644928,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null}
""";
private static final String MULTI_CONTENT = """
[
{"locationId":1234,"locationName":"Some Name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":null,"tvoc":null,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null},
{"locationId":4321,"locationName":"Some other name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":null,"tvoc":null,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null}
]""";
private static final String MULTI_CONTENT2 = """
[{"locationId":654321,"locationName":"xxxx","pm01":0,"pm02":1,"pm10":1,"pm003Count":null,"atmp":24.2,"rhum":18,"rco2":519,"tvoc":50.793266,"wifi":-62,"timestamp":"2024-02-01T19:15:37.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"ecda3b1a2a50","firmwareVersion":null,"tvocIndex":52,"noxIndex":1},{"locationId":123456,"locationName":"yyyy","pm01":0,"pm02":0,"pm10":0,"pm003Count":105,"atmp":22.33,"rhum":24,"rco2":468,"tvoc":130.95694,"wifi":-50,"timestamp":"2024-02-01T19:15:34.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"84fce612e644","firmwareVersion":null,"tvocIndex":137,"noxIndex":1}]
""";
private static final String PROMETHEUS_CONTENT = """
# HELP pm02 Particulate Matter PM2.5 value
# TYPE pm02 gauge
pm02{id="Airgradient"}6
# HELP rco2 CO2 value, in ppm
# TYPE rco2 gauge
rco2{id="Airgradient"}862
# HELP atmp Temperature, in degrees Celsius
# TYPE atmp gauge
atmp{id="Airgradient"}31.6
# HELP rhum Relative humidity, in percent
# TYPE rhum gauge
rhum{id="Airgradient"}38
# HELP tvoc Total volatile organic components, in μg/m³
# TYPE tvoc gauge
tvoc{id="Airgradient"}51.644928
# HELP nox, in μg/m³
# TYPE nox gauge
nox{id="Airgradient"}1
""";
private static final String OPEN_METRICS_CONTENT = """
# HELP airgradient_info AirGradient device information
# TYPE airgradient_info info
airgradient_info{airgradient_serial_number="4XXXXXXXXXXc",airgradient_device_type="ONE_I-9PSL",airgradient_library_version="3.0.4"} 1
# HELP airgradient_config_ok 1 if the AirGradient device was able to successfully fetch its configuration from the server
# TYPE airgradient_config_ok gauge
airgradient_config_ok{} 1
# HELP airgradient_post_ok 1 if the AirGradient device was able to successfully send to the server
# TYPE airgradient_post_ok gauge
airgradient_post_ok{} 1
# HELP airgradient_wifi_rssi_dbm WiFi signal strength from the AirGradient device perspective, in dBm
# TYPE airgradient_wifi_rssi_dbm gauge
# UNIT airgradient_wifi_rssi_dbm dbm
airgradient_wifi_rssi_dbm{} -51
# HELP airgradient_co2_ppm Carbon dioxide concentration as measured by the AirGradient S8 sensor, in parts per million
# TYPE airgradient_co2_ppm gauge
# UNIT airgradient_co2_ppm ppm
airgradient_co2_ppm{} 589
# HELP airgradient_pm1_ugm3 PM1.0 concentration as measured by the AirGradient PMS sensor, in micrograms per cubic meter
# TYPE airgradient_pm1_ugm3 gauge
# UNIT airgradient_pm1_ugm3 ugm3
airgradient_pm1_ugm3{} 3
# HELP airgradient_pm2d5_ugm3 PM2.5 concentration as measured by the AirGradient PMS sensor, in micrograms per cubic meter
# TYPE airgradient_pm2d5_ugm3 gauge
# UNIT airgradient_pm2d5_ugm3 ugm3
airgradient_pm2d5_ugm3{} 3
# HELP airgradient_pm10_ugm3 PM10 concentration as measured by the AirGradient PMS sensor, in micrograms per cubic meter
# TYPE airgradient_pm10_ugm3 gauge
# UNIT airgradient_pm10_ugm3 ugm3
airgradient_pm10_ugm3{} 3
# HELP airgradient_pm0d3_p100ml PM0.3 concentration as measured by the AirGradient PMS sensor, in number of particules per 100 milliliters
# TYPE airgradient_pm0d3_p100ml gauge
# UNIT airgradient_pm0d3_p100ml p100ml
airgradient_pm0d3_p100ml{} 594
# HELP airgradient_tvoc_index The processed Total Volatile Organic Compounds (TVOC) index as measured by the AirGradient SGP sensor
# TYPE airgradient_tvoc_index gauge
airgradient_tvoc_index{} 220
# HELP airgradient_tvoc_raw_index The raw input value to the Total Volatile Organic Compounds (TVOC) index as measured by the AirGradient SGP sensor
# TYPE airgradient_tvoc_raw_index gauge
airgradient_tvoc_raw_index{} 30801
# HELP airgradient_nox_index The processed Nitrous Oxide (NOx) index as measured by the AirGradient SGP sensor
# TYPE airgradient_nox_index gauge
airgradient_nox_index{} 1
# HELP airgradient_temperature_degc The ambient temperature as measured by the AirGradient SHT sensor, in degrees Celsius
# TYPE airgradient_temperature_degc gauge
# UNIT airgradient_temperature_degc degc
airgradient_temperature_degc{} 23.69
# HELP airgradient_humidity_percent The relative humidity as measured by the AirGradient SHT sensor
# TYPE airgradient_humidity_percent gauge
# UNIT airgradient_humidity_percent percent
airgradient_humidity_percent{} 39
# EOF
""";
@Nullable
private RemoteAPIController sut;
@Nullable
HttpClient httpClientMock;
@Nullable
Request requestMock;
@BeforeEach
public void setUp() {
httpClientMock = Mockito.mock(HttpClient.class);
requestMock = Mockito.mock(Request.class);
sut = new RemoteAPIController(requireNonNull(httpClientMock), new Gson(), TEST_CONFIG);
}
@Test
public void testGetMeasuresNone() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getMediaType()).thenReturn("application/json");
Mockito.when(response.getStatus()).thenReturn(500);
AirGradientCommunicationException agce = Assertions.assertThrows(AirGradientCommunicationException.class,
() -> sut.getMeasures());
assertThat(agce.getMessage(), is("Returned status code: 500"));
}
@Test
public void testGetMeasuresSingle() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getStatus()).thenReturn(200);
Mockito.when(response.getMediaType()).thenReturn("application/json");
Mockito.when(response.getContentAsString()).thenReturn(SINGLE_CONTENT);
var res = sut.getMeasures();
assertThat(res, is(not(empty())));
assertThat(res.size(), is(1));
assertThat(res.get(0).locationName, is("Some other name"));
}
@Test
public void testGetMeasuresMulti() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getStatus()).thenReturn(200);
Mockito.when(response.getMediaType()).thenReturn("application/json");
Mockito.when(response.getContentAsString()).thenReturn(MULTI_CONTENT);
var res = sut.getMeasures();
assertThat(res, is(not(empty())));
assertThat(res.size(), is(2));
assertThat(res.get(0).locationName, is("Some Name"));
assertThat(res.get(1).locationName, is("Some other name"));
}
@Test
public void testGetMeasuresMulti2() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getStatus()).thenReturn(200);
Mockito.when(response.getMediaType()).thenReturn("application/json");
Mockito.when(response.getContentAsString()).thenReturn(MULTI_CONTENT2);
var res = sut.getMeasures();
assertThat(res, is(not(empty())));
assertThat(res.size(), is(2));
assertThat(res.get(0).locationName, is("xxxx"));
assertThat(res.get(1).locationName, is("yyyy"));
}
@Test
public void testGetMeasuresPrometheus() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getStatus()).thenReturn(200);
Mockito.when(response.getMediaType()).thenReturn("text/plain");
Mockito.when(response.getContentAsString()).thenReturn(PROMETHEUS_CONTENT);
var res = sut.getMeasures();
assertThat(res, is(not(empty())));
assertThat(res.size(), is(1));
assertThat(res.get(0).pm02, closeTo(6, 0.1));
assertThat(res.get(0).rco2, closeTo(862, 0.1));
assertThat(res.get(0).atmp, closeTo(31.6, 0.1));
assertThat(res.get(0).rhum, closeTo(38, 0.1));
assertThat(res.get(0).tvoc, closeTo(51.644928, 0.1));
assertThat(res.get(0).noxIndex, closeTo(1, 0.1));
assertThat(res.get(0).locationId, is("Airgradient"));
assertThat(res.get(0).locationName, is("Airgradient"));
assertThat(res.get(0).serialno, is("Airgradient"));
}
@Test
public void testGetMeasuresOpenMetrics() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getStatus()).thenReturn(200);
Mockito.when(response.getMediaType()).thenReturn("application/openmetrics-text");
Mockito.when(response.getContentAsString()).thenReturn(OPEN_METRICS_CONTENT);
var res = sut.getMeasures();
assertThat(res, is(not(empty())));
assertThat(res.size(), is(1));
assertThat(res.get(0).pm01, closeTo(3, 0.1));
assertThat(res.get(0).pm02, closeTo(3, 0.1));
assertThat(res.get(0).pm10, closeTo(3, 0.1));
assertThat(res.get(0).rco2, closeTo(589, 0.1));
assertThat(res.get(0).atmp, closeTo(23.69, 0.1));
assertThat(res.get(0).rhum, closeTo(39, 0.1));
assertThat(res.get(0).tvoc, closeTo(220, 0.1));
assertThat(res.get(0).noxIndex, closeTo(1, 0.1));
assertThat(res.get(0).serialno, is("4XXXXXXXXXXc"));
}
@Test
public void testGetConfig() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getStatus()).thenReturn(200);
Mockito.when(response.getMediaType()).thenReturn("application/json");
Mockito.when(response.getContentAsString()).thenReturn(SINGLE_CONFIG);
var res = sut.getConfig();
assertThat(res.abcDays, is(8L));
assertThat(res.configurationControl, is("both"));
assertThat(res.country, is("NO"));
assertThat(res.displayBrightness, is(100L));
assertThat(res.ledBarBrightness, is(100L));
assertThat(res.ledBarMode, is("off"));
assertThat(res.model, is("I-9PSL"));
assertThat(res.mqttBrokerUrl, is("https://192.168.1.1/mqtt"));
assertThat(res.noxLearningOffset, is(12L));
assertThat(res.pmStandard, is("ugm3"));
assertThat(res.postDataToAirGradient, is(true));
assertThat(res.temperatureUnit, is("c"));
assertThat(res.tvocLearningOffset, is(12L));
}
}

View File

@ -0,0 +1,77 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airgradient.internal.prometheus;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.closeTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
import java.time.Instant;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
/**
* @author Jørgen Austvik - Initial contribution
*/
@SuppressWarnings({ "null" })
@NonNullByDefault
public class PrometheusMetricTest {
@Test
public void testParseEmpty() {
var res = PrometheusMetric.parse("");
assertThat(res, is(nullValue()));
}
@Test
public void testParseComment() {
var res = PrometheusMetric.parse("# Comment");
assertThat(res, is(nullValue()));
}
@Test
public void testParseAirGradient() {
var res = PrometheusMetric.parse("atmp{id=\"Airgradient\"}31.6");
assertThat(res.getMetricName(), is("atmp"));
assertThat(res.getValue(), closeTo(31.6, 0.1));
assertThat(res.getLabels().get("id"), is("Airgradient"));
}
@Test
public void testParseNoLables() {
var res = PrometheusMetric.parse("http_request_duration_seconds_count 144320");
assertThat(res.getMetricName(), is("http_request_duration_seconds_count"));
assertThat(res.getValue(), closeTo(144320, 0.1));
}
@Test
public void testParseWithTimestamp() {
var res = PrometheusMetric.parse("http_requests_total{method=\"post\",code=\"200\"} 1027 1395066363000");
assertThat(res.getMetricName(), is("http_requests_total"));
assertThat(res.getValue(), closeTo(1027, 0.1));
assertThat(res.getTimeStamp(), is(Instant.ofEpochMilli(1395066363000L)));
assertThat(res.getLabels().get("method"), is("post"));
assertThat(res.getLabels().get("code"), is("200"));
}
@Test
public void testParseNegativeEpoch() {
var res = PrometheusMetric.parse("something_weird{problem=\"division by zero\"} 123 -3982045");
assertThat(res.getMetricName(), is("something_weird"));
assertThat(res.getTimeStamp(), is(Instant.ofEpochMilli(-3982045)));
assertThat(res.getValue(), closeTo(123, 0.1));
assertThat(res.getLabels().get("problem"), is("division by zero"));
}
}

View File

@ -53,25 +53,28 @@ The rw column is empty if the channel is only readable, w if the channel can be
| fineDustCnt02_5 | Number:Dimensionless | | Fine Dust >2,5 µm |
| fineDustCnt05 | Number:Dimensionless | | Fine Dust >5 µm |
| fineDustCnt10 | Number:Dimensionless | | Fine Dust >10 µm |
| co | Number | | CO concentration |
| co2 | Number:Dimensionless | | CO₂ concentration |
| co | Number:Density | | Carbon monoxide (CO) concentration |
| co2 | Number:Dimensionless | | Carbon dioxide (CO₂) concentration |
| dCO2dt | Number | | Change of CO₂ concentration |
| dHdt | Number | | Change of Humidity |
| dewpt | Number:Temperature | | Dew Point |
| doorEvent | Number | | Door Event (experimental, might not work reliably) |
| h2s | Number:Density | | Hydrogen sulfide (H₂S) |
| healthIndex | Number:Dimensionless | | Health Index in percent |
| health | Number:Dimensionless | | Health Index (0 to 1000, -200 for gas alarm, -800 for fire alarm) |
| humidityRelative | Number:Dimensionless | | Humidity in percent |
| humidityAbsolute | Number | | Absolute Humidity |
| humidityAbsolute | Number:Density | | Absolute Humidity |
| measureTime | Number:Time | | Milliseconds needed for measurement |
| no2 | Number | | NO₂ concentration |
| o3 | Number | | Ozone (O₃) concentration |
| no2 | Number:Density | | Nitrogen Dioxide (NO₂) concentration |
| o3 | Number:Density | | Ozone (O₃) concentration |
| o2 | Number:Dimensionless | | Oxygen (O₂) concentration |
| performanceIndex | Number:Dimensionless | | Performance Index in percent |
| performance | Number:Dimensionless | | Performance Index (0 to 1000) |
| fineDustConc01 | Number | | Fine Dust concentration >1 µm |
| fineDustConc02_5 | Number | | Fine Dust concentration >2.5 µm |
| fineDustConc10 | Number | | Fine Dust concentration >10 µm fni |
| pressure | Number:Pressure | | Pressure |
| so2 | Number | | SO₂ concentration |
| fineDustConc10 | Number | | Fine Dust concentration >10 µm |
| pressure | Number:Pressure | | Barometric Pressure |
| so2 | Number | | Sulfur dioxide (SO₂) concentration |
| sound | Number:Dimensionless | | Noise |
| temperature | Number:Temperature | | Temperature |
| timestamp | DateTime | | Timestamp of measurement |
@ -116,6 +119,12 @@ The rw column is empty if the channel is only readable, w if the channel can be
| errorBars | Switch | rw | Calculate Maximum Errors |
| warmupPhase | Switch | rw | Output data as Warmup Phase |
## Usage with Docker
This binding requires the JVM cryptographic strength policy to be set to "unlimited".
Otherwise the connection to the device will fail.
See the [openHAB Docker image documentation](https://github.com/openhab/openhab-docker/blob/main/README.md#java-cryptographic-strength-policy) for details.
## Example
### air-Q.things

View File

@ -7,7 +7,7 @@
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.2.0-SNAPSHOT</version>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.airq</artifactId>

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airq.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Exception for handling an empty response.
*
* @author Fabian Wolter - Initial contribution
*/
@NonNullByDefault
public class AirqEmptyResonseException extends AirqException {
private static final long serialVersionUID = 1423144673651821622L;
public AirqEmptyResonseException() {
super("Device sent an empty response");
}
}

View File

@ -0,0 +1,41 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airq.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* General exception for this binding.
*
* @author Fabian Wolter - Initial contribution
*/
@NonNullByDefault
public class AirqException extends Exception {
private static final long serialVersionUID = 8255154215873928896L;
public AirqException() {
// nothing
}
public AirqException(String message) {
super(message);
}
public AirqException(Exception exception) {
super(exception);
}
public AirqException(String message, Exception exception) {
super(message, exception);
}
}

View File

@ -65,16 +65,17 @@ import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
/**
* The {@link AirqHandler} is responsible for retrieving all information from the air-Q device
* and change properties and channels accordingly.
*
* @author Aurelio Caliaro - Initial contribution
* @author Fabian Wolter - Improve error handling
*/
@NonNullByDefault
public class AirqHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(AirqHandler.class);
private final Gson gson = new Gson();
private @Nullable ScheduledFuture<?> pollingJob;
@ -100,19 +101,23 @@ public class AirqHandler extends BaseThingHandler {
* Expects a string consisting of two values as sent by the air-Q device
* and returns a corresponding object
*
* @param input string formed as this: [1234,56,789,012] (including the brackets)
* @param input element as JSON array
* @return ResultPair object with the two values
* @throws AirqException when parsing fails
*/
public ResultPair(String input) {
value = Float.parseFloat(input.substring(1, input.indexOf(',')));
maxdev = Float.parseFloat(input.substring(input.indexOf(',') + 1, input.length() - 1));
public ResultPair(JsonElement input) throws JsonSyntaxException {
if (input instanceof JsonArray pair && pair.size() == 2) {
value = pair.get(0).getAsFloat();
maxdev = pair.get(1).getAsFloat();
} else {
throw new JsonSyntaxException("Failed to parse pair: " + input);
}
}
}
public AirqHandler(Thing thing, HttpClient httpClient) {
super(thing);
this.httpClient = httpClient;
logger.warn("air-Q - airqHandler - constructor: httpClient={}", httpClient);
}
private boolean isTimeFormat(String str) {
@ -309,17 +314,7 @@ public class AirqHandler extends BaseThingHandler {
public void initialize() {
config = getThing().getConfiguration().as(AirqConfiguration.class);
updateStatus(ThingStatus.UNKNOWN);
// We don't have to test if ipAddress and password have been set because we have defined them
// as being 'required' in thing-types.xml and OpenHAB will only initialize the handler if both are set.
String data = getDecryptedContentString("http://" + config.ipAddress + "/data", "GET", null);
// we try if the device is reachable and the password is correct. Otherwise a corresponding message is
// thrown in Thing manager.
if (data == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Unable to retrieve get data from air-Q device. Probable cause: invalid password.");
} else {
updateStatus(ThingStatus.ONLINE);
}
pollingJob = scheduler.scheduleWithFixedDelay(this::pollData, 0, POLLING_PERIOD_DATA_MSEC,
TimeUnit.MILLISECONDS);
getConfigDataJob = scheduler.scheduleWithFixedDelay(this::getConfigData, 0, POLLING_PERIOD_CONFIG,
@ -327,9 +322,8 @@ public class AirqHandler extends BaseThingHandler {
}
// AES decoding based on this tutorial: https://www.javainterviewpoint.com/aes-256-encryption-and-decryption/
public @Nullable String decrypt(byte[] base64text, String password) {
public String decrypt(byte[] base64text, String password) throws AirqException {
String content = "";
logger.trace("air-Q - airqHandler - decrypt(): content to decrypt: {}", base64text);
byte[] encodedtextwithIV = Base64.getDecoder().decode(base64text);
byte[] ciphertext = Arrays.copyOfRange(encodedtextwithIV, 16, encodedtextwithIV.length);
byte[] passkey = Arrays.copyOf(password.getBytes(), 32);
@ -344,18 +338,16 @@ public class AirqHandler extends BaseThingHandler {
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
byte[] decryptedText = cipher.doFinal(ciphertext);
content = new String(decryptedText, StandardCharsets.UTF_8);
logger.trace("air-Q - airqHandler - decrypt(): Text decoded as String: {}", content);
} catch (BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException
return content;
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException
| InvalidAlgorithmParameterException | IllegalBlockSizeException exc) {
logger.warn("Error while decrypting. Probably the provided password is wrong.");
return null;
throw new AirqException(exc);
} catch (BadPaddingException e) {
throw new AirqPasswordIncorrectException();
}
return content;
}
public String encrypt(byte[] toencode, String password) {
String content = "";
logger.trace("air-Q - airqHandler - encrypt(): text to encode: {}", new String(toencode));
public String encrypt(byte[] toencode, String password) throws AirqException {
byte[] passkey = Arrays.copyOf(password.getBytes(StandardCharsets.UTF_8), 32);
if (password.length() < 32) {
Arrays.fill(passkey, password.length(), 32, (byte) '0');
@ -374,47 +366,32 @@ public class AirqHandler extends BaseThingHandler {
System.arraycopy(iv, 0, totaltext, 0, 16);
System.arraycopy(encryptedText, 0, totaltext, 16, encryptedText.length);
byte[] encodedcontent = Base64.getEncoder().encode(totaltext);
logger.trace("air-Q - airqHandler - encrypt(): encrypted text: {}", encodedcontent);
content = new String(encodedcontent);
} catch (Exception e) {
logger.warn("air-Q - airqHandler - encrypt(): Error while encrypting: {}", e.toString());
return new String(encodedcontent);
} catch (BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException
| InvalidAlgorithmParameterException | IllegalBlockSizeException exc) {
throw new AirqException("Failed to encrypt data", exc);
}
return content;
}
// gets the data after online/offline management and does the JSON work, or at least the first step.
protected @Nullable String getDecryptedContentString(String url, String requestMethod, @Nullable String body) {
Result res = null;
String jsonAnswer = null;
res = getData(url, "GET", null);
if (res != null) {
String jsontext = res.getBody();
logger.trace("air-Q - airqHandler - getDecryptedContentString(): Result from getData() is {} with body={}",
res, res.getBody());
// Gson code based on https://riptutorial.com/de/gson
JsonElement ans = gson.fromJson(jsontext, JsonElement.class);
if (ans != null) {
JsonObject jsonObj = ans.getAsJsonObject();
jsonAnswer = decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
if (jsonAnswer == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Decryption not possible, probably wrong password");
}
} else {
logger.warn(
"air-Q - airqHandler - getDecryptedContentString(): The air-Q data could not be extracted from this string: {}",
ans);
}
protected String getDecryptedContentString(String url, String requestMethod, @Nullable String body)
throws AirqException, InterruptedException {
Result res = getData(url, "GET", null);
String jsontext = res.getBody();
// Gson code based on https://riptutorial.com/de/gson
JsonElement ans = gson.fromJson(jsontext, JsonElement.class);
if (ans == null) {
throw new AirqEmptyResonseException();
}
return jsonAnswer;
JsonObject jsonObj = ans.getAsJsonObject();
return decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
}
// calls the networking job and in addition does additional tests for online/offline management
protected @Nullable Result getData(String address, String requestMethod, @Nullable String body) {
Result res = null;
protected Result getData(String address, String requestMethod, @Nullable String body)
throws AirqException, InterruptedException {
int timeout = 10;
logger.debug("air-Q - airqHandler - getData(): connecting to {} with method {} and body {}", address,
requestMethod, body);
Request request = httpClient.newRequest(address).timeout(timeout, TimeUnit.SECONDS).method(requestMethod);
if (body != null) {
request = request.content(new StringContentProvider(body)).header(HttpHeader.CONTENT_TYPE,
@ -422,22 +399,12 @@ public class AirqHandler extends BaseThingHandler {
}
try {
ContentResponse response = request.send();
res = new Result(response.getContentAsString(), response.getStatus());
} catch (InterruptedException | ExecutionException | TimeoutException exc) {
logger.warn("air-Q - airqHandler - doNetwork(): Error while accessing air-Q: {}", exc.toString());
return new Result(response.getContentAsString(), response.getStatus());
} catch (ExecutionException e) {
throw new AirqException("Connection failed: " + e.getMessage());
} catch (TimeoutException e) {
throw new AirqException("Timeout while connecting");
}
if (res == null) {
if (getThing().getStatus() != ThingStatus.OFFLINE) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "air-Q device not reachable");
} else {
logger.warn("air-Q - airqHandler - getData(): retried but still cannot reach the air-Q device.");
}
} else {
if (getThing().getStatus() == ThingStatus.OFFLINE) {
updateStatus(ThingStatus.ONLINE);
}
}
return res;
}
public static class Result {
@ -460,151 +427,161 @@ public class AirqHandler extends BaseThingHandler {
@Override
public void dispose() {
if (pollingJob != null) {
pollingJob.cancel(true);
ScheduledFuture<?> localPollingJob = pollingJob;
if (localPollingJob != null) {
localPollingJob.cancel(true);
}
if (getConfigDataJob != null) {
getConfigDataJob.cancel(true);
ScheduledFuture<?> localGetConfigDataJob = getConfigDataJob;
if (localGetConfigDataJob != null) {
localGetConfigDataJob.cancel(true);
}
}
public void pollData() {
logger.trace("air-Q - airqHandler - run(): starting polled data handler");
try {
String url = "http://" + config.ipAddress + "/data";
String jsonAnswer = getDecryptedContentString(url, "GET", null);
if (jsonAnswer != null) {
try {
JsonElement decEl = gson.fromJson(jsonAnswer, JsonElement.class);
if (decEl != null) {
JsonObject decObj = decEl.getAsJsonObject();
logger.debug("air-Q - airqHandler - run(): decObj={}, jsonAnswer={}", decObj, jsonAnswer);
// 'bat' is a field that is already delivered by air-Q but as
// there are no air-Q devices which are powered with batteries
// it is obsolete at this moment. We implemented the code anyway
// to make it easier to add afterwords, but for the moment it is not applicable.
// processType(decObj, "bat", "battery", "pair");
processType(decObj, "cnt0_3", "fineDustCnt00_3", "pair");
processType(decObj, "cnt0_5", "fineDustCnt00_5", "pair");
processType(decObj, "cnt1", "fineDustCnt01", "pair");
processType(decObj, "cnt2_5", "fineDustCnt02_5", "pair");
processType(decObj, "cnt5", "fineDustCnt05", "pair");
processType(decObj, "cnt10", "fineDustCnt10", "pair");
processType(decObj, "co", "co", "pair");
processType(decObj, "co2", "co2", "pairPPM");
processType(decObj, "dewpt", "dewpt", "pair");
processType(decObj, "humidity", "humidityRelative", "pair");
processType(decObj, "humidity_abs", "humidityAbsolute", "pair");
processType(decObj, "no2", "no2", "pair");
processType(decObj, "o3", "o3", "pair");
processType(decObj, "oxygen", "o2", "pair");
processType(decObj, "pm1", "fineDustConc01", "pair");
processType(decObj, "pm2_5", "fineDustConc02_5", "pair");
processType(decObj, "pm10", "fineDustConc10", "pair");
processType(decObj, "pressure", "pressure", "pair");
processType(decObj, "so2", "so2", "pair");
processType(decObj, "sound", "sound", "pairDB");
processType(decObj, "temperature", "temperature", "pair");
// We have two places where the Device ID is delivered: with the measurement data and
// with the configuration.
// We take the info from the configuration and show it as a property, so we don't need
// something like processType(decObj, "DeviceID", "DeviceID", "string") at this moment. We leave
// this as a reminder in case for some reason it will be needed in future, e.g. when an air-Q
// device also sends data from other devices (then with another Device ID)
processType(decObj, "Status", "status", "string");
processType(decObj, "TypPS", "avgFineDustSize", "number");
processType(decObj, "dCO2dt", "dCO2dt", "number");
processType(decObj, "dHdt", "dHdt", "number");
processType(decObj, "door_event", "doorEvent", "number");
processType(decObj, "health", "health", "number");
processType(decObj, "measuretime", "measureTime", "number");
processType(decObj, "performance", "performance", "number");
processType(decObj, "timestamp", "timestamp", "datetime");
processType(decObj, "uptime", "uptime", "numberTimePeriod");
processType(decObj, "tvoc", "tvoc", "pairPPB");
} else {
logger.warn("The air-Q data could not be extracted from this string: {}", decEl);
if (decEl == null) {
throw new AirqEmptyResonseException();
}
JsonObject decObj = decEl.getAsJsonObject();
// 'bat' is a field that is already delivered by air-Q but as
// there are no air-Q devices which are powered with batteries
// it is obsolete at this moment. We implemented the code anyway
// to make it easier to add afterwords, but for the moment it is not applicable.
// processType(decObj, "bat", "battery", "pair");
processType(decObj, "cnt0_3", "fineDustCnt00_3", "pair");
processType(decObj, "cnt0_5", "fineDustCnt00_5", "pair");
processType(decObj, "cnt1", "fineDustCnt01", "pair");
processType(decObj, "cnt2_5", "fineDustCnt02_5", "pair");
processType(decObj, "cnt5", "fineDustCnt05", "pair");
processType(decObj, "cnt10", "fineDustCnt10", "pair");
processType(decObj, "co", "co", "pair");
processType(decObj, "co2", "co2", "pairPPM");
processType(decObj, "dewpt", "dewpt", "pair");
processType(decObj, "h2s", "h2s", "pair");
processType(decObj, "humidity", "humidityRelative", "pair");
processType(decObj, "humidity_abs", "humidityAbsolute", "pair");
processType(decObj, "no2", "no2", "pair");
processType(decObj, "o3", "o3", "pair");
processType(decObj, "oxygen", "o2", "pair");
processType(decObj, "pm1", "fineDustConc01", "pair");
processType(decObj, "pm2_5", "fineDustConc02_5", "pair");
processType(decObj, "pm10", "fineDustConc10", "pair");
processType(decObj, "pressure", "pressure", "pair");
processType(decObj, "so2", "so2", "pair");
processType(decObj, "sound", "sound", "pairDB");
processType(decObj, "temperature", "temperature", "pair");
// We have two places where the Device ID is delivered: with the measurement data and
// with the configuration.
// We take the info from the configuration and show it as a property, so we don't need
// something like processType(decObj, "DeviceID", "DeviceID", "string") at this moment. We leave
// this as a reminder in case for some reason it will be needed in future, e.g. when an air-Q
// device also sends data from other devices (then with another Device ID)
processType(decObj, "Status", "status", "string");
processType(decObj, "TypPS", "avgFineDustSize", "number");
processType(decObj, "dCO2dt", "dCO2dt", "number");
processType(decObj, "dHdt", "dHdt", "number");
processType(decObj, "door_event", "doorEvent", "number");
processType(decObj, "health", "healthIndex", "index");
processType(decObj, "health", "health", "number");
processType(decObj, "measuretime", "measureTime", "number");
processType(decObj, "performance", "performanceIndex", "index");
processType(decObj, "performance", "performance", "number");
processType(decObj, "timestamp", "timestamp", "datetime");
processType(decObj, "uptime", "uptime", "numberTimePeriod");
processType(decObj, "tvoc", "tvoc", "pairPPB");
updateStatus(ThingStatus.ONLINE);
} catch (JsonSyntaxException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Syntax error while parsing response from device: " + e.getMessage());
logger.trace("Parse error in response: {}", jsonAnswer);
}
} catch (Exception e) {
logger.warn("air-Q - airqHandler - polldata.run(): Error while retrieving air-Q data: {}", toString());
} catch (AirqPasswordIncorrectException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Device password incorrect");
} catch (AirqException e) {
String causeMessage = "";
Throwable cause = e.getCause();
if (cause != null) {
causeMessage = cause.getClass().getSimpleName() + ": " + cause.getMessage() + ": ";
}
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, causeMessage + e.getMessage());
} catch (InterruptedException e) {
// nothing
}
}
public void getConfigData() {
Result res = null;
logger.trace("air-Q - airqHandler - getConfigData(): starting processing data");
try {
String url = "http://" + config.ipAddress + "/config";
res = getData(url, "GET", null);
if (res != null) {
String jsontext = res.getBody();
logger.trace("air-Q - airqHandler - getConfigData(): Result from getBody() is {} with body={}", res,
res.getBody());
JsonElement ans = gson.fromJson(jsontext, JsonElement.class);
if (ans != null) {
JsonObject jsonObj = ans.getAsJsonObject();
String jsonAnswer = decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
if (jsonAnswer != null) {
JsonElement decEl = gson.fromJson(jsonAnswer, JsonElement.class);
if (decEl != null) {
JsonObject decObj = decEl.getAsJsonObject();
logger.debug("air-Q - airqHandler - getConfigData(): decObj={}", decObj);
processType(decObj, "Wifi", "wifi", "boolean");
processType(decObj, "WLANssid", "ssid", "arr");
processType(decObj, "pass", "password", "string");
processType(decObj, "WifiInfo", "wifiInfo", "boolean");
processType(decObj, "TimeServer", "timeServer", "string");
processType(decObj, "geopos", "location", "coord");
processType(decObj, "NightMode", "", "nightmode");
processType(decObj, "devicename", "deviceName", "string");
processType(decObj, "RoomType", "roomType", "string");
processType(decObj, "logging", "logLevel", "string");
processType(decObj, "DeleteKey", "deleteKey", "string");
processType(decObj, "FireAlarm", "fireAlarm", "boolean");
processType(decObj, "air-Q-Hardware-Version", "hardwareVersion", "property");
processType(decObj, "WLAN config", "", "wlan");
processType(decObj, "cloudUpload", "cloudUpload", "boolean");
processType(decObj, "SecondsMeasurementDelay", "averagingRhythm", "number");
processType(decObj, "Rejection", "powerFreqSuppression", "string");
processType(decObj, "air-Q-Software-Version", "softwareVersion", "property");
processType(decObj, "sensors", "sensorList", "proparr");
processType(decObj, "AutoDriftCompensation", "autoDriftCompensation", "boolean");
processType(decObj, "AutoUpdate", "autoUpdate", "boolean");
processType(decObj, "AdvancedDataProcessing", "advancedDataProcessing", "boolean");
processType(decObj, "Industry", "Industry", "property");
processType(decObj, "ppm&ppb", "ppm_and_ppb", "boolean");
processType(decObj, "GasAlarm", "gasAlarm", "boolean");
processType(decObj, "id", "id", "property");
processType(decObj, "SoundInfo", "soundPressure", "boolean");
processType(decObj, "AlarmForwarding", "alarmForwarding", "boolean");
processType(decObj, "usercalib", "userCalib", "calib");
processType(decObj, "InitialCalFinished", "initialCalFinished", "boolean");
processType(decObj, "Averaging", "averaging", "boolean");
processType(decObj, "SensorInfo", "sensorInfo", "property");
processType(decObj, "ErrorBars", "errorBars", "boolean");
processType(decObj, "warmup-phase", "warmupPhase", "boolean");
} else {
logger.warn(
"air-Q - airqHandler - getConfigData(): The air-Q data could not be extracted from this string: {}",
decEl);
}
}
} else {
logger.warn(
"air-Q - airqHandler - getConfigData(): The air-Q data could not be extracted from this string: {}",
ans);
}
String jsontext = res.getBody();
JsonElement ans = gson.fromJson(jsontext, JsonElement.class);
if (ans == null) {
throw new AirqEmptyResonseException();
}
} catch (Exception e) {
logger.warn("air-Q - airqHandler - getConfigData(): Error in processConfigData(): {}", e.toString());
JsonObject jsonObj = ans.getAsJsonObject();
String jsonAnswer = decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
JsonElement decEl = gson.fromJson(jsonAnswer, JsonElement.class);
if (decEl == null) {
throw new AirqEmptyResonseException();
}
JsonObject decObj = decEl.getAsJsonObject();
processType(decObj, "Wifi", "wifi", "boolean");
processType(decObj, "WLANssid", "ssid", "arr");
processType(decObj, "pass", "password", "string");
processType(decObj, "WifiInfo", "wifiInfo", "boolean");
processType(decObj, "TimeServer", "timeServer", "string");
processType(decObj, "geopos", "location", "coord");
processType(decObj, "NightMode", "", "nightmode");
processType(decObj, "devicename", "deviceName", "string");
processType(decObj, "RoomType", "roomType", "string");
processType(decObj, "logging", "logLevel", "string");
processType(decObj, "DeleteKey", "deleteKey", "string");
processType(decObj, "FireAlarm", "fireAlarm", "boolean");
processType(decObj, "air-Q-Hardware-Version", "hardwareVersion", "property");
processType(decObj, "WLAN config", "", "wlan");
processType(decObj, "cloudUpload", "cloudUpload", "boolean");
processType(decObj, "SecondsMeasurementDelay", "averagingRhythm", "number");
processType(decObj, "Rejection", "powerFreqSuppression", "string");
processType(decObj, "air-Q-Software-Version", "softwareVersion", "property");
processType(decObj, "sensors", "sensorList", "proparr");
processType(decObj, "AutoDriftCompensation", "autoDriftCompensation", "boolean");
processType(decObj, "AutoUpdate", "autoUpdate", "boolean");
processType(decObj, "AdvancedDataProcessing", "advancedDataProcessing", "boolean");
processType(decObj, "Industry", "Industry", "property");
processType(decObj, "ppm&ppb", "ppm_and_ppb", "boolean");
processType(decObj, "GasAlarm", "gasAlarm", "boolean");
processType(decObj, "id", "id", "property");
processType(decObj, "SoundInfo", "soundPressure", "boolean");
processType(decObj, "AlarmForwarding", "alarmForwarding", "boolean");
processType(decObj, "usercalib", "userCalib", "calib");
processType(decObj, "InitialCalFinished", "initialCalFinished", "boolean");
processType(decObj, "Averaging", "averaging", "boolean");
processType(decObj, "SensorInfo", "sensorInfo", "property");
processType(decObj, "ErrorBars", "errorBars", "boolean");
processType(decObj, "warmup-phase", "warmupPhase", "boolean");
} catch (AirqException | JsonSyntaxException e) {
logger.warn("Failed to retrieve configuration: {}", e.getMessage());
} catch (InterruptedException e) {
// nothing
}
}
private void processType(JsonObject dec, String airqName, String channelName, String type) {
logger.trace("air-Q - airqHandler - processType(): airqName={}, channelName={}, type={}", airqName, channelName,
type);
if (dec.get(airqName) == null) {
logger.trace("air-Q - airqHandler - processType(): get({}) is null", airqName);
// If a device variant does not have a specific sensor type, the value is not present in the JSON data.
// Under rare conditions an existing sensor has a null value in the JSON data on a single event.
if (dec.get(airqName) == null || dec.get(airqName).isJsonNull()) {
updateState(channelName, UnDefType.UNDEF);
if (type.contentEquals("pair")) {
updateState(channelName + "_maxerr", UnDefType.UNDEF);
@ -631,27 +608,29 @@ public class AirqHandler extends BaseThingHandler {
updateState(channelName, new QuantityType<>(dec.get(airqName).getAsBigInteger(), Units.SECOND));
break;
case "pair":
ResultPair pair = new ResultPair(dec.get(airqName).toString());
ResultPair pair = new ResultPair(dec.get(airqName));
updateState(channelName, new DecimalType(pair.getValue()));
updateState(channelName + "_maxerr", new DecimalType(pair.getMaxdev()));
break;
case "pairPPM":
ResultPair pairPPM = new ResultPair(dec.get(airqName).toString());
ResultPair pairPPM = new ResultPair(dec.get(airqName));
updateState(channelName, new QuantityType<>(pairPPM.getValue(), Units.PARTS_PER_MILLION));
updateState(channelName + "_maxerr", new DecimalType(pairPPM.getMaxdev()));
break;
case "pairPPB":
ResultPair pairPPB = new ResultPair(dec.get(airqName).toString());
ResultPair pairPPB = new ResultPair(dec.get(airqName));
updateState(channelName, new QuantityType<>(pairPPB.getValue(), Units.PARTS_PER_BILLION));
updateState(channelName + "_maxerr", new DecimalType(pairPPB.getMaxdev()));
break;
case "pairDB":
ResultPair pairDB = new ResultPair(dec.get(airqName).toString());
logger.trace("air-Q - airqHandler - processType(): db transmitted as {} with unit {}",
pairDB.getValue(), Units.DECIBEL);
ResultPair pairDB = new ResultPair(dec.get(airqName));
updateState(channelName, new QuantityType<>(pairDB.getValue(), Units.DECIBEL));
updateState(channelName + "_maxerr", new DecimalType(pairDB.getMaxdev()));
break;
case "index":
double rawValue = Double.parseDouble(dec.get(airqName).toString());
updateState(channelName, new QuantityType<>(rawValue / 10, Units.PERCENT));
break;
case "datetime":
Long timest = Long.valueOf(dec.get(airqName).toString());
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
@ -761,8 +740,6 @@ public class AirqHandler extends BaseThingHandler {
arrstr = arrstr + el.getAsString() + ", ";
}
if (arrstr.length() >= 2) {
logger.trace("air-Q - airqHandler - processType(): property array {} set to {}",
channelName, arrstr.substring(0, arrstr.length() - 2));
getThing().setProperty(channelName, arrstr.substring(0, arrstr.length() - 2));
} else {
logger.trace("air-Q - airqHandler - processType(): cannot handle this as an array: {}",
@ -782,24 +759,25 @@ public class AirqHandler extends BaseThingHandler {
}
private void changeSettings(JsonObject jsonchange) {
String jsoncmd = jsonchange.toString();
logger.trace("air-Q - airqHandler - changeSettings(): called with jsoncmd={}", jsoncmd);
Result res = null;
String url = "http://" + config.ipAddress + "/config";
String jsonbody = encrypt(jsoncmd.getBytes(StandardCharsets.UTF_8), config.password);
String fullbody = "request=" + jsonbody;
logger.trace("air-Q - airqHandler - changeSettings(): doing call to url={}, method=POST, body={}", url,
fullbody);
res = getData(url, "POST", fullbody);
if (res != null) {
try {
String jsoncmd = jsonchange.toString();
Result res;
String url = "http://" + config.ipAddress + "/config";
String jsonbody = encrypt(jsoncmd.getBytes(StandardCharsets.UTF_8), config.password);
String fullbody = "request=" + jsonbody;
res = getData(url, "POST", fullbody);
JsonElement ans = gson.fromJson(res.getBody(), JsonElement.class);
if (ans != null) {
JsonObject jsonObj = ans.getAsJsonObject();
String jsonAnswer = decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
logger.trace("air-Q - airqHandler - changeSettings(): call returned {}", jsonAnswer);
} else {
logger.warn("The air-Q data could not be extracted from this string: {}", ans);
if (ans == null) {
throw new AirqEmptyResonseException();
}
JsonObject jsonObj = ans.getAsJsonObject();
decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
} catch (AirqException e) {
logger.warn("Failed to change settings", e);
} catch (InterruptedException e) {
// nothing
}
}
}

Some files were not shown because too many files have changed in this diff Show More