[airq] Fix occasional stalling of sensor value updates and refactoring (#17202)

* [airq] Fix occasional stalling of sensor value updates and refactoring

- Remove trace debugging statements
- Handle InterruptedException correctly
- Replace substring parsing by GSON functionality

Signed-off-by: Fabian Wolter <github@fabian-wolter.de>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Fabian Wolter 2024-08-20 23:35:48 +02:00 committed by Ciprian Pascu
parent d26ca6debe
commit 875b2791bf

View File

@ -101,12 +101,17 @@ public class AirqHandler extends BaseThingHandler {
* Expects a string consisting of two values as sent by the air-Q device * Expects a string consisting of two values as sent by the air-Q device
* and returns a corresponding object * 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 * @return ResultPair object with the two values
* @throws AirqException when parsing fails
*/ */
public ResultPair(String input) { public ResultPair(JsonElement input) throws JsonSyntaxException {
value = Float.parseFloat(input.substring(1, input.indexOf(','))); if (input instanceof JsonArray pair && pair.size() == 2) {
maxdev = Float.parseFloat(input.substring(input.indexOf(',') + 1, input.length() - 1)); value = pair.get(0).getAsFloat();
maxdev = pair.get(1).getAsFloat();
} else {
throw new JsonSyntaxException("Failed to parse pair: " + input);
}
} }
} }
@ -319,7 +324,6 @@ public class AirqHandler extends BaseThingHandler {
// AES decoding based on this tutorial: https://www.javainterviewpoint.com/aes-256-encryption-and-decryption/ // AES decoding based on this tutorial: https://www.javainterviewpoint.com/aes-256-encryption-and-decryption/
public String decrypt(byte[] base64text, String password) throws AirqException { public String decrypt(byte[] base64text, String password) throws AirqException {
String content = ""; String content = "";
logger.trace("air-Q - airqHandler - decrypt(): content to decrypt: {}", base64text);
byte[] encodedtextwithIV = Base64.getDecoder().decode(base64text); byte[] encodedtextwithIV = Base64.getDecoder().decode(base64text);
byte[] ciphertext = Arrays.copyOfRange(encodedtextwithIV, 16, encodedtextwithIV.length); byte[] ciphertext = Arrays.copyOfRange(encodedtextwithIV, 16, encodedtextwithIV.length);
byte[] passkey = Arrays.copyOf(password.getBytes(), 32); byte[] passkey = Arrays.copyOf(password.getBytes(), 32);
@ -334,7 +338,6 @@ public class AirqHandler extends BaseThingHandler {
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
byte[] decryptedText = cipher.doFinal(ciphertext); byte[] decryptedText = cipher.doFinal(ciphertext);
content = new String(decryptedText, StandardCharsets.UTF_8); content = new String(decryptedText, StandardCharsets.UTF_8);
logger.trace("air-Q - airqHandler - decrypt(): Text decoded as String: {}", content);
return content; return content;
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException
| InvalidAlgorithmParameterException | IllegalBlockSizeException exc) { | InvalidAlgorithmParameterException | IllegalBlockSizeException exc) {
@ -345,7 +348,6 @@ public class AirqHandler extends BaseThingHandler {
} }
public String encrypt(byte[] toencode, String password) throws AirqException { public String encrypt(byte[] toencode, String password) throws AirqException {
logger.trace("air-Q - airqHandler - encrypt(): text to encode: {}", new String(toencode));
byte[] passkey = Arrays.copyOf(password.getBytes(StandardCharsets.UTF_8), 32); byte[] passkey = Arrays.copyOf(password.getBytes(StandardCharsets.UTF_8), 32);
if (password.length() < 32) { if (password.length() < 32) {
Arrays.fill(passkey, password.length(), 32, (byte) '0'); Arrays.fill(passkey, password.length(), 32, (byte) '0');
@ -364,7 +366,6 @@ public class AirqHandler extends BaseThingHandler {
System.arraycopy(iv, 0, totaltext, 0, 16); System.arraycopy(iv, 0, totaltext, 0, 16);
System.arraycopy(encryptedText, 0, totaltext, 16, encryptedText.length); System.arraycopy(encryptedText, 0, totaltext, 16, encryptedText.length);
byte[] encodedcontent = Base64.getEncoder().encode(totaltext); byte[] encodedcontent = Base64.getEncoder().encode(totaltext);
logger.trace("air-Q - airqHandler - encrypt(): encrypted text: {}", encodedcontent);
return new String(encodedcontent); return new String(encodedcontent);
} catch (BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException } catch (BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException
| InvalidAlgorithmParameterException | IllegalBlockSizeException exc) { | InvalidAlgorithmParameterException | IllegalBlockSizeException exc) {
@ -374,11 +375,9 @@ public class AirqHandler extends BaseThingHandler {
// gets the data after online/offline management and does the JSON work, or at least the first step. // gets the data after online/offline management and does the JSON work, or at least the first step.
protected String getDecryptedContentString(String url, String requestMethod, @Nullable String body) protected String getDecryptedContentString(String url, String requestMethod, @Nullable String body)
throws AirqException { throws AirqException, InterruptedException {
Result res = getData(url, "GET", null); Result res = getData(url, "GET", null);
String jsontext = res.getBody(); 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 // Gson code based on https://riptutorial.com/de/gson
JsonElement ans = gson.fromJson(jsontext, JsonElement.class); JsonElement ans = gson.fromJson(jsontext, JsonElement.class);
if (ans == null) { if (ans == null) {
@ -390,10 +389,9 @@ public class AirqHandler extends BaseThingHandler {
} }
// calls the networking job and in addition does additional tests for online/offline management // calls the networking job and in addition does additional tests for online/offline management
protected Result getData(String address, String requestMethod, @Nullable String body) throws AirqException { protected Result getData(String address, String requestMethod, @Nullable String body)
throws AirqException, InterruptedException {
int timeout = 10; int timeout = 10;
logger.trace("air-Q - airqHandler - getData(): connecting to {} with method {} and body {}", address,
requestMethod, body);
Request request = httpClient.newRequest(address).timeout(timeout, TimeUnit.SECONDS).method(requestMethod); Request request = httpClient.newRequest(address).timeout(timeout, TimeUnit.SECONDS).method(requestMethod);
if (body != null) { if (body != null) {
request = request.content(new StringContentProvider(body)).header(HttpHeader.CONTENT_TYPE, request = request.content(new StringContentProvider(body)).header(HttpHeader.CONTENT_TYPE,
@ -402,8 +400,10 @@ public class AirqHandler extends BaseThingHandler {
try { try {
ContentResponse response = request.send(); ContentResponse response = request.send();
return new Result(response.getContentAsString(), response.getStatus()); return new Result(response.getContentAsString(), response.getStatus());
} catch (InterruptedException | ExecutionException | TimeoutException exc) { } catch (ExecutionException e) {
throw new AirqException("Error while accessing air-Q", exc); throw new AirqException("Connection failed: " + e.getMessage());
} catch (TimeoutException e) {
throw new AirqException("Timeout while connecting");
} }
} }
@ -439,65 +439,70 @@ public class AirqHandler extends BaseThingHandler {
} }
public void pollData() { public void pollData() {
logger.trace("air-Q - airqHandler - run(): starting polled data handler");
try { try {
String url = "http://" + config.ipAddress + "/data"; String url = "http://" + config.ipAddress + "/data";
String jsonAnswer = getDecryptedContentString(url, "GET", null); String jsonAnswer = getDecryptedContentString(url, "GET", null);
JsonElement decEl = gson.fromJson(jsonAnswer, JsonElement.class);
if (decEl == null) { try {
throw new AirqEmptyResonseException(); JsonElement decEl = gson.fromJson(jsonAnswer, JsonElement.class);
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);
} }
JsonObject decObj = decEl.getAsJsonObject();
logger.trace("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, "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 (AirqPasswordIncorrectException e) { } catch (AirqPasswordIncorrectException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Device password incorrect"); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Device password incorrect");
} catch (AirqException e) { } catch (AirqException e) {
@ -508,21 +513,17 @@ public class AirqHandler extends BaseThingHandler {
} }
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, causeMessage + e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, causeMessage + e.getMessage());
} catch (JsonSyntaxException e) { } catch (InterruptedException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, // nothing
"Syntax error while parsing response from device");
} }
} }
public void getConfigData() { public void getConfigData() {
Result res = null; Result res = null;
logger.trace("air-Q - airqHandler - getConfigData(): starting processing data");
try { try {
String url = "http://" + config.ipAddress + "/config"; String url = "http://" + config.ipAddress + "/config";
res = getData(url, "GET", null); res = getData(url, "GET", null);
String jsontext = res.getBody(); 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); JsonElement ans = gson.fromJson(jsontext, JsonElement.class);
if (ans == null) { if (ans == null) {
throw new AirqEmptyResonseException(); throw new AirqEmptyResonseException();
@ -536,7 +537,6 @@ public class AirqHandler extends BaseThingHandler {
} }
JsonObject decObj = decEl.getAsJsonObject(); JsonObject decObj = decEl.getAsJsonObject();
logger.trace("air-Q - airqHandler - getConfigData(): decObj={}", decObj);
processType(decObj, "Wifi", "wifi", "boolean"); processType(decObj, "Wifi", "wifi", "boolean");
processType(decObj, "WLANssid", "ssid", "arr"); processType(decObj, "WLANssid", "ssid", "arr");
processType(decObj, "pass", "password", "string"); processType(decObj, "pass", "password", "string");
@ -573,14 +573,15 @@ public class AirqHandler extends BaseThingHandler {
processType(decObj, "warmup-phase", "warmupPhase", "boolean"); processType(decObj, "warmup-phase", "warmupPhase", "boolean");
} catch (AirqException | JsonSyntaxException e) { } catch (AirqException | JsonSyntaxException e) {
logger.warn("Failed to retrieve configuration: {}", e.getMessage()); logger.warn("Failed to retrieve configuration: {}", e.getMessage());
} catch (InterruptedException e) {
// nothing
} }
} }
private void processType(JsonObject dec, String airqName, String channelName, String type) { private void processType(JsonObject dec, String airqName, String channelName, String type) {
logger.trace("air-Q - airqHandler - processType(): airqName={}, channelName={}, type={}", airqName, channelName, // If a device variant does not have a specific sensor type, the value is not present in the JSON data.
type); // Under rare conditions an existing sensor has a null value in the JSON data on a single event.
if (dec.get(airqName) == null) { if (dec.get(airqName) == null || dec.get(airqName).isJsonNull()) {
logger.trace("air-Q - airqHandler - processType(): get({}) is null", airqName);
updateState(channelName, UnDefType.UNDEF); updateState(channelName, UnDefType.UNDEF);
if (type.contentEquals("pair")) { if (type.contentEquals("pair")) {
updateState(channelName + "_maxerr", UnDefType.UNDEF); updateState(channelName + "_maxerr", UnDefType.UNDEF);
@ -607,24 +608,22 @@ public class AirqHandler extends BaseThingHandler {
updateState(channelName, new QuantityType<>(dec.get(airqName).getAsBigInteger(), Units.SECOND)); updateState(channelName, new QuantityType<>(dec.get(airqName).getAsBigInteger(), Units.SECOND));
break; break;
case "pair": 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, new DecimalType(pair.getValue()));
updateState(channelName + "_maxerr", new DecimalType(pair.getMaxdev())); updateState(channelName + "_maxerr", new DecimalType(pair.getMaxdev()));
break; break;
case "pairPPM": 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, new QuantityType<>(pairPPM.getValue(), Units.PARTS_PER_MILLION));
updateState(channelName + "_maxerr", new DecimalType(pairPPM.getMaxdev())); updateState(channelName + "_maxerr", new DecimalType(pairPPM.getMaxdev()));
break; break;
case "pairPPB": 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, new QuantityType<>(pairPPB.getValue(), Units.PARTS_PER_BILLION));
updateState(channelName + "_maxerr", new DecimalType(pairPPB.getMaxdev())); updateState(channelName + "_maxerr", new DecimalType(pairPPB.getMaxdev()));
break; break;
case "pairDB": case "pairDB":
ResultPair pairDB = new ResultPair(dec.get(airqName).toString()); ResultPair pairDB = new ResultPair(dec.get(airqName));
logger.trace("air-Q - airqHandler - processType(): db transmitted as {} with unit {}",
pairDB.getValue(), Units.DECIBEL);
updateState(channelName, new QuantityType<>(pairDB.getValue(), Units.DECIBEL)); updateState(channelName, new QuantityType<>(pairDB.getValue(), Units.DECIBEL));
updateState(channelName + "_maxerr", new DecimalType(pairDB.getMaxdev())); updateState(channelName + "_maxerr", new DecimalType(pairDB.getMaxdev()));
break; break;
@ -741,8 +740,6 @@ public class AirqHandler extends BaseThingHandler {
arrstr = arrstr + el.getAsString() + ", "; arrstr = arrstr + el.getAsString() + ", ";
} }
if (arrstr.length() >= 2) { 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)); getThing().setProperty(channelName, arrstr.substring(0, arrstr.length() - 2));
} else { } else {
logger.trace("air-Q - airqHandler - processType(): cannot handle this as an array: {}", logger.trace("air-Q - airqHandler - processType(): cannot handle this as an array: {}",
@ -764,13 +761,10 @@ public class AirqHandler extends BaseThingHandler {
private void changeSettings(JsonObject jsonchange) { private void changeSettings(JsonObject jsonchange) {
try { try {
String jsoncmd = jsonchange.toString(); String jsoncmd = jsonchange.toString();
logger.trace("air-Q - airqHandler - changeSettings(): called with jsoncmd={}", jsoncmd);
Result res; Result res;
String url = "http://" + config.ipAddress + "/config"; String url = "http://" + config.ipAddress + "/config";
String jsonbody = encrypt(jsoncmd.getBytes(StandardCharsets.UTF_8), config.password); String jsonbody = encrypt(jsoncmd.getBytes(StandardCharsets.UTF_8), config.password);
String fullbody = "request=" + jsonbody; String fullbody = "request=" + jsonbody;
logger.trace("air-Q - airqHandler - changeSettings(): doing call to url={}, method=POST, body={}", url,
fullbody);
res = getData(url, "POST", fullbody); res = getData(url, "POST", fullbody);
JsonElement ans = gson.fromJson(res.getBody(), JsonElement.class); JsonElement ans = gson.fromJson(res.getBody(), JsonElement.class);
@ -779,11 +773,11 @@ public class AirqHandler extends BaseThingHandler {
} }
JsonObject jsonObj = ans.getAsJsonObject(); JsonObject jsonObj = ans.getAsJsonObject();
String jsonAnswer; decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
jsonAnswer = decrypt(jsonObj.get("content").getAsString().getBytes(), config.password);
logger.trace("air-Q - airqHandler - changeSettings(): call returned {}", jsonAnswer);
} catch (AirqException e) { } catch (AirqException e) {
logger.warn("Failed to change settings", e); logger.warn("Failed to change settings", e);
} catch (InterruptedException e) {
// nothing
} }
} }
} }