]> git.basschouten.com Git - openhab-addons.git/blob
a379889f1379dac1bcd40a6a2047dcb93e6913cf
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.russound.internal.rio.zone;
14
15 import java.util.ArrayList;
16 import java.util.List;
17 import java.util.Objects;
18 import java.util.concurrent.atomic.AtomicInteger;
19 import java.util.regex.Matcher;
20 import java.util.regex.Pattern;
21
22 import org.openhab.binding.russound.internal.net.SocketSession;
23 import org.openhab.binding.russound.internal.net.SocketSessionListener;
24 import org.openhab.binding.russound.internal.rio.AbstractRioProtocol;
25 import org.openhab.binding.russound.internal.rio.RioConstants;
26 import org.openhab.binding.russound.internal.rio.RioHandlerCallback;
27 import org.openhab.binding.russound.internal.rio.RioPresetsProtocol;
28 import org.openhab.binding.russound.internal.rio.RioSystemFavoritesProtocol;
29 import org.openhab.binding.russound.internal.rio.models.GsonUtilities;
30 import org.openhab.binding.russound.internal.rio.models.RioFavorite;
31 import org.openhab.core.library.types.DecimalType;
32 import org.openhab.core.library.types.OnOffType;
33 import org.openhab.core.library.types.PercentType;
34 import org.openhab.core.library.types.StringType;
35 import org.slf4j.Logger;
36 import org.slf4j.LoggerFactory;
37
38 import com.google.gson.Gson;
39 import com.google.gson.JsonSyntaxException;
40
41 /**
42  * This is the protocol handler for the Russound Zone. This handler will issue the protocol commands and will
43  * process the responses from the Russound system.
44  *
45  * @author Tim Roberts - Initial contribution
46  */
47 class RioZoneProtocol extends AbstractRioProtocol
48         implements RioSystemFavoritesProtocol.Listener, RioPresetsProtocol.Listener {
49     // logger
50     private final Logger logger = LoggerFactory.getLogger(RioZoneProtocol.class);
51
52     /**
53      * The controller identifier
54      */
55     private final int controller;
56
57     /**
58      * The zone identifier
59      */
60     private final int zone;
61
62     // Zone constants
63     private static final String ZONE_NAME = "name"; // 12 max
64     private static final String ZONE_SOURCE = "currentsource"; // 1-8 or 1-12
65     private static final String ZONE_BASS = "bass"; // -10 to 10
66     private static final String ZONE_TREBLE = "treble"; // -10 to 10
67     private static final String ZONE_BALANCE = "balance"; // -10 to 10
68     private static final String ZONE_LOUDNESS = "loudness"; // OFF/ON
69     private static final String ZONE_TURNONVOLUME = "turnonvolume"; // 0 to 50
70     private static final String ZONE_DONOTDISTURB = "donotdisturb"; // OFF/ON/SLAVE
71     private static final String ZONE_PARTYMODE = "partymode"; // OFF/ON/MASTER
72     private static final String ZONE_STATUS = "status"; // OFF/ON/MASTER
73     private static final String ZONE_VOLUME = "volume"; // 0 to 50
74     private static final String ZONE_MUTE = "mute"; // OFF/ON/MASTER
75     private static final String ZONE_PAGE = "page"; // OFF/ON/MASTER
76     private static final String ZONE_SHAREDSOURCE = "sharedsource"; // OFF/ON/MASTER
77     private static final String ZONE_SLEEPTIMEREMAINING = "sleeptimeremaining"; // OFF/ON/MASTER
78     private static final String ZONE_LASTERROR = "lasterror"; // OFF/ON/MASTER
79     private static final String ZONE_ENABLED = "enabled"; // OFF/ON
80
81     // Multimedia functions
82     private static final String ZONE_MMINIT = "MMInit"; // button
83     private static final String ZONE_MMCONTEXTMENU = "MMContextMenu"; // button
84
85     // Favorites
86     private static final String FAV_NAME = "name";
87     private static final String FAV_VALID = "valid";
88
89     // Respone patterns
90     private static final Pattern RSP_ZONENOTIFICATION = Pattern
91             .compile("(?i)^[SN] C\\[(\\d+)\\]\\.Z\\[(\\d+)\\]\\.(\\w+)=\"(.*)\"$");
92
93     private static final Pattern RSP_ZONEFAVORITENOTIFICATION = Pattern
94             .compile("(?i)^[SN] C\\[(\\d+)\\].Z\\[(\\d+)\\].favorite\\[(\\d+)\\].(\\w+)=\"(.*)\"$");
95
96     // The zone favorites
97     private final RioFavorite[] zoneFavorites = new RioFavorite[2];
98
99     // The current source identifier (or -1 if none)
100     private final AtomicInteger sourceId = new AtomicInteger(-1);
101
102     // GSON object used for json
103     private final Gson gson;
104
105     // The favorites protocol
106     private final RioSystemFavoritesProtocol favoritesProtocol;
107
108     // The presets protocol
109     private final RioPresetsProtocol presetsProtocol;
110
111     /**
112      * Constructs the protocol handler from given parameters
113      *
114      * @param zone the zone identifier
115      * @param controller the controller identifier
116      * @param favoritesProtocol a non-null {@link RioSystemFavoritesProtocol}
117      * @param presetsProtocol a non-null {@link RioPresetsProtocol}
118      * @param session a non-null {@link SocketSession} (may be connected or disconnected)
119      * @param callback a non-null {@link RioHandlerCallback} to callback
120      */
121     RioZoneProtocol(int zone, int controller, RioSystemFavoritesProtocol favoritesProtocol,
122             RioPresetsProtocol presetsProtocol, SocketSession session, RioHandlerCallback callback) {
123         super(session, callback);
124
125         if (controller < 1 || controller > 6) {
126             throw new IllegalArgumentException("Controller must be between 1-6: " + controller);
127         }
128         if (zone < 1 || zone > 8) {
129             throw new IllegalArgumentException("Zone must be between 1-6: " + zone);
130         }
131
132         this.controller = controller;
133         this.zone = zone;
134
135         this.favoritesProtocol = favoritesProtocol;
136         this.favoritesProtocol.addListener(this);
137
138         this.presetsProtocol = presetsProtocol;
139         this.presetsProtocol.addListener(this);
140
141         this.gson = GsonUtilities.createGson();
142
143         this.zoneFavorites[0] = new RioFavorite(1);
144         this.zoneFavorites[1] = new RioFavorite(2);
145     }
146
147     /**
148      * Helper method to issue post online commands
149      */
150     void postOnline() {
151         watchZone(true);
152         refreshZoneSource();
153         refreshZoneEnabled();
154         refreshZoneName();
155
156         systemFavoritesUpdated(favoritesProtocol.getJson());
157     }
158
159     /**
160      * Helper method to refresh a system keyname
161      *
162      * @param keyname a non-null, non-empty keyname
163      * @throws IllegalArgumentException if keyname is null or empty
164      */
165     private void refreshZoneKey(String keyname) {
166         if (keyname == null || keyname.trim().length() == 0) {
167             throw new IllegalArgumentException("keyName cannot be null or empty");
168         }
169
170         sendCommand("GET C[" + controller + "].Z[" + zone + "]." + keyname);
171     }
172
173     /**
174      * Refresh a zone name
175      */
176     void refreshZoneName() {
177         refreshZoneKey(ZONE_NAME);
178     }
179
180     /**
181      * Refresh the zone's source
182      */
183     void refreshZoneSource() {
184         refreshZoneKey(ZONE_SOURCE);
185     }
186
187     /**
188      * Refresh the zone's bass setting
189      */
190     void refreshZoneBass() {
191         refreshZoneKey(ZONE_BASS);
192     }
193
194     /**
195      * Refresh the zone's treble setting
196      */
197     void refreshZoneTreble() {
198         refreshZoneKey(ZONE_TREBLE);
199     }
200
201     /**
202      * Refresh the zone's balance setting
203      */
204     void refreshZoneBalance() {
205         refreshZoneKey(ZONE_BALANCE);
206     }
207
208     /**
209      * Refresh the zone's loudness setting
210      */
211     void refreshZoneLoudness() {
212         refreshZoneKey(ZONE_LOUDNESS);
213     }
214
215     /**
216      * Refresh the zone's turn on volume setting
217      */
218     void refreshZoneTurnOnVolume() {
219         refreshZoneKey(ZONE_TURNONVOLUME);
220     }
221
222     /**
223      * Refresh the zone's do not disturb setting
224      */
225     void refreshZoneDoNotDisturb() {
226         refreshZoneKey(ZONE_DONOTDISTURB);
227     }
228
229     /**
230      * Refresh the zone's party mode setting
231      */
232     void refreshZonePartyMode() {
233         refreshZoneKey(ZONE_PARTYMODE);
234     }
235
236     /**
237      * Refresh the zone's status
238      */
239     void refreshZoneStatus() {
240         refreshZoneKey(ZONE_STATUS);
241     }
242
243     /**
244      * Refresh the zone's volume setting
245      */
246     void refreshZoneVolume() {
247         refreshZoneKey(ZONE_VOLUME);
248     }
249
250     /**
251      * Refresh the zone's mute setting
252      */
253     void refreshZoneMute() {
254         refreshZoneKey(ZONE_MUTE);
255     }
256
257     /**
258      * Refresh the zone's paging setting
259      */
260     void refreshZonePage() {
261         refreshZoneKey(ZONE_PAGE);
262     }
263
264     /**
265      * Refresh the zone's shared source setting
266      */
267     void refreshZoneSharedSource() {
268         refreshZoneKey(ZONE_SHAREDSOURCE);
269     }
270
271     /**
272      * Refresh the zone's sleep time remaining setting
273      */
274     void refreshZoneSleepTimeRemaining() {
275         refreshZoneKey(ZONE_SLEEPTIMEREMAINING);
276     }
277
278     /**
279      * Refresh the zone's last error
280      */
281     void refreshZoneLastError() {
282         refreshZoneKey(ZONE_LASTERROR);
283     }
284
285     /**
286      * Refresh the zone's enabled setting
287      */
288     void refreshZoneEnabled() {
289         refreshZoneKey(ZONE_ENABLED);
290     }
291
292     /**
293      * Refreshes the system favorites via {@link #favoritesProtocol}
294      */
295     void refreshSystemFavorites() {
296         favoritesProtocol.refreshSystemFavorites();
297     }
298
299     /**
300      * Refreshes the zone favorites
301      */
302     void refreshZoneFavorites() {
303         for (int x = 1; x <= 2; x++) {
304             sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + x + "].valid");
305             sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + x + "].name");
306         }
307     }
308
309     /**
310      * Refresh the zone preset via {@link #presetsProtocol}
311      */
312     void refreshZonePresets() {
313         presetsProtocol.refreshPresets();
314     }
315
316     /**
317      * Turns on/off watching for zone notifications
318      *
319      * @param on true to turn on, false to turn off
320      */
321     void watchZone(boolean watch) {
322         sendCommand("WATCH C[" + controller + "].Z[" + zone + "] " + (watch ? "ON" : "OFF"));
323     }
324
325     /**
326      * Set's the zone bass setting (from -10 to 10)
327      *
328      * @param bass the bass setting from -10 to 10
329      * @throws IllegalArgumentException if bass < -10 or > 10
330      */
331     void setZoneBass(int bass) {
332         if (bass < -10 || bass > 10) {
333             throw new IllegalArgumentException("Bass must be between -10 and 10: " + bass);
334         }
335         sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_BASS + "=\"" + bass + "\"");
336     }
337
338     /**
339      * Set's the zone treble setting (from -10 to 10)
340      *
341      * @param treble the treble setting from -10 to 10
342      * @throws IllegalArgumentException if treble < -10 or > 10
343      */
344     void setZoneTreble(int treble) {
345         if (treble < -10 || treble > 10) {
346             throw new IllegalArgumentException("Treble must be between -10 and 10: " + treble);
347         }
348         sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_TREBLE + "=\"" + treble + "\"");
349     }
350
351     /**
352      * Set's the zone balance setting (from -10 [full left] to 10 [full right])
353      *
354      * @param balance the balance setting from -10 to 10
355      * @throws IllegalArgumentException if balance < -10 or > 10
356      */
357     void setZoneBalance(int balance) {
358         if (balance < -10 || balance > 10) {
359             throw new IllegalArgumentException("Balance must be between -10 and 10: " + balance);
360         }
361         sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_BALANCE + "=\"" + balance + "\"");
362     }
363
364     /**
365      * Set's the zone's loudness
366      *
367      * @param on true to turn on loudness, false to turn off
368      */
369     void setZoneLoudness(boolean on) {
370         sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_LOUDNESS + "=\"" + (on ? "ON" : "OFF") + "\"");
371     }
372
373     /**
374      * Set's the zone turn on volume (will be scaled between 0 and 50)
375      *
376      * @param volume the turn on volume (between 0 and 1)
377      * @throws IllegalArgumentException if volume < 0 or > 1
378      */
379     void setZoneTurnOnVolume(double volume) {
380         if (volume < 0 || volume > 1) {
381             throw new IllegalArgumentException("Volume must be between 0 and 1: " + volume);
382         }
383
384         final int scaledVolume = (int) ((volume * 100) / 2);
385         sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_TURNONVOLUME + "=\"" + scaledVolume + "\"");
386     }
387
388     /**
389      * Set's the zone sleep time remaining in seconds (from 0 to 60). Will be rounded to nearest 5 (37 will become 35,
390      * 38 will become 40).
391      *
392      * @param sleepTime the sleeptime in seconds
393      * @throws IllegalArgumentException if sleepTime < 0 or > 60
394      */
395     void setZoneSleepTimeRemaining(int sleepTime) {
396         if (sleepTime < 0 || sleepTime > 60) {
397             throw new IllegalArgumentException("Sleep Time Remaining must be between 0 and 60: " + sleepTime);
398         }
399         sleepTime = (int) (5 * Math.round(sleepTime / 5.0));
400         sendCommand("SET C[" + controller + "].Z[" + zone + "]." + ZONE_SLEEPTIMEREMAINING + "=\"" + sleepTime + "\"");
401     }
402
403     /**
404      * Set's the zone source (physical source from 1 to 12)
405      *
406      * @param source the source (1 to 12)
407      * @throws IllegalArgumentException if source is < 1 or > 12
408      */
409     void setZoneSource(int source) {
410         if (source < 1 || source > 12) {
411             throw new IllegalArgumentException("Source must be between 1 and 12");
412         }
413         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!SelectSource " + source);
414     }
415
416     /**
417      * Set's the zone's status
418      *
419      * @param on true to turn on, false otherwise
420      */
421     void setZoneStatus(boolean on) {
422         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!Zone" + (on ? "On" : "Off"));
423     }
424
425     /**
426      * Set's the zone's partymode (supports on/off/master). Case does not matter - will be
427      * converted to uppercase for the system.
428      *
429      * @param partyMode a non-null, non-empty party mode
430      * @throws IllegalArgumentException if partymode is null, empty or not (on/off/master).
431      */
432     void setZonePartyMode(String partyMode) {
433         if (partyMode == null || partyMode.trim().length() == 0) {
434             throw new IllegalArgumentException("PartyMode cannot be null or empty");
435         }
436         if ("|on|off|master|".indexOf("|" + partyMode + "|") == -1) {
437             throw new IllegalArgumentException(
438                     "Party mode can only be set to on, off or master: " + partyMode.toUpperCase());
439         }
440         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!PartyMode " + partyMode);
441     }
442
443     /**
444      * Set's the zone's do not disturb (supports on/off/slave). Case does not matter - will be
445      * converted to uppercase for the system. Please note that slave will be translated to "ON" but may be refreshed
446      * back to "SLAVE" if a master zone has been designated
447      *
448      * @param doNotDisturb a non-null, non-empty do not disturb mode
449      * @throws IllegalArgumentException if doNotDisturb is null, empty or not (on/off/slave).
450      */
451     void setZoneDoNotDisturb(String doNotDisturb) {
452         if (doNotDisturb == null || doNotDisturb.trim().length() == 0) {
453             throw new IllegalArgumentException("Do Not Disturb cannot be null or empty");
454         }
455         if ("|on|off|slave|".indexOf("|" + doNotDisturb + "|") == -1) {
456             throw new IllegalArgumentException("Do Not Disturb can only be set to on, off or slave: " + doNotDisturb);
457         }
458         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!DoNotDisturb "
459                 + ("off".equals(doNotDisturb) ? "OFF" : "ON")); // translate "slave" to "on"
460     }
461
462     /**
463      * Sets the zone's volume level (scaled to 0-50)
464      *
465      * @param volume the volume level
466      * @throws IllegalArgumentException if volume is < 0 or > 1
467      */
468     void setZoneVolume(double volume) {
469         if (volume < 0 || volume > 1) {
470             throw new IllegalArgumentException("Volume must be between 0 and 1");
471         }
472
473         final int scaledVolume = (int) ((volume * 100) / 2);
474         sendKeyPress("Volume " + scaledVolume);
475     }
476
477     /**
478      * Sets the volume up or down by 1
479      *
480      * @param increase true to increase by 1, false to decrease
481      */
482     void setZoneVolume(boolean increase) {
483         sendKeyPress("Volume" + (increase ? "Up" : "Down"));
484     }
485
486     /**
487      * Toggles the zone's mute
488      */
489     void toggleZoneMute() {
490         sendKeyRelease("Mute");
491     }
492
493     /**
494      * Toggles the zone's shuffle if the source supports shuffle mode
495      */
496     void toggleZoneShuffle() {
497         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!Shuffle");
498     }
499
500     /**
501      * Toggles the zone's repeat if the source supports repeat mod
502      */
503     void toggleZoneRepeat() {
504         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!Repeat");
505     }
506
507     /**
508      * Assign a rating to the current song if the source supports a rating
509      *
510      * @param like true to like, false to dislike
511      */
512     void setZoneRating(boolean like) {
513         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!MMRate " + (like ? "hi" : "low"));
514     }
515
516     /**
517      * Sets the system favorite based on what is currently being played in the zone via {@link #favoritesProtocol}
518      *
519      * @param favJson a possibly null, possibly empty JSON of favorites to set
520      */
521     void setSystemFavorites(String favJson) {
522         favoritesProtocol.setSystemFavorites(controller, zone, favJson);
523     }
524
525     /**
526      * Sets the zone favorites to what is currently playing
527      *
528      * @param favJson a possibly null, possibly empty json for favorites to set
529      * @return a non-null {@link Runnable} that should be run after the call
530      */
531     Runnable setZoneFavorites(String favJson) {
532         if (favJson.isEmpty()) {
533             return () -> {
534             };
535         }
536
537         final List<Integer> updateFavIds = new ArrayList<>();
538         try {
539             final RioFavorite[] favs = gson.fromJson(favJson, RioFavorite[].class);
540             for (int x = favs.length - 1; x >= 0; x--) {
541                 final RioFavorite fav = favs[x];
542                 if (fav == null) {
543                     continue;// caused by {id,valid,name},,{id,valid,name}
544                 }
545                 final int favId = fav.getId();
546                 if (favId < 1 || favId > 2) {
547                     logger.debug("Invalid favorite id (not between 1 and 2) - ignoring: {}:{}", favId, favJson);
548                 } else {
549                     final RioFavorite myFav = zoneFavorites[favId - 1];
550                     final boolean favValid = fav.isValid();
551                     final String favName = fav.getName();
552
553                     if (!Objects.equals(myFav.getName(), favName) || myFav.isValid() != favValid) {
554                         myFav.setName(favName);
555                         myFav.setValid(favValid);
556                         if (favValid) {
557                             sendEvent("saveZoneFavorite \"" + favName + "\" " + favId);
558                             updateFavIds.add(favId);
559                         } else {
560                             sendEvent("deleteZoneFavorite " + favId);
561                         }
562                     }
563                 }
564             }
565         } catch (JsonSyntaxException e) {
566             logger.debug("Invalid JSON: {}", e.getMessage(), e);
567         }
568         // regardless of what happens above - reupdate the channel
569         // (to remove anything bad from it)
570         return () -> {
571             for (Integer favId : updateFavIds) {
572                 sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + favId + "].valid");
573                 sendCommand("GET C[" + controller + "].Z[" + zone + "].favorite[" + favId + "].name");
574             }
575             updateZoneFavoritesChannel();
576         };
577     }
578
579     /**
580      * Sets the zone presets for what is currently playing via {@link #presetsProtocol}
581      *
582      * @param presetJson a possibly empty, possibly null preset json
583      */
584     void setZonePresets(String presetJson) {
585         presetsProtocol.setZonePresets(controller, zone, sourceId.get(), presetJson);
586     }
587
588     /**
589      * Sends a KeyPress instruction to the zone
590      *
591      * @param keyPress a non-null, non-empty string to send
592      * @throws IllegalArgumentException if keyPress is null or empty
593      */
594     void sendKeyPress(String keyPress) {
595         if (keyPress == null || keyPress.trim().length() == 0) {
596             throw new IllegalArgumentException("keyPress cannot be null or empty");
597         }
598         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyPress " + keyPress);
599     }
600
601     /**
602      * Sends a KeyRelease instruction to the zone
603      *
604      * @param keyRelease a non-null, non-empty string to send
605      * @throws IllegalArgumentException if keyRelease is null or empty
606      */
607     void sendKeyRelease(String keyRelease) {
608         if (keyRelease == null || keyRelease.trim().length() == 0) {
609             throw new IllegalArgumentException("keyRelease cannot be null or empty");
610         }
611         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyRelease " + keyRelease);
612     }
613
614     /**
615      * Sends a KeyHold instruction to the zone
616      *
617      * @param keyHold a non-null, non-empty string to send
618      * @throws IllegalArgumentException if keyHold is null or empty
619      */
620     void sendKeyHold(String keyHold) {
621         if (keyHold == null || keyHold.trim().length() == 0) {
622             throw new IllegalArgumentException("keyHold cannot be null or empty");
623         }
624         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyHold " + keyHold);
625     }
626
627     /**
628      * Sends a KeyCode instruction to the zone
629      *
630      * @param keyCode a non-null, non-empty string to send
631      * @throws IllegalArgumentException if keyCode is null or empty
632      */
633     void sendKeyCode(String keyCode) {
634         if (keyCode == null || keyCode.trim().length() == 0) {
635             throw new IllegalArgumentException("keyCode cannot be null or empty");
636         }
637         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!KeyCode " + keyCode);
638     }
639
640     /**
641      * Sends an EVENT instruction to the zone
642      *
643      * @param event a non-null, non-empty string to send
644      * @throws IllegalArgumentException if event is null or empty
645      */
646     void sendEvent(String event) {
647         if (event == null || event.trim().length() == 0) {
648             throw new IllegalArgumentException("event cannot be null or empty");
649         }
650         sendCommand("EVENT C[" + controller + "].Z[" + zone + "]!" + event);
651     }
652
653     /**
654      * Sends the MMInit [home screen] command
655      */
656     void sendMMInit() {
657         sendEvent("MMVerbosity 2");
658         sendEvent("MMIndex ABSOLUTE");
659         sendEvent("MMFormat JSON");
660         sendEvent("MMUseBlockInfo TRUE");
661         sendEvent("MMUseForms FALSE");
662         sendEvent("MMMaxItems 25");
663
664         sendEvent(ZONE_MMINIT);
665     }
666
667     /**
668      * Requests a context menu
669      */
670     void sendMMContextMenu() {
671         sendEvent("MMVerbosity 2");
672         sendEvent("MMIndex ABSOLUTE");
673         sendEvent("MMFormat JSON");
674         sendEvent("MMUseBlockInfo TRUE");
675         sendEvent("MMUseForms FALSE");
676         sendEvent("MMMaxItems 25");
677
678         sendEvent(ZONE_MMCONTEXTMENU);
679     }
680
681     /**
682      * Handles any zone notifications returned by the russound system
683      *
684      * @param m a non-null matcher
685      * @param resp a possibly null, possibly empty response
686      */
687     private void handleZoneNotification(Matcher m, String resp) {
688         if (m == null) {
689             throw new IllegalArgumentException("m (matcher) cannot be null");
690         }
691         if (m.groupCount() == 4) {
692             try {
693                 final int notifyController = Integer.parseInt(m.group(1));
694                 if (notifyController != controller) {
695                     return;
696                 }
697                 final int notifyZone = Integer.parseInt(m.group(2));
698                 if (notifyZone != zone) {
699                     return;
700                 }
701                 final String key = m.group(3).toLowerCase();
702                 final String value = m.group(4);
703
704                 switch (key) {
705                     case ZONE_NAME:
706                         stateChanged(RioConstants.CHANNEL_ZONENAME, new StringType(value));
707                         break;
708
709                     case ZONE_SOURCE:
710                         try {
711                             final int nbr = Integer.parseInt(value);
712                             stateChanged(RioConstants.CHANNEL_ZONESOURCE, new DecimalType(nbr));
713
714                             if (nbr != sourceId.getAndSet(nbr)) {
715                                 sourceId.set(nbr);
716                                 presetsUpdated(nbr, presetsProtocol.getJson(nbr));
717                             }
718                         } catch (NumberFormatException e) {
719                             logger.warn("Invalid zone notification (source not parsable): '{}')", resp);
720                         }
721                         break;
722
723                     case ZONE_BASS:
724                         try {
725                             final int nbr = Integer.parseInt(value);
726                             stateChanged(RioConstants.CHANNEL_ZONEBASS, new DecimalType(nbr));
727                         } catch (NumberFormatException e) {
728                             logger.warn("Invalid zone notification (bass not parsable): '{}')", resp);
729                         }
730                         break;
731
732                     case ZONE_TREBLE:
733                         try {
734                             final int nbr = Integer.parseInt(value);
735                             stateChanged(RioConstants.CHANNEL_ZONETREBLE, new DecimalType(nbr));
736                         } catch (NumberFormatException e) {
737                             logger.warn("Invalid zone notification (treble not parsable): '{}')", resp);
738                         }
739                         break;
740
741                     case ZONE_BALANCE:
742                         try {
743                             final int nbr = Integer.parseInt(value);
744                             stateChanged(RioConstants.CHANNEL_ZONEBALANCE, new DecimalType(nbr));
745                         } catch (NumberFormatException e) {
746                             logger.warn("Invalid zone notification (balance not parsable): '{}')", resp);
747                         }
748                         break;
749
750                     case ZONE_LOUDNESS:
751                         stateChanged(RioConstants.CHANNEL_ZONELOUDNESS,
752                                 "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
753                         break;
754
755                     case ZONE_TURNONVOLUME:
756                         try {
757                             final int nbr = Integer.parseInt(value);
758                             stateChanged(RioConstants.CHANNEL_ZONETURNONVOLUME, new PercentType(nbr * 2));
759                         } catch (NumberFormatException e) {
760                             logger.warn("Invalid zone notification (turnonvolume not parsable): '{}')", resp);
761                         }
762                         break;
763
764                     case ZONE_DONOTDISTURB:
765                         stateChanged(RioConstants.CHANNEL_ZONEDONOTDISTURB, new StringType(value));
766                         break;
767
768                     case ZONE_PARTYMODE:
769                         stateChanged(RioConstants.CHANNEL_ZONEPARTYMODE, new StringType(value));
770                         break;
771
772                     case ZONE_STATUS:
773                         stateChanged(RioConstants.CHANNEL_ZONESTATUS,
774                                 "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
775                         break;
776                     case ZONE_MUTE:
777                         stateChanged(RioConstants.CHANNEL_ZONEMUTE, "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
778                         break;
779
780                     case ZONE_SHAREDSOURCE:
781                         stateChanged(RioConstants.CHANNEL_ZONESHAREDSOURCE,
782                                 "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
783                         break;
784
785                     case ZONE_LASTERROR:
786                         stateChanged(RioConstants.CHANNEL_ZONELASTERROR, new StringType(value));
787                         break;
788
789                     case ZONE_PAGE:
790                         stateChanged(RioConstants.CHANNEL_ZONEPAGE, "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
791                         break;
792
793                     case ZONE_SLEEPTIMEREMAINING:
794                         try {
795                             final int nbr = Integer.parseInt(value);
796                             stateChanged(RioConstants.CHANNEL_ZONESLEEPTIMEREMAINING, new DecimalType(nbr));
797                         } catch (NumberFormatException e) {
798                             logger.warn("Invalid zone notification (sleeptimeremaining not parsable): '{}')", resp);
799                         }
800                         break;
801
802                     case ZONE_ENABLED:
803                         stateChanged(RioConstants.CHANNEL_ZONEENABLED,
804                                 "ON".equals(value) ? OnOffType.ON : OnOffType.OFF);
805                         break;
806
807                     case ZONE_VOLUME:
808                         try {
809                             final int nbr = Integer.parseInt(value);
810                             stateChanged(RioConstants.CHANNEL_ZONEVOLUME, new PercentType(nbr * 2));
811                         } catch (NumberFormatException e) {
812                             logger.warn("Invalid zone notification (volume not parsable): '{}')", resp);
813                         }
814                         break;
815
816                     default:
817                         logger.warn("Unknown zone notification: '{}'", resp);
818                         break;
819                 }
820             } catch (NumberFormatException e) {
821                 logger.warn("Invalid Zone Notification (controller/zone not a parsable integer): '{}')", resp);
822             }
823         } else {
824             logger.warn("Invalid Zone Notification response: '{}'", resp);
825         }
826     }
827
828     /**
829      * Handles any system notifications returned by the russound system
830      *
831      * @param m a non-null matcher
832      * @param resp a possibly null, possibly empty response
833      */
834     void handleZoneFavoriteNotification(Matcher m, String resp) {
835         if (m == null) {
836             throw new IllegalArgumentException("m (matcher) cannot be null");
837         }
838         if (m.groupCount() == 5) {
839             try {
840                 final int notifyController = Integer.parseInt(m.group(1));
841                 if (notifyController != controller) {
842                     return;
843                 }
844                 final int notifyZone = Integer.parseInt(m.group(2));
845                 if (notifyZone != zone) {
846                     return;
847                 }
848
849                 final int favoriteId = Integer.parseInt(m.group(3));
850
851                 if (favoriteId >= 1 && favoriteId <= 2) {
852                     final RioFavorite fav = zoneFavorites[favoriteId - 1];
853
854                     final String key = m.group(4);
855                     final String value = m.group(5);
856
857                     switch (key) {
858                         case FAV_NAME:
859                             fav.setName(value);
860                             updateZoneFavoritesChannel();
861                             break;
862                         case FAV_VALID:
863                             fav.setValid(!"false".equalsIgnoreCase(value));
864                             updateZoneFavoritesChannel();
865                             break;
866
867                         default:
868                             logger.warn("Unknown zone favorite notification: '{}'", resp);
869                             break;
870                     }
871                 } else {
872                     logger.warn("Invalid Zone Favorite Notification (favorite < 1 or > 2): '{}')", resp);
873                 }
874             } catch (NumberFormatException e) {
875                 logger.warn("Invalid Zone Favorite Notification (favorite not a parsable integer): '{}')", resp);
876             }
877         } else {
878             logger.warn("Invalid Zone Notification response: '{}'", resp);
879         }
880     }
881
882     /**
883      * Will update the zone favorites channel with only valid favorites
884      */
885     private void updateZoneFavoritesChannel() {
886         final List<RioFavorite> favs = new ArrayList<>();
887         for (final RioFavorite fav : zoneFavorites) {
888             if (fav.isValid()) {
889                 favs.add(fav);
890             }
891         }
892
893         final String favJson = gson.toJson(favs);
894         stateChanged(RioConstants.CHANNEL_ZONEFAVORITES, new StringType(favJson));
895     }
896
897     /**
898      * Callback method when system favorites are updated. Simply issues a state change for the zone system favorites
899      * channel using the jsonString as the value
900      */
901     @Override
902     public void systemFavoritesUpdated(String jsonString) {
903         stateChanged(RioConstants.CHANNEL_ZONESYSFAVORITES, new StringType(jsonString));
904     }
905
906     /**
907      * Callback method when presets are updated. Simply issues a state change for the zone presets channel using the
908      * jsonString as the value
909      */
910     @Override
911     public void presetsUpdated(int sourceIdUpdated, String jsonString) {
912         if (sourceIdUpdated != sourceId.get()) {
913             return;
914         }
915         stateChanged(RioConstants.CHANNEL_ZONEPRESETS, new StringType(jsonString));
916     }
917
918     /**
919      * Implements {@link SocketSessionListener#responseReceived(String)} to try to process the response from the
920      * russound system. This response may be for other protocol handler - so ignore if we don't recognize the response.
921      *
922      * @param a possibly null, possibly empty response
923      */
924     @Override
925     public void responseReceived(String response) {
926         if (response == null || response.isEmpty()) {
927             return;
928         }
929
930         Matcher m = RSP_ZONENOTIFICATION.matcher(response);
931         if (m.matches()) {
932             handleZoneNotification(m, response);
933         }
934
935         m = RSP_ZONEFAVORITENOTIFICATION.matcher(response);
936         if (m.matches()) {
937             handleZoneFavoriteNotification(m, response);
938         }
939     }
940
941     /**
942      * Overrides the default implementation to turn watch off ({@link #watchZone(boolean)}) before calling the dispose
943      */
944     @Override
945     public void dispose() {
946         watchZone(false);
947         favoritesProtocol.removeListener(this);
948         super.dispose();
949     }
950 }