]> git.basschouten.com Git - openhab-addons.git/commitdiff
[denonmarantz] Add HTTP protocol support for newer receivers (#16748)
authorJacob Laursen <jacob-github@vindvejr.dk>
Sat, 25 May 2024 11:53:02 +0000 (13:53 +0200)
committerGitHub <noreply@github.com>
Sat, 25 May 2024 11:53:02 +0000 (13:53 +0200)
* Add HTTP protocol support for newer receivers

Resolves #16747

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
bundles/org.openhab.binding.denonmarantz/README.md
bundles/org.openhab.binding.denonmarantz/src/main/java/org/openhab/binding/denonmarantz/internal/connector/http/DenonMarantzHttpConnector.java
bundles/org.openhab.binding.denonmarantz/src/main/java/org/openhab/binding/denonmarantz/internal/exception/HttpCommunicationException.java [new file with mode: 0644]
bundles/org.openhab.binding.denonmarantz/src/main/java/org/openhab/binding/denonmarantz/internal/handler/DenonMarantzHandler.java
bundles/org.openhab.binding.denonmarantz/src/main/java/org/openhab/binding/denonmarantz/internal/xml/dto/commands/CommandRx.java

index 7617ea5a1840433079ab50294b6b2047cba2c765..525f2e95457687e8ba41da42dc013dd765dfc61a 100644 (file)
@@ -7,18 +7,18 @@ This binding integrates Denon & Marantz AV receivers by using either Telnet or a
 This binding supports Denon and Marantz receivers having a Telnet interface or a web based controller at `http://<AVR IP address>/`.
 The thing type for all of them is `avr`.
 
-Tested models: Marantz SR5008, Denon AVR-X2000 / X3000 / X1200W / X2100W / X2200W / X3100W / X3300W / X4400H
-
-Denon models with HEOS support (`AVR-X..00H`) do not support the HTTP API. They do support Telnet.
-During Discovery this is auto-detected and configured.
+Tested models: Marantz SR5008, Denon AVR-3808 / AVR-4520 / AVR-X2000 / X3000 / X1200W / X2100W / X2200W / X3100W / X3300W / X4400H / X4800H
 
 ## Discovery
 
 This binding can discover Denon and Marantz receivers using mDNS.
 The serial number (which is the MAC address of the network interface) is used as unique identifier.
 
+The protocol will be auto-detected.
+The HTTP port as well as slight variations in the API will be auto-detected as well.
+
 It tries to detect the number of zones (when the AVR responds to HTTP).
-It defaults to 2 zones.
+It defaults to two zones.
 
 ## Thing Configuration
 
index 37be5ec3073ce8260347aae1f2cbaf3e58707ea8..73d643654e9384469cf2480400090923c180cc4d 100644 (file)
@@ -14,14 +14,16 @@ package org.openhab.binding.denonmarantz.internal.connector.http;
 
 import java.beans.Introspector;
 import java.io.ByteArrayInputStream;
-import java.io.IOException;
 import java.io.StringWriter;
+import java.math.BigDecimal;
 import java.net.URLEncoder;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 
 import javax.xml.bind.JAXBContext;
 import javax.xml.bind.JAXBException;
@@ -35,11 +37,17 @@ import javax.xml.stream.util.StreamReaderDelegate;
 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.api.Response;
 import org.eclipse.jetty.client.api.Result;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
 import org.openhab.binding.denonmarantz.internal.DenonMarantzState;
 import org.openhab.binding.denonmarantz.internal.config.DenonMarantzConfiguration;
 import org.openhab.binding.denonmarantz.internal.connector.DenonMarantzConnector;
+import org.openhab.binding.denonmarantz.internal.exception.HttpCommunicationException;
 import org.openhab.binding.denonmarantz.internal.xml.dto.Deviceinfo;
 import org.openhab.binding.denonmarantz.internal.xml.dto.Main;
 import org.openhab.binding.denonmarantz.internal.xml.dto.ZoneStatus;
@@ -48,7 +56,7 @@ import org.openhab.binding.denonmarantz.internal.xml.dto.commands.AppCommandRequ
 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.AppCommandResponse;
 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.CommandRx;
 import org.openhab.binding.denonmarantz.internal.xml.dto.commands.CommandTx;
-import org.openhab.core.io.net.http.HttpUtil;
+import org.openhab.binding.denonmarantz.internal.xml.dto.types.StringType;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -92,6 +100,8 @@ public class DenonMarantzHttpConnector extends DenonMarantzConnector {
 
     private @Nullable ScheduledFuture<?> pollingJob;
 
+    private boolean legacyApiSupported = true;
+
     public DenonMarantzHttpConnector(DenonMarantzConfiguration config, DenonMarantzState state,
             ScheduledExecutorService scheduler, HttpClient httpClient) {
         super(config, scheduler, state);
@@ -114,16 +124,19 @@ public class DenonMarantzHttpConnector extends DenonMarantzConnector {
             logger.debug("HTTP polling started.");
             try {
                 setConfigProperties();
-            } catch (IOException e) {
+            } catch (TimeoutException | ExecutionException | HttpCommunicationException e) {
                 logger.debug("IO error while retrieving document:", e);
                 state.connectionError("IO error while connecting to AVR: " + e.getMessage());
                 return;
+            } catch (InterruptedException e) {
+                logger.debug("Interrupted while retrieving document: {}", e.getMessage());
+                Thread.currentThread().interrupt();
             }
 
             pollingJob = scheduler.scheduleWithFixedDelay(() -> {
                 try {
                     refreshHttpProperties();
-                } catch (IOException e) {
+                } catch (TimeoutException | ExecutionException e) {
                     logger.debug("IO error while retrieving document", e);
                     state.connectionError("IO error while connecting to AVR: " + e.getMessage());
                     stopPolling();
@@ -137,6 +150,9 @@ public class DenonMarantzHttpConnector extends DenonMarantzConnector {
                         sb.append(s.toString()).append("\n");
                     }
                     logger.error("Error while polling Http: \"{}\". Stacktrace: \n{}", e.getMessage(), sb.toString());
+                } catch (InterruptedException e) {
+                    logger.debug("Interrupted while polling: {}", e.getMessage());
+                    Thread.currentThread().interrupt();
                 }
             }, 0, config.httpPollingInterval, TimeUnit.SECONDS);
         }
@@ -186,96 +202,163 @@ public class DenonMarantzHttpConnector extends DenonMarantzConnector {
         });
     }
 
-    private void updateMain() throws IOException {
+    private void updateMain() throws TimeoutException, ExecutionException, InterruptedException {
         String url = statusUrl + URL_MAIN;
         logger.trace("Refreshing URL: {}", url);
 
-        Main statusMain = getDocument(url, Main.class);
-        if (statusMain != null) {
-            state.setPower(statusMain.getPower().getValue());
+        try {
+            Main statusMain = getDocument(url, Main.class);
+            if (statusMain != null) {
+                state.setPower(statusMain.getPower().getValue());
+            }
+        } catch (HttpCommunicationException e) {
+            if (e.getHttpStatus() == HttpStatus.FORBIDDEN_403) {
+                legacyApiSupported = false;
+                logger.debug("Legacy API not supported, will attempt app command method");
+            } else {
+                logger.debug("Failed to update main by legacy API: {}", e.getMessage());
+            }
         }
     }
 
-    private void updateMainZone() throws IOException {
+    private void updateMainZone() throws TimeoutException, ExecutionException, InterruptedException {
         String url = statusUrl + URL_ZONE_MAIN;
         logger.trace("Refreshing URL: {}", url);
 
-        ZoneStatus mainZone = getDocument(url, ZoneStatus.class);
-        if (mainZone != null) {
-            state.setInput(mainZone.getInputFuncSelect().getValue());
-            state.setMainVolume(mainZone.getMasterVolume().getValue());
-            state.setMainZonePower(mainZone.getPower().getValue());
-            state.setMute(mainZone.getMute().getValue());
+        try {
+            ZoneStatus mainZone = getDocument(url, ZoneStatus.class);
+            if (mainZone != null) {
+                state.setInput(mainZone.getInputFuncSelect().getValue());
+                state.setMainVolume(mainZone.getMasterVolume().getValue());
+                state.setMainZonePower(mainZone.getPower().getValue());
+                state.setMute(mainZone.getMute().getValue());
+
+                if (config.inputOptions == null) {
+                    config.inputOptions = mainZone.getInputFuncList();
+                }
 
-            if (config.inputOptions == null) {
-                config.inputOptions = mainZone.getInputFuncList();
+                StringType surroundMode = mainZone.getSurrMode();
+                if (surroundMode == null) {
+                    logger.debug("Unable to get the SURROUND_MODE. MainZone update may not be correct.");
+                } else {
+                    state.setSurroundProgram(surroundMode.getValue());
+                }
             }
-
-            if (mainZone.getSurrMode() == null) {
-                logger.debug("Unable to get the SURROUND_MODE. MainZone update may not be correct.");
+        } catch (HttpCommunicationException e) {
+            if (e.getHttpStatus() == HttpStatus.FORBIDDEN_403) {
+                legacyApiSupported = false;
+                logger.debug("Legacy API not supported, will attempt app command method");
             } else {
-                state.setSurroundProgram(mainZone.getSurrMode().getValue());
+                logger.debug("Failed to update main zone by legacy API: {}", e.getMessage());
+            }
+        }
+    }
+
+    private void updateMainZoneByAppCommand() throws TimeoutException, ExecutionException, InterruptedException {
+        String url = statusUrl + URL_APP_COMMAND;
+        logger.trace("Refreshing URL: {}", url);
+
+        AppCommandRequest request = AppCommandRequest.of(CommandTx.CMD_ALL_POWER).add(CommandTx.CMD_VOLUME_LEVEL)
+                .add(CommandTx.CMD_MUTE_STATUS).add(CommandTx.CMD_SOURCE_STATUS).add(CommandTx.CMD_SURROUND_STATUS);
+
+        try {
+            AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
+
+            if (response != null) {
+                for (CommandRx rx : response.getCommands()) {
+                    String inputSource = rx.getSource();
+                    if (inputSource != null) {
+                        state.setInput(inputSource);
+                    }
+                    Boolean power = rx.getZone1();
+                    if (power != null) {
+                        state.setMainZonePower(power.booleanValue());
+                    }
+                    BigDecimal volume = rx.getVolume();
+                    if (volume != null) {
+                        state.setMainVolume(volume);
+                    }
+                    Boolean mute = rx.getMute();
+                    if (mute != null) {
+                        state.setMute(mute.booleanValue());
+                    }
+                    String surroundMode = rx.getSurround();
+                    if (surroundMode != null) {
+                        state.setSurroundProgram(surroundMode);
+                    }
+                }
             }
+        } catch (HttpCommunicationException e) {
+            logger.debug("Failed to update main zone by app command: {}", e.getMessage());
         }
     }
 
-    private void updateSecondaryZones() throws IOException {
+    private void updateSecondaryZones() throws TimeoutException, ExecutionException, InterruptedException {
         for (int i = 2; i <= config.getZoneCount(); i++) {
             String url = String.format("%s" + URL_ZONE_SECONDARY_LITE, statusUrl, i, i);
             logger.trace("Refreshing URL: {}", url);
-            ZoneStatusLite zoneSecondary = getDocument(url, ZoneStatusLite.class);
-            if (zoneSecondary != null) {
-                switch (i) {
-                    // maximum 2 secondary zones are supported
-                    case 2:
-                        state.setZone2Power(zoneSecondary.getPower().getValue());
-                        state.setZone2Volume(zoneSecondary.getMasterVolume().getValue());
-                        state.setZone2Mute(zoneSecondary.getMute().getValue());
-                        state.setZone2Input(zoneSecondary.getInputFuncSelect().getValue());
-                        break;
-                    case 3:
-                        state.setZone3Power(zoneSecondary.getPower().getValue());
-                        state.setZone3Volume(zoneSecondary.getMasterVolume().getValue());
-                        state.setZone3Mute(zoneSecondary.getMute().getValue());
-                        state.setZone3Input(zoneSecondary.getInputFuncSelect().getValue());
-                        break;
-                    case 4:
-                        state.setZone4Power(zoneSecondary.getPower().getValue());
-                        state.setZone4Volume(zoneSecondary.getMasterVolume().getValue());
-                        state.setZone4Mute(zoneSecondary.getMute().getValue());
-                        state.setZone4Input(zoneSecondary.getInputFuncSelect().getValue());
-                        break;
+            try {
+                ZoneStatusLite zoneSecondary = getDocument(url, ZoneStatusLite.class);
+                if (zoneSecondary != null) {
+                    switch (i) {
+                        // maximum 2 secondary zones are supported
+                        case 2:
+                            state.setZone2Power(zoneSecondary.getPower().getValue());
+                            state.setZone2Volume(zoneSecondary.getMasterVolume().getValue());
+                            state.setZone2Mute(zoneSecondary.getMute().getValue());
+                            state.setZone2Input(zoneSecondary.getInputFuncSelect().getValue());
+                            break;
+                        case 3:
+                            state.setZone3Power(zoneSecondary.getPower().getValue());
+                            state.setZone3Volume(zoneSecondary.getMasterVolume().getValue());
+                            state.setZone3Mute(zoneSecondary.getMute().getValue());
+                            state.setZone3Input(zoneSecondary.getInputFuncSelect().getValue());
+                            break;
+                        case 4:
+                            state.setZone4Power(zoneSecondary.getPower().getValue());
+                            state.setZone4Volume(zoneSecondary.getMasterVolume().getValue());
+                            state.setZone4Mute(zoneSecondary.getMute().getValue());
+                            state.setZone4Input(zoneSecondary.getInputFuncSelect().getValue());
+                            break;
+                    }
                 }
+            } catch (HttpCommunicationException e) {
+                logger.debug("Failed to update zone {}: {}", i, e.getMessage());
             }
         }
     }
 
-    private void updateDisplayInfo() throws IOException {
+    private void updateDisplayInfo() throws TimeoutException, ExecutionException, InterruptedException {
         String url = statusUrl + URL_APP_COMMAND;
         logger.trace("Refreshing URL: {}", url);
 
         AppCommandRequest request = AppCommandRequest.of(CommandTx.CMD_NET_STATUS);
-        AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
+        try {
+            AppCommandResponse response = postDocument(url, AppCommandResponse.class, request);
 
-        if (response == null) {
-            return;
-        }
-        CommandRx titleInfo = response.getCommands().get(0);
-        String artist = titleInfo.getText("artist");
-        if (artist != null) {
-            state.setNowPlayingArtist(artist);
-        }
-        String album = titleInfo.getText("album");
-        if (album != null) {
-            state.setNowPlayingAlbum(album);
-        }
-        String track = titleInfo.getText("track");
-        if (track != null) {
-            state.setNowPlayingTrack(track);
+            if (response == null) {
+                return;
+            }
+            CommandRx titleInfo = response.getCommands().get(0);
+            String artist = titleInfo.getText("artist");
+            if (artist != null) {
+                state.setNowPlayingArtist(artist);
+            }
+            String album = titleInfo.getText("album");
+            if (album != null) {
+                state.setNowPlayingAlbum(album);
+            }
+            String track = titleInfo.getText("track");
+            if (track != null) {
+                state.setNowPlayingTrack(track);
+            }
+        } catch (HttpCommunicationException e) {
+            logger.debug("Failed to update display info: {}", e.getMessage());
         }
     }
 
-    private boolean setConfigProperties() throws IOException {
+    private boolean setConfigProperties()
+            throws TimeoutException, ExecutionException, InterruptedException, HttpCommunicationException {
         String url = statusUrl + URL_DEVICE_INFO;
         logger.debug("Refreshing URL: {}", url);
 
@@ -295,20 +378,39 @@ public class DenonMarantzHttpConnector extends DenonMarantzConnector {
         return (deviceinfo != null);
     }
 
-    private void refreshHttpProperties() throws IOException {
+    private void refreshHttpProperties() throws TimeoutException, ExecutionException, InterruptedException {
         logger.trace("Refreshing Denon status");
 
-        updateMain();
-        updateMainZone();
+        if (legacyApiSupported) {
+            updateMain();
+            updateMainZone();
+        }
+
+        if (!legacyApiSupported) {
+            updateMainZoneByAppCommand();
+        }
+
         updateSecondaryZones();
         updateDisplayInfo();
     }
 
     @Nullable
-    private <T> T getDocument(String uri, Class<T> response) throws IOException {
+    private <T> T getDocument(String uri, Class<T> response)
+            throws TimeoutException, ExecutionException, InterruptedException, HttpCommunicationException {
         try {
-            String result = HttpUtil.executeUrl("GET", uri, REQUEST_TIMEOUT_MS);
-            logger.trace("result of getDocument for uri '{}':\r\n{}", uri, result);
+            Request request = httpClient.newRequest(uri).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
+                    .method(HttpMethod.GET);
+
+            ContentResponse contentResponse = request.send();
+
+            String result = contentResponse.getContentAsString();
+            int status = contentResponse.getStatus();
+
+            logger.trace("result of getDocument for uri '{}' (status code {}):\r\n{}", uri, status, result);
+
+            if (!HttpStatus.isSuccess(status)) {
+                throw new HttpCommunicationException("Error retrieving document for uri '" + uri + "'", status);
+            }
 
             if (result != null && !result.isBlank()) {
                 JAXBContext jc = JAXBContext.newInstance(response);
@@ -336,15 +438,28 @@ public class DenonMarantzHttpConnector extends DenonMarantzConnector {
     }
 
     @Nullable
-    private <T, S> T postDocument(String uri, Class<T> response, S request) throws IOException {
+    private <T, S> T postDocument(String uri, Class<T> response, S request)
+            throws TimeoutException, ExecutionException, InterruptedException, HttpCommunicationException {
         try {
             JAXBContext jaxbContext = JAXBContext.newInstance(request.getClass());
             Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
             StringWriter sw = new StringWriter();
             jaxbMarshaller.marshal(request, sw);
 
-            ByteArrayInputStream inputStream = new ByteArrayInputStream(sw.toString().getBytes(StandardCharsets.UTF_8));
-            String result = HttpUtil.executeUrl("POST", uri, inputStream, CONTENT_TYPE_XML, REQUEST_TIMEOUT_MS);
+            Request httpRequest = httpClient.newRequest(uri).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
+                    .content(new StringContentProvider(sw.toString(), StandardCharsets.UTF_8), CONTENT_TYPE_XML)
+                    .method(HttpMethod.POST);
+
+            ContentResponse contentResponse = httpRequest.send();
+
+            String result = contentResponse.getContentAsString();
+            int status = contentResponse.getStatus();
+
+            logger.trace("result of postDocument for uri '{}' (status code {}):\r\n{}", uri, status, result);
+
+            if (!HttpStatus.isSuccess(status)) {
+                throw new HttpCommunicationException("Error retrieving document for uri '" + uri + "'", status);
+            }
 
             if (result != null && !result.isBlank()) {
                 JAXBContext jcResponse = JAXBContext.newInstance(response);
diff --git a/bundles/org.openhab.binding.denonmarantz/src/main/java/org/openhab/binding/denonmarantz/internal/exception/HttpCommunicationException.java b/bundles/org.openhab.binding.denonmarantz/src/main/java/org/openhab/binding/denonmarantz/internal/exception/HttpCommunicationException.java
new file mode 100644 (file)
index 0000000..0e1e354
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * 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.denonmarantz.internal.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * {@link HttpCommunicationException} is a generic exception thrown in case
+ * of communication failure or unexpected response.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class HttpCommunicationException extends Exception {
+
+    private static final long serialVersionUID = 1L;
+    private int httpStatus = 0;
+
+    public HttpCommunicationException(String message, int httpStatus) {
+        super(message);
+        this.httpStatus = httpStatus;
+    }
+
+    public int getHttpStatus() {
+        return httpStatus;
+    }
+}
index e37d55e2893fa8f8bb0808610134e64faaf2a271..b821905e869bdf48d5a83a4909690047c581a0bd 100644 (file)
@@ -229,7 +229,7 @@ public class DenonMarantzHandler extends BaseThingHandler implements DenonMarant
                     httpApiUsable = true;
                 }
             } catch (TimeoutException | ExecutionException e) {
-                logger.debug("Error when trying to access AVR using HTTP on port 80, reverting to Telnet mode.", e);
+                logger.debug("Error when trying to access AVR using HTTP on port 80.", e);
             }
 
             if (telnetEnable) {
@@ -239,13 +239,15 @@ public class DenonMarantzHandler extends BaseThingHandler implements DenonMarant
                     response = httpClient.newRequest("http://" + host + ":8080/goform/Deviceinfo.xml")
                             .timeout(3, TimeUnit.SECONDS).send();
                     if (response.getStatus() == HttpURLConnection.HTTP_OK) {
-                        logger.debug(
-                                "This model responds to HTTP port 8080, we use this port to retrieve the number of zones.");
+                        logger.debug("This model responds to HTTP port 8080, disabling the Telnet mode by default.");
+                        telnetEnable = false;
                         httpPort = 8080;
                         httpApiUsable = true;
                     }
                 } catch (TimeoutException | ExecutionException e) {
-                    logger.debug("Additionally tried to connect to port 8080, this also failed", e);
+                    logger.debug(
+                            "Additionally tried to connect to port 8080, this also failed. Reverting to Telnet mode.",
+                            e);
                 }
             }
 
index 55531ce69d350b901a51c5273123b255664ce7f8..07c6f038da08b24a191ff064983ab054a551f370 100644 (file)
@@ -12,6 +12,7 @@
  */
 package org.openhab.binding.denonmarantz.internal.xml.dto.commands;
 
+import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -20,9 +21,12 @@ import javax.xml.bind.annotation.XmlAccessorType;
 import javax.xml.bind.annotation.XmlElement;
 import javax.xml.bind.annotation.XmlElementWrapper;
 import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
 
 import org.eclipse.jdt.annotation.NonNull;
 import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.denonmarantz.internal.xml.adapters.OnOffAdapter;
+import org.openhab.binding.denonmarantz.internal.xml.adapters.VolumeAdapter;
 
 /**
  * Response to a {@link CommandTx}
@@ -33,21 +37,27 @@ import org.eclipse.jdt.annotation.Nullable;
 @XmlAccessorType(XmlAccessType.FIELD)
 public class CommandRx {
 
-    private String zone1;
+    @XmlJavaTypeAdapter(OnOffAdapter.class)
+    private Boolean zone1;
 
-    private String zone2;
+    @XmlJavaTypeAdapter(OnOffAdapter.class)
+    private Boolean zone2;
 
-    private String zone3;
+    @XmlJavaTypeAdapter(OnOffAdapter.class)
+    private Boolean zone3;
 
-    private String zone4;
+    @XmlJavaTypeAdapter(OnOffAdapter.class)
+    private Boolean zone4;
 
-    private String volume;
+    @XmlJavaTypeAdapter(value = VolumeAdapter.class)
+    private BigDecimal volume;
 
     private String disptype;
 
     private String dispvalue;
 
-    private String mute;
+    @XmlJavaTypeAdapter(OnOffAdapter.class)
+    private Boolean mute;
 
     private String type;
 
@@ -72,46 +82,48 @@ public class CommandRx {
 
     private String source;
 
+    private String surround;
+
     public CommandRx() {
     }
 
-    public String getZone1() {
+    public Boolean getZone1() {
         return zone1;
     }
 
-    public void setZone1(String zone1) {
+    public void setZone1(Boolean zone1) {
         this.zone1 = zone1;
     }
 
-    public String getZone2() {
+    public Boolean getZone2() {
         return zone2;
     }
 
-    public void setZone2(String zone2) {
+    public void setZone2(Boolean zone2) {
         this.zone2 = zone2;
     }
 
-    public String getZone3() {
+    public Boolean getZone3() {
         return zone3;
     }
 
-    public void setZone3(String zone3) {
+    public void setZone3(Boolean zone3) {
         this.zone3 = zone3;
     }
 
-    public String getZone4() {
+    public Boolean getZone4() {
         return zone4;
     }
 
-    public void setZone4(String zone4) {
+    public void setZone4(Boolean zone4) {
         this.zone4 = zone4;
     }
 
-    public String getVolume() {
+    public BigDecimal getVolume() {
         return volume;
     }
 
-    public void setVolume(String volume) {
+    public void setVolume(BigDecimal volume) {
         this.volume = volume;
     }
 
@@ -131,11 +143,11 @@ public class CommandRx {
         this.dispvalue = dispvalue;
     }
 
-    public String getMute() {
+    public Boolean getMute() {
         return mute;
     }
 
-    public void setMute(String mute) {
+    public void setMute(Boolean mute) {
         this.mute = mute;
     }
 
@@ -187,6 +199,14 @@ public class CommandRx {
         this.source = source;
     }
 
+    public String getSurround() {
+        return surround;
+    }
+
+    public void setSurround(String surround) {
+        this.surround = surround;
+    }
+
     public @Nullable String getText(@NonNull String key) {
         for (Text text : texts) {
             if (key.equals(text.getId())) {