]> git.basschouten.com Git - openhab-addons.git/blob
0f65e9fc185439fc9e3376327f7c3a0031af5373
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.atlona.internal.pro3;
14
15 import java.io.IOException;
16 import java.util.concurrent.ArrayBlockingQueue;
17 import java.util.concurrent.BlockingQueue;
18 import java.util.concurrent.TimeUnit;
19 import java.util.regex.Matcher;
20 import java.util.regex.Pattern;
21
22 import org.apache.commons.lang.StringUtils;
23 import org.openhab.binding.atlona.internal.AtlonaHandlerCallback;
24 import org.openhab.binding.atlona.internal.net.SocketSession;
25 import org.openhab.binding.atlona.internal.net.SocketSessionListener;
26 import org.openhab.core.library.types.DecimalType;
27 import org.openhab.core.library.types.OnOffType;
28 import org.openhab.core.thing.ThingStatus;
29 import org.openhab.core.thing.ThingStatusDetail;
30 import org.slf4j.Logger;
31 import org.slf4j.LoggerFactory;
32
33 /**
34  * This is the protocol handler for the PRO3 product line. This handler will issue the protocol commands and will
35  * process the responses from the PRO3 switch. This handler was written to respond to any response that can be sent from
36  * the TCP/IP session (either in response to our own commands or in response to external events [other TCP/IP sessions,
37  * web GUI, front panel keystrokes, etc]).
38  *
39  * @author Tim Roberts - Initial contribution
40  * @author Michael Lobstein - Add support for AT-PRO3HD66M
41  */
42 class AtlonaPro3PortocolHandler {
43     private final Logger logger = LoggerFactory.getLogger(AtlonaPro3PortocolHandler.class);
44
45     /**
46      * The {@link SocketSession} used by this protocol handler
47      */
48     private final SocketSession session;
49
50     /**
51      * The {@link AtlonaPro3Config} configuration used by this handler
52      */
53     private final AtlonaPro3Config config;
54
55     /**
56      * The {@link AtlonaPro3Capabilities} of the PRO3 model
57      */
58     private final AtlonaPro3Capabilities capabilities;
59
60     /**
61      * The {@link AtlonaPro3Handler} to call back to update status and state
62      */
63     private final AtlonaHandlerCallback callback;
64
65     /**
66      * The model type identified by the switch. We save it for faster refreshes since it will not change
67      */
68     private String modelType;
69
70     /**
71      * The version (firmware) identified by the switch. We save it for faster refreshes since it will not change between
72      * sessions
73      */
74     private String version;
75
76     /**
77      * A special (invalid) command used internally by this handler to identify whether the switch wants a login or not
78      * (see {@link #login()})
79      */
80     private static final String NOTVALID_USER_OR_CMD = "notvalid$934%912";
81
82     // ------------------------------------------------------------------------------------------------
83     // The following are the various command formats specified by the Atlona protocol
84     private static final String CMD_POWERON = "PWON";
85     private static final String CMD_POWEROFF = "PWOFF";
86     private static final String CMD_POWER_STATUS = "PWSTA";
87     private static final String CMD_VERSION = "Version";
88     private static final String CMD_TYPE = "Type";
89     private static final String CMD_PANELLOCK = "Lock";
90     private static final String CMD_PANELUNLOCK = "Unlock";
91     private static final String CMD_PORT_RESETALL = "All#";
92     private static final String CMD_PORT_POWER_FORMAT = "x%d$ %s";
93     private static final String CMD_PORT_ALL_FORMAT = "x%dAll";
94     private static final String CMD_PORT_SWITCH_FORMAT = "x%dAVx%d";
95     private static final String CMD_PORT_MIRROR_FORMAT = "MirrorHdmi%d Out%d";
96     private static final String CMD_PORT_MIRROR_STATUS_FORMAT = "MirrorHdmi%d sta";
97     private static final String CMD_PORT_UNMIRROR_FORMAT = "UnMirror%d";
98     private static final String CMD_VOLUME_FORMAT = "VOUT%d %s";
99     private static final String CMD_VOLUME_MUTE_FORMAT = "VOUTMute%d %s";
100     private static final String CMD_IROFF = "IROFF";
101     private static final String CMD_IRON = "IRON";
102     private static final String CMD_PORT_STATUS = "Status";
103     private static final String CMD_PORT_STATUS_FORMAT = "Statusx%d";
104     private static final String CMD_SAVEIO_FORMAT = "Save%d";
105     private static final String CMD_RECALLIO_FORMAT = "Recall%d";
106     private static final String CMD_CLEARIO_FORMAT = "Clear%d";
107     private static final String CMD_MATRIX_RESET = "Mreset";
108     private static final String CMD_BROADCAST_ON = "Broadcast on";
109
110     // ------------------------------------------------------------------------------------------------
111     // The following are the various responses specified by the Atlona protocol
112     private static final String RSP_FAILED = "Command FAILED:";
113
114     private static final String RSP_LOGIN = "Login";
115     private static final String RSP_PASSWORD = "Password";
116
117     private final Pattern powerStatusPattern = Pattern.compile("PW(\\w+)");
118     private final Pattern versionPattern = Pattern.compile("Firmware (.*)");
119     private final Pattern typePattern = Pattern.compile("AT-UHD-PRO3-(\\d+)M");
120     private static final String RSP_ALL = "All#";
121     private static final String RSP_LOCK = "Lock";
122     private static final String RSP_UNLOCK = "Unlock";
123     private final Pattern portStatusPattern = Pattern.compile("x(\\d+)AVx(\\d+),?+");
124     private final Pattern portPowerPattern = Pattern.compile("x(\\d+)\\$ (\\w+)");
125     private final Pattern portAllPattern = Pattern.compile("x(\\d+)All");
126     private final Pattern portMirrorPattern = Pattern.compile("MirrorHdmi(\\d+) (\\p{Alpha}+)(\\d*)");
127     private final Pattern portUnmirrorPattern = Pattern.compile("UnMirror(\\d+)");
128     private final Pattern volumePattern = Pattern.compile("VOUT(\\d+) (-?\\d+)");
129     private final Pattern volumeMutePattern = Pattern.compile("VOUTMute(\\d+) (\\w+)");
130     private static final String RSP_IROFF = "IROFF";
131     private static final String RSP_IRON = "IRON";
132     private final Pattern saveIoPattern = Pattern.compile("Save(\\d+)");
133     private final Pattern recallIoPattern = Pattern.compile("Recall(\\d+)");
134     private final Pattern clearIoPattern = Pattern.compile("Clear(\\d+)");
135     private final Pattern broadCastPattern = Pattern.compile("Broadcast (\\w+)");
136     private static final String RSP_MATRIX_RESET = "Mreset";
137
138     // Constants added to support the HD models
139     private static final String RSP_WELCOME = "Welcome to TELNET";
140     private static final String RSP_LOGIN_PLEASE = "Login Please";
141     private static final String RSP_USERNAME = "Username";
142     private static final String RSP_TRY_AGAIN = "Please Try Again";
143     private final Pattern versionHdPattern = Pattern.compile("V(.*)");
144     private final Pattern typeHdPattern = Pattern.compile("AT-PRO3HD(\\d+)M");
145
146     // ------------------------------------------------------------------------------------------------
147     // The following isn't part of the atlona protocol and is generated by us
148     private static final String CMD_PING = "ping";
149     private static final String RSP_PING = "Command FAILED: (ping)";
150
151     /**
152      * Constructs the protocol handler from given parameters
153      *
154      * @param session a non-null {@link SocketSession} (may be connected or disconnected)
155      * @param config a non-null {@link AtlonaPro3Config}
156      * @param capabilities a non-null {@link AtlonaPro3Capabilities}
157      * @param callback a non-null {@link AtlonaHandlerCallback} to update state and status
158      */
159     AtlonaPro3PortocolHandler(SocketSession session, AtlonaPro3Config config, AtlonaPro3Capabilities capabilities,
160             AtlonaHandlerCallback callback) {
161         if (session == null) {
162             throw new IllegalArgumentException("session cannot be null");
163         }
164
165         if (config == null) {
166             throw new IllegalArgumentException("config cannot be null");
167         }
168
169         if (capabilities == null) {
170             throw new IllegalArgumentException("capabilities cannot be null");
171         }
172
173         if (callback == null) {
174             throw new IllegalArgumentException("callback cannot be null");
175         }
176
177         this.session = session;
178         this.config = config;
179         this.capabilities = capabilities;
180         this.callback = callback;
181     }
182
183     /**
184      * Attempts to log into the switch when prompted by the switch. Please see code comments on the exact protocol for
185      * this.
186      *
187      * @return a null if logged in successfully (or if switch didn't require login). Non-null if an exception occurred.
188      * @throws IOException an IO exception occurred during login
189      */
190     String loginUHD() throws Exception {
191         logger.debug("Logging into atlona switch");
192         // Void to make sure we retrieve them
193         modelType = null;
194         version = null;
195
196         NoDispatchingCallback callback = new NoDispatchingCallback();
197         session.addListener(callback);
198
199         // Burn the initial (empty) return
200         String response;
201         try {
202             response = callback.getResponse();
203             if (!response.equals("")) {
204                 logger.debug("Altona protocol violation - didn't start with an inital empty response: '{}'", response);
205             }
206         } catch (Exception e) {
207             // ignore - may not having given us an initial ""
208         }
209
210         // At this point - we are not sure if it's:
211         // 1) waiting for a command input
212         // or 2) has sent a "Login: " prompt
213         // By sending a string that doesn't exist as a command or user
214         // we can tell which by the response to the invalid command
215         session.sendCommand(NOTVALID_USER_OR_CMD);
216
217         // Command failed - Altona not configured with IPLogin - return success
218         response = callback.getResponse();
219         if (response.startsWith(RSP_FAILED)) {
220             logger.debug("Altona didn't require a login");
221             postLogin();
222             return null;
223         }
224
225         // We should have been presented with a new "\r\nLogin: "
226         response = callback.getResponse();
227         if (!response.equals("")) {
228             logger.debug("Altona protocol violation - didn't start with an inital empty response: '{}'", response);
229         }
230
231         // Get the new "Login: " prompt response
232         response = callback.getResponse();
233         if (response.equals(RSP_LOGIN)) {
234             if (config.getUserName() == null || config.getUserName().trim().length() == 0) {
235                 return "Atlona PRO3 has enabled Telnet/IP Login but no username was provided in the configuration.";
236             }
237
238             // Send the username and wait for a ": " response
239             session.sendCommand(config.getUserName());
240         } else {
241             return "Altona protocol violation - wasn't initially a command failure or login prompt: " + response;
242         }
243
244         // We should have gotten the password response
245         response = callback.getResponse();
246
247         // Burn the empty response if we got one (
248         if (response.equals("")) {
249             response = callback.getResponse();
250         }
251         if (!response.equals(RSP_PASSWORD)) {
252             // If we got another login response, username wasn't valid
253             if (response.equals(RSP_LOGIN)) {
254                 return "Username " + config.getUserName() + " is not a valid user on the atlona";
255             }
256             return "Altona protocol violation - invalid response to a login: " + response;
257         }
258
259         // Make sure we have a password
260         if (config.getPassword() == null || config.getPassword().trim().length() == 0) {
261             return "Atlona PRO3 has enabled Telnet/IP Login but no password was provided in the configuration.";
262         }
263
264         // Send the password
265         session.sendCommand(config.getPassword());
266         response = callback.getResponse();
267
268         // At this point, we don't know if we received a
269         // 1) "\r\n" and waiting for a command
270         // or 2) "\r\nLogin: " if the password is invalid
271         // Send an invalid command to see if we get the failed command response
272
273         // First make sure we had an empty response (the "\r\n" part)
274         if (!response.equals("")) {
275             logger.debug("Altona protocol violation - not an empty response after password: '{}'", response);
276         }
277
278         // Now send an invalid command
279         session.sendCommand(NOTVALID_USER_OR_CMD);
280
281         // If we get an invalid command response - we are logged in
282         response = callback.getResponse();
283         if (response.startsWith(RSP_FAILED)) {
284             postLogin();
285             return null;
286         }
287
288         // Nope - password invalid
289         return "Password was invalid - please check your atlona setup";
290     }
291
292     /**
293      * Attempts to log into the older HD model switches using a slightly different protocol
294      *
295      * @return a null if logged in successfully (or if switch didn't require login). Non-null if an exception occurred.
296      * @throws IOException an IO exception occurred during login
297      */
298     String loginHD() throws Exception {
299         logger.debug("Logging into atlona switch");
300         // Void to make sure we retrieve them
301         modelType = null;
302         version = null;
303
304         NoDispatchingCallback callback = new NoDispatchingCallback();
305         session.addListener(callback);
306
307         // Burn the initial (empty) return
308         String response;
309         try {
310             response = callback.getResponse();
311             if (!response.equals("")) {
312                 logger.debug("Altona protocol violation - didn't start with an inital empty response: '{}'", response);
313             }
314         } catch (Exception e) {
315             // ignore - may not having given us an initial ""
316         }
317
318         response = callback.getResponse();
319         if (response.startsWith(RSP_WELCOME)) {
320             logger.debug("Altona AT-PRO3HD66M didn't require a login");
321             postLogin();
322             return null;
323         } else {
324             if (!response.startsWith(RSP_LOGIN_PLEASE)) {
325                 logger.debug("Altona protocol violation - didn't start with login prompt '{}'", response);
326             }
327             // Since we were not logged in automatically, a user name is required from the configuration
328             if (config.getUserName() == null || config.getUserName().trim().length() == 0) {
329                 return "Atlona PRO3 has enabled Telnet/IP Login but no username was provided in the configuration.";
330             }
331
332             // Make sure we have a password too
333             if (config.getPassword() == null || config.getPassword().trim().length() == 0) {
334                 return "Atlona PRO3 has enabled Telnet/IP Login but no password was provided in the configuration.";
335             }
336
337             // Check for an empty response after the login prompt (the "\r\n" part)
338             response = callback.getResponse();
339             if (!response.equals("")) {
340                 logger.debug("Altona protocol violation - not an empty response after password: '{}'", response);
341             }
342
343             // Send the username and wait for a ": " response
344             session.sendCommand(config.getUserName());
345
346             // We should have gotten the username response
347             response = callback.getResponse();
348             if (!response.startsWith(RSP_USERNAME)) {
349                 logger.debug("Altona protocol violation - invalid response to username: '{}'", response);
350             }
351
352             // Send the password
353             try {
354                 session.sendCommand(config.getPassword());
355                 response = callback.getResponse();
356             } catch (Exception e) {
357                 return "Password was invalid - please check your atlona setup";
358             }
359
360             if (response.startsWith(RSP_TRY_AGAIN)) {
361                 return "Username " + config.getUserName() + " is not a valid user on the atlona";
362             }
363
364             if (response.startsWith(RSP_PASSWORD)) {
365                 // After the correct password is sent, several empty responses are sent before the welcome message
366                 for (int i = 0; i < 8; i++) {
367                     response = callback.getResponse();
368
369                     // If we get a welcome message, login was successful
370                     if (response.startsWith(RSP_WELCOME)) {
371                         postLogin();
372                         return null;
373                     }
374                 }
375             }
376         }
377         return "Authentication failed - please check your atlona setup";
378     }
379
380     /**
381      * Post successful login stuff - mark us online and refresh from the switch
382      */
383     private void postLogin() {
384         logger.debug("Atlona switch now connected");
385         session.clearListeners();
386         session.addListener(new NormalResponseCallback());
387         callback.statusChanged(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
388
389         if (capabilities.isUHDModel()) {
390             // Set broadcast to on to receive notifications when
391             // routing changes (via the webpage, or presets or IR, etc)
392             sendCommand(CMD_BROADCAST_ON);
393         }
394
395         // setup the most likely state of these switches (there is no protocol to get them)
396         refreshAll();
397     }
398
399     /**
400      * Returns the callback being used by this handler
401      *
402      * @return a non-null {@link AtlonaHandlerCallback}
403      */
404     AtlonaHandlerCallback getCallback() {
405         return callback;
406     }
407
408     /**
409      * Pings the server with an (invalid) ping command to keep the connection alive
410      */
411     void ping() {
412         if (capabilities.isUHDModel()) {
413             sendCommand(CMD_PING);
414         } else {
415             // the HD model does not reflect the invalid command string back in the response for us to match later
416             sendCommand(CMD_VERSION);
417         }
418     }
419
420     /**
421      * Refreshes the state from the switch itself. This will retrieve all the state (that we can get) from the switch.
422      */
423     void refreshAll() {
424         logger.debug("Refreshing matrix state");
425         if (version == null) {
426             refreshVersion();
427         } else {
428             callback.setProperty(AtlonaPro3Constants.PROPERTY_VERSION, version);
429         }
430
431         if (modelType == null) {
432             refreshType();
433         } else {
434             callback.setProperty(AtlonaPro3Constants.PROPERTY_TYPE, modelType);
435         }
436
437         refreshPower();
438         if (capabilities.isUHDModel()) {
439             refreshAllPortStatuses();
440         }
441
442         final int nbrPowerPorts = capabilities.getNbrPowerPorts();
443         for (int x = 1; x <= nbrPowerPorts; x++) {
444             refreshPortPower(x);
445         }
446
447         final int nbrAudioPorts = capabilities.getNbrAudioPorts();
448         for (int x = 1; x <= nbrAudioPorts; x++) {
449             refreshVolumeStatus(x);
450             refreshVolumeMute(x);
451         }
452
453         for (int x : capabilities.getHdmiPorts()) {
454             refreshPortStatus(x);
455         }
456     }
457
458     /**
459      * Sets the power to the switch
460      *
461      * @param on true if on, false otherwise
462      */
463     void setPower(boolean on) {
464         sendCommand(on ? CMD_POWERON : CMD_POWEROFF);
465     }
466
467     /**
468      * Queries the switch about it's power state
469      */
470     void refreshPower() {
471         sendCommand(CMD_POWER_STATUS);
472     }
473
474     /**
475      * Queries the switch about it's version (firmware)
476      */
477     void refreshVersion() {
478         sendCommand(CMD_VERSION);
479     }
480
481     /**
482      * Queries the switch about it's type (model)
483      */
484     void refreshType() {
485         sendCommand(CMD_TYPE);
486     }
487
488     /**
489      * Sets whether the front panel is locked or not
490      *
491      * @param locked true if locked, false otherwise
492      */
493     void setPanelLock(boolean locked) {
494         sendCommand(locked ? CMD_PANELLOCK : CMD_PANELUNLOCK);
495     }
496
497     /**
498      * Resets all ports back to their default state.
499      */
500     void resetAllPorts() {
501         sendCommand(CMD_PORT_RESETALL);
502     }
503
504     /**
505      * Sets whether the specified port is powered (i.e. outputing).
506      *
507      * @param portNbr a greater than zero port number
508      * @param on true if powered.
509      */
510     void setPortPower(int portNbr, boolean on) {
511         if (portNbr <= 0) {
512             throw new IllegalArgumentException("portNbr must be greater than 0");
513         }
514         sendCommand(String.format(CMD_PORT_POWER_FORMAT, portNbr, on ? "on" : "off"));
515     }
516
517     /**
518      * Refreshes whether the specified port is powered (i.e. outputing).
519      *
520      * @param portNbr a greater than zero port number
521      */
522     void refreshPortPower(int portNbr) {
523         if (portNbr <= 0) {
524             throw new IllegalArgumentException("portNbr must be greater than 0");
525         }
526         sendCommand(String.format(CMD_PORT_POWER_FORMAT, portNbr, "sta"));
527     }
528
529     /**
530      * Sets all the output ports to the specified input port.
531      *
532      * @param portNbr a greater than zero port number
533      */
534     void setPortAll(int portNbr) {
535         if (portNbr <= 0) {
536             throw new IllegalArgumentException("portNbr must be greater than 0");
537         }
538         sendCommand(String.format(CMD_PORT_ALL_FORMAT, portNbr));
539     }
540
541     /**
542      * Sets the input port number to the specified output port number.
543      *
544      * @param inPortNbr a greater than zero port number
545      * @param outPortNbr a greater than zero port number
546      */
547     void setPortSwitch(int inPortNbr, int outPortNbr) {
548         if (inPortNbr <= 0) {
549             throw new IllegalArgumentException("inPortNbr must be greater than 0");
550         }
551         if (outPortNbr <= 0) {
552             throw new IllegalArgumentException("outPortNbr must be greater than 0");
553         }
554         sendCommand(String.format(CMD_PORT_SWITCH_FORMAT, inPortNbr, outPortNbr));
555     }
556
557     /**
558      * Sets the hdmi port number to mirror the specified output port number.
559      *
560      * @param hdmiPortNbr a greater than zero port number
561      * @param outPortNbr a greater than zero port number
562      */
563     void setPortMirror(int hdmiPortNbr, int outPortNbr) {
564         if (hdmiPortNbr <= 0) {
565             throw new IllegalArgumentException("hdmiPortNbr must be greater than 0");
566         }
567         if (outPortNbr <= 0) {
568             throw new IllegalArgumentException("outPortNbr must be greater than 0");
569         }
570
571         if (capabilities.getHdmiPorts().contains(hdmiPortNbr)) {
572             sendCommand(String.format(CMD_PORT_MIRROR_FORMAT, hdmiPortNbr, outPortNbr));
573         } else {
574             logger.info("Trying to set port mirroring on a non-hdmi port: {}", hdmiPortNbr);
575         }
576     }
577
578     /**
579      * Disabled mirroring on the specified hdmi port number.
580      *
581      * @param hdmiPortNbr a greater than zero port number
582      * @param outPortNbr a greater than zero port number
583      */
584     void removePortMirror(int hdmiPortNbr) {
585         if (hdmiPortNbr <= 0) {
586             throw new IllegalArgumentException("hdmiPortNbr must be greater than 0");
587         }
588
589         if (capabilities.getHdmiPorts().contains(hdmiPortNbr)) {
590             sendCommand(String.format(CMD_PORT_UNMIRROR_FORMAT, hdmiPortNbr));
591         } else {
592             logger.info("Trying to remove port mirroring on a non-hdmi port: {}", hdmiPortNbr);
593         }
594     }
595
596     /**
597      * Sets the volume level on the specified audio port.
598      *
599      * @param portNbr a greater than zero port number
600      * @param level a volume level in decibels (must range from -79 to +15)
601      */
602     void setVolume(int portNbr, double level) {
603         if (portNbr <= 0) {
604             throw new IllegalArgumentException("portNbr must be greater than 0");
605         }
606         if (level < -79 || level > 15) {
607             throw new IllegalArgumentException("level must be between -79 to +15");
608         }
609         sendCommand(String.format(CMD_VOLUME_FORMAT, portNbr, level));
610     }
611
612     /**
613      * Refreshes the volume level for the given audio port.
614      *
615      * @param portNbr a greater than zero port number
616      */
617     void refreshVolumeStatus(int portNbr) {
618         if (portNbr <= 0) {
619             throw new IllegalArgumentException("portNbr must be greater than 0");
620         }
621         sendCommand(String.format(CMD_VOLUME_FORMAT, portNbr, "sta"));
622     }
623
624     /**
625      * Refreshes the specified hdmi port's mirroring status
626      *
627      * @param hdmiPortNbr a greater than zero hdmi port number
628      */
629     void refreshPortMirror(int hdmiPortNbr) {
630         if (hdmiPortNbr <= 0) {
631             throw new IllegalArgumentException("hdmiPortNbr must be greater than 0");
632         }
633         sendCommand(String.format(CMD_PORT_MIRROR_STATUS_FORMAT, hdmiPortNbr));
634     }
635
636     /**
637      * Mutes/Unmutes the specified audio port.
638      *
639      * @param portNbr a greater than zero port number
640      * @param mute true to mute, false to unmute
641      */
642     void setVolumeMute(int portNbr, boolean mute) {
643         if (portNbr <= 0) {
644             throw new IllegalArgumentException("portNbr must be greater than 0");
645         }
646         sendCommand(String.format(CMD_VOLUME_MUTE_FORMAT, portNbr, mute ? "on" : "off"));
647     }
648
649     /**
650      * Refreshes the volume mute for the given audio port.
651      *
652      * @param portNbr a greater than zero port number
653      */
654     void refreshVolumeMute(int portNbr) {
655         if (portNbr <= 0) {
656             throw new IllegalArgumentException("portNbr must be greater than 0");
657         }
658         sendCommand(String.format(CMD_VOLUME_MUTE_FORMAT, portNbr, "sta"));
659     }
660
661     /**
662      * Turn on/off the front panel IR.
663      *
664      * @param on true for on, false otherwise
665      */
666     void setIrOn(boolean on) {
667         sendCommand(on ? CMD_IRON : CMD_IROFF);
668     }
669
670     /**
671      * Refreshes the input port setting on the specified output port.
672      *
673      * @param portNbr a greater than zero port number
674      */
675     void refreshPortStatus(int portNbr) {
676         if (portNbr <= 0) {
677             throw new IllegalArgumentException("portNbr must be greater than 0");
678         }
679         sendCommand(String.format(CMD_PORT_STATUS_FORMAT, portNbr));
680     }
681
682     /**
683      * Refreshes all of the input port settings for all of the output ports.
684      */
685     private void refreshAllPortStatuses() {
686         sendCommand(CMD_PORT_STATUS);
687     }
688
689     /**
690      * Saves the current Input/Output scheme to the specified preset number.
691      *
692      * @param presetNbr a greater than 0 preset number
693      */
694     void saveIoSettings(int presetNbr) {
695         if (presetNbr <= 0) {
696             throw new IllegalArgumentException("presetNbr must be greater than 0");
697         }
698         sendCommand(String.format(CMD_SAVEIO_FORMAT, presetNbr));
699     }
700
701     /**
702      * Recalls the Input/Output scheme for the specified preset number.
703      *
704      * @param presetNbr a greater than 0 preset number
705      */
706     void recallIoSettings(int presetNbr) {
707         if (presetNbr <= 0) {
708             throw new IllegalArgumentException("presetNbr must be greater than 0");
709         }
710         sendCommand(String.format(CMD_RECALLIO_FORMAT, presetNbr));
711     }
712
713     /**
714      * Clears the Input/Output scheme for the specified preset number.
715      *
716      * @param presetNbr a greater than 0 preset number
717      */
718     void clearIoSettings(int presetNbr) {
719         if (presetNbr <= 0) {
720             throw new IllegalArgumentException("presetNbr must be greater than 0");
721         }
722         sendCommand(String.format(CMD_CLEARIO_FORMAT, presetNbr));
723     }
724
725     /**
726      * Resets the matrix back to defaults.
727      */
728     void resetMatrix() {
729         sendCommand(CMD_MATRIX_RESET);
730     }
731
732     /**
733      * Sends the command and puts the thing into {@link ThingStatus#OFFLINE} if an IOException occurs
734      *
735      * @param command a non-null, non-empty command to send
736      */
737     private void sendCommand(String command) {
738         if (command == null) {
739             throw new IllegalArgumentException("command cannot be null");
740         }
741         if (command.trim().length() == 0) {
742             throw new IllegalArgumentException("command cannot be empty");
743         }
744         try {
745             session.sendCommand(command);
746         } catch (IOException e) {
747             callback.statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
748                     "Exception occurred sending to Atlona: " + e);
749         }
750     }
751
752     /**
753      * Handles the switch power response. The first matching group should be "on" or "off"
754      *
755      * @param m the non-null {@link Matcher} that matched the response
756      * @param resp the possibly null, possibly empty actual response
757      */
758     private void handlePowerResponse(Matcher m, String resp) {
759         if (m == null) {
760             throw new IllegalArgumentException("m (matcher) cannot be null");
761         }
762         if (m.groupCount() == 1) {
763             switch (m.group(1)) {
764                 case "ON":
765                     callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_PRIMARY,
766                             AtlonaPro3Constants.CHANNEL_POWER), OnOffType.ON);
767                     break;
768                 case "OFF":
769                     callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_PRIMARY,
770                             AtlonaPro3Constants.CHANNEL_POWER), OnOffType.OFF);
771                     break;
772                 default:
773                     logger.warn("Invalid power response: '{}'", resp);
774             }
775         } else {
776             logger.warn("Invalid power response: '{}'", resp);
777         }
778     }
779
780     /**
781      * Handles the version (firmware) response. The first matching group should be the version
782      *
783      * @param m the non-null {@link Matcher} that matched the response
784      * @param resp the possibly null, possibly empty actual response
785      */
786     private void handleVersionResponse(Matcher m, String resp) {
787         if (m == null) {
788             throw new IllegalArgumentException("m (matcher) cannot be null");
789         }
790         if (m.groupCount() == 1) {
791             version = m.group(1);
792             callback.setProperty(AtlonaPro3Constants.PROPERTY_VERSION, version);
793         } else {
794             logger.warn("Invalid version response: '{}'", resp);
795         }
796     }
797
798     /**
799      * Handles the type (model) response. The first matching group should be the type.
800      *
801      * @param m the non-null {@link Matcher} that matched the response
802      * @param resp the possibly null, possibly empty actual response
803      */
804     private void handleTypeResponse(Matcher m, String resp) {
805         if (m == null) {
806             throw new IllegalArgumentException("m (matcher) cannot be null");
807         }
808         if (m.groupCount() == 1) {
809             modelType = resp;
810             callback.setProperty(AtlonaPro3Constants.PROPERTY_TYPE, modelType);
811         } else {
812             logger.warn("Invalid Type response: '{}'", resp);
813         }
814     }
815
816     /**
817      * Handles the panel lock response. The response is only on or off.
818      *
819      * @param resp the possibly null, possibly empty actual response
820      */
821     private void handlePanelLockResponse(String resp) {
822         callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_PRIMARY,
823                 AtlonaPro3Constants.CHANNEL_PANELLOCK), RSP_LOCK.equals(resp) ? OnOffType.ON : OnOffType.OFF);
824     }
825
826     /**
827      * Handles the port power response. The first two groups should be the port nbr and either "on" or "off"
828      *
829      * @param m the non-null {@link Matcher} that matched the response
830      * @param resp the possibly null, possibly empty actual response
831      */
832     private void handlePortPowerResponse(Matcher m, String resp) {
833         if (m == null) {
834             throw new IllegalArgumentException("m (matcher) cannot be null");
835         }
836         if (m.groupCount() == 2) {
837             try {
838                 int portNbr = Integer.parseInt(m.group(1));
839                 switch (m.group(2)) {
840                     case "on":
841                         callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_PORT,
842                                 portNbr, AtlonaPro3Constants.CHANNEL_PORTPOWER), OnOffType.ON);
843                         break;
844                     case "off":
845                         callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_PORT,
846                                 portNbr, AtlonaPro3Constants.CHANNEL_PORTPOWER), OnOffType.OFF);
847                         break;
848                     default:
849                         logger.warn("Invalid port power response: '{}'", resp);
850                 }
851             } catch (NumberFormatException e) {
852                 logger.warn("Invalid port power (can't parse number): '{}'", resp);
853             }
854         } else {
855             logger.warn("Invalid port power response: '{}'", resp);
856         }
857     }
858
859     /**
860      * Handles the port all response. Simply calls {@link #refreshAllPortStatuses()}
861      *
862      * @param resp ignored
863      */
864     private void handlePortAllResponse(String resp) {
865         refreshAllPortStatuses();
866     }
867
868     /**
869      * Handles the port output response. This matcher can have multiple groups separated by commas. Find each group and
870      * that group should have two groups within - an input port nbr and an output port number
871      *
872      * @param m the non-null {@link Matcher} that matched the response
873      * @param resp the possibly null, possibly empty actual response
874      */
875     private void handlePortOutputResponse(Matcher m, String resp) {
876         if (m == null) {
877             throw new IllegalArgumentException("m (matcher) cannot be null");
878         }
879
880         m.reset();
881         while (m.find()) {
882             try {
883                 int inPort = Integer.parseInt(m.group(1));
884                 int outPort = Integer.parseInt(m.group(2));
885
886                 callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_PORT, outPort,
887                         AtlonaPro3Constants.CHANNEL_PORTOUTPUT), new DecimalType(inPort));
888             } catch (NumberFormatException e) {
889                 logger.warn("Invalid port output response (can't parse number): '{}'", resp);
890             }
891         }
892     }
893
894     /**
895      * Handles the mirror response. The matcher should have two groups - an hdmi port number and an output port number.
896      *
897      * @param m the non-null {@link Matcher} that matched the response
898      * @param resp the possibly null, possibly empty actual response
899      */
900     private void handleMirrorResponse(Matcher m, String resp) {
901         if (m == null) {
902             throw new IllegalArgumentException("m (matcher) cannot be null");
903         }
904         if (m.groupCount() == 3) {
905             try {
906                 int hdmiPortNbr = Integer.parseInt(m.group(1));
907
908                 // could be "off" (if mirror off), "on"/"Out" (with 3rd group representing out)
909                 String oper = StringUtils.trimToEmpty(m.group(2)).toLowerCase();
910
911                 if (oper.equals("off")) {
912                     callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_MIRROR,
913                             hdmiPortNbr, AtlonaPro3Constants.CHANNEL_PORTMIRRORENABLED), OnOffType.OFF);
914                 } else {
915                     int outPortNbr = Integer.parseInt(m.group(3));
916                     callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_MIRROR,
917                             hdmiPortNbr, AtlonaPro3Constants.CHANNEL_PORTMIRROR), new DecimalType(outPortNbr));
918                     callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_MIRROR,
919                             hdmiPortNbr, AtlonaPro3Constants.CHANNEL_PORTMIRRORENABLED), OnOffType.ON);
920                 }
921             } catch (NumberFormatException e) {
922                 logger.warn("Invalid mirror response (can't parse number): '{}'", resp);
923             }
924         } else {
925             logger.warn("Invalid mirror response: '{}'", resp);
926         }
927     }
928
929     /**
930      * Handles the unmirror response. The first group should contain the hdmi port number
931      *
932      * @param m the non-null {@link Matcher} that matched the response
933      * @param resp the possibly null, possibly empty actual response
934      */
935     private void handleUnMirrorResponse(Matcher m, String resp) {
936         if (m == null) {
937             throw new IllegalArgumentException("m (matcher) cannot be null");
938         }
939         if (m.groupCount() == 1) {
940             try {
941                 int hdmiPortNbr = Integer.parseInt(m.group(1));
942                 callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_MIRROR, hdmiPortNbr,
943                         AtlonaPro3Constants.CHANNEL_PORTMIRROR), new DecimalType(0));
944             } catch (NumberFormatException e) {
945                 logger.warn("Invalid unmirror response (can't parse number): '{}'", resp);
946             }
947         } else {
948             logger.warn("Invalid unmirror response: '{}'", resp);
949         }
950     }
951
952     /**
953      * Handles the volume response. The first two group should be the audio port number and the level
954      *
955      * @param m the non-null {@link Matcher} that matched the response
956      * @param resp the possibly null, possibly empty actual response
957      */
958     private void handleVolumeResponse(Matcher m, String resp) {
959         if (m == null) {
960             throw new IllegalArgumentException("m (matcher) cannot be null");
961         }
962         if (m.groupCount() == 2) {
963             try {
964                 int portNbr = Integer.parseInt(m.group(1));
965                 double level = Double.parseDouble(m.group(2));
966                 callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_VOLUME, portNbr,
967                         AtlonaPro3Constants.CHANNEL_VOLUME), new DecimalType(level));
968             } catch (NumberFormatException e) {
969                 logger.warn("Invalid volume response (can't parse number): '{}'", resp);
970             }
971         } else {
972             logger.warn("Invalid volume response: '{}'", resp);
973         }
974     }
975
976     /**
977      * Handles the volume mute response. The first two group should be the audio port number and either "on" or "off
978      *
979      * @param m the non-null {@link Matcher} that matched the response
980      * @param resp the possibly null, possibly empty actual response
981      */
982     private void handleVolumeMuteResponse(Matcher m, String resp) {
983         if (m == null) {
984             throw new IllegalArgumentException("m (matcher) cannot be null");
985         }
986         if (m.groupCount() == 2) {
987             try {
988                 int portNbr = Integer.parseInt(m.group(1));
989                 switch (m.group(2)) {
990                     case "on":
991                         callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_VOLUME,
992                                 portNbr, AtlonaPro3Constants.CHANNEL_VOLUME_MUTE), OnOffType.ON);
993                         break;
994                     case "off":
995                         callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_VOLUME,
996                                 portNbr, AtlonaPro3Constants.CHANNEL_VOLUME_MUTE), OnOffType.OFF);
997                         break;
998                     default:
999                         logger.warn("Invalid volume mute response: '{}'", resp);
1000                 }
1001             } catch (NumberFormatException e) {
1002                 logger.warn("Invalid volume mute (can't parse number): '{}'", resp);
1003             }
1004         } else {
1005             logger.warn("Invalid volume mute response: '{}'", resp);
1006         }
1007     }
1008
1009     /**
1010      * Handles the IR Response. The response is either on or off
1011      *
1012      * @param resp the possibly null, possibly empty actual response
1013      */
1014     private void handleIrLockResponse(String resp) {
1015         callback.stateChanged(AtlonaPro3Utilities.createChannelID(AtlonaPro3Constants.GROUP_PRIMARY,
1016                 AtlonaPro3Constants.CHANNEL_IRENABLE), RSP_IRON.equals(resp) ? OnOffType.ON : OnOffType.OFF);
1017     }
1018
1019     /**
1020      * Handles the Save IO Response. Should have one group specifying the preset number
1021      *
1022      * @param m the non-null {@link Matcher} that matched the response
1023      * @param resp the possibly null, possibly empty actual response
1024      */
1025     private void handleSaveIoResponse(Matcher m, String resp) {
1026         // nothing to handle
1027     }
1028
1029     /**
1030      * Handles the Recall IO Response. Should have one group specifying the preset number. After updating the Recall
1031      * State, we refresh all the ports via {@link #refreshAllPortStatuses()}.
1032      *
1033      * @param m the non-null {@link Matcher} that matched the response
1034      * @param resp the possibly null, possibly empty actual response
1035      */
1036     private void handleRecallIoResponse(Matcher m, String resp) {
1037         refreshAllPortStatuses();
1038     }
1039
1040     /**
1041      * Handles the Clear IO Response. Should have one group specifying the preset number.
1042      *
1043      * @param m the non-null {@link Matcher} that matched the response
1044      * @param resp the possibly null, possibly empty actual response
1045      */
1046     private void handleClearIoResponse(Matcher m, String resp) {
1047         // nothing to handle
1048     }
1049
1050     /**
1051      * Handles the broadcast Response. Should have one group specifying the status.
1052      *
1053      * @param m the non-null {@link Matcher} that matched the response
1054      * @param resp the possibly null, possibly empty actual response
1055      */
1056     private void handleBroadcastResponse(Matcher m, String resp) {
1057         // nothing to handle
1058     }
1059
1060     /**
1061      * Handles the matrix reset response. The matrix will go offline immediately on a reset.
1062      *
1063      * @param resp the possibly null, possibly empty actual response
1064      */
1065     private void handleMatrixResetResponse(String resp) {
1066         if (RSP_MATRIX_RESET.equals(resp)) {
1067             callback.statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1068                     "System is rebooting due to matrix reset");
1069         }
1070     }
1071
1072     /**
1073      * Handles a command failure - we simply log the response as an error
1074      *
1075      * @param resp the possibly null, possibly empty actual response
1076      */
1077     private void handleCommandFailure(String resp) {
1078         logger.debug("{}", resp);
1079     }
1080
1081     /**
1082      * This callback is our normal response callback. Should be set into the {@link SocketSession} after the login
1083      * process to handle normal responses.
1084      *
1085      * @author Tim Roberts
1086      *
1087      */
1088     private class NormalResponseCallback implements SocketSessionListener {
1089
1090         @Override
1091         public void responseReceived(String response) {
1092             if (response == null || response == "") {
1093                 return;
1094             }
1095
1096             if (RSP_PING.equals(response)) {
1097                 // ignore
1098                 return;
1099             }
1100
1101             Matcher m;
1102
1103             m = portStatusPattern.matcher(response);
1104             if (m.find()) {
1105                 handlePortOutputResponse(m, response);
1106                 return;
1107             }
1108
1109             m = powerStatusPattern.matcher(response);
1110             if (m.matches()) {
1111                 handlePowerResponse(m, response);
1112                 return;
1113             }
1114
1115             m = versionPattern.matcher(response);
1116             if (m.matches()) {
1117                 handleVersionResponse(m, response);
1118                 return;
1119             }
1120
1121             m = versionHdPattern.matcher(response);
1122             if (m.matches()) {
1123                 handleVersionResponse(m, response);
1124                 return;
1125             }
1126
1127             m = typePattern.matcher(response);
1128             if (m.matches()) {
1129                 handleTypeResponse(m, response);
1130                 return;
1131             }
1132
1133             m = typeHdPattern.matcher(response);
1134             if (m.matches()) {
1135                 handleTypeResponse(m, response);
1136                 return;
1137             }
1138
1139             m = portPowerPattern.matcher(response);
1140             if (m.matches()) {
1141                 handlePortPowerResponse(m, response);
1142                 return;
1143             }
1144
1145             m = volumePattern.matcher(response);
1146             if (m.matches()) {
1147                 handleVolumeResponse(m, response);
1148                 return;
1149             }
1150
1151             m = volumeMutePattern.matcher(response);
1152             if (m.matches()) {
1153                 handleVolumeMuteResponse(m, response);
1154                 return;
1155             }
1156
1157             m = portAllPattern.matcher(response);
1158             if (m.matches()) {
1159                 handlePortAllResponse(response);
1160                 return;
1161             }
1162
1163             m = portMirrorPattern.matcher(response);
1164             if (m.matches()) {
1165                 handleMirrorResponse(m, response);
1166                 return;
1167             }
1168
1169             m = portUnmirrorPattern.matcher(response);
1170             if (m.matches()) {
1171                 handleUnMirrorResponse(m, response);
1172                 return;
1173             }
1174
1175             m = saveIoPattern.matcher(response);
1176             if (m.matches()) {
1177                 handleSaveIoResponse(m, response);
1178                 return;
1179             }
1180
1181             m = recallIoPattern.matcher(response);
1182             if (m.matches()) {
1183                 handleRecallIoResponse(m, response);
1184                 return;
1185             }
1186
1187             m = clearIoPattern.matcher(response);
1188             if (m.matches()) {
1189                 handleClearIoResponse(m, response);
1190                 return;
1191             }
1192
1193             m = broadCastPattern.matcher(response);
1194             if (m.matches()) {
1195                 handleBroadcastResponse(m, response);
1196                 return;
1197             }
1198
1199             if (RSP_IRON.equals(response) || RSP_IROFF.equals(response)) {
1200                 handleIrLockResponse(response);
1201                 return;
1202             }
1203
1204             if (RSP_ALL.equals(response)) {
1205                 handlePortAllResponse(response);
1206                 return;
1207             }
1208
1209             if (RSP_LOCK.equals(response) || RSP_UNLOCK.equals(response)) {
1210                 handlePanelLockResponse(response);
1211                 return;
1212             }
1213
1214             if (RSP_MATRIX_RESET.equals(response)) {
1215                 handleMatrixResetResponse(response);
1216                 return;
1217             }
1218
1219             if (response.startsWith(RSP_FAILED)) {
1220                 handleCommandFailure(response);
1221                 return;
1222             }
1223
1224             logger.debug("Unhandled response: {}", response);
1225         }
1226
1227         @Override
1228         public void responseException(Exception e) {
1229             callback.statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1230                     "Exception occurred reading from Atlona: " + e);
1231         }
1232     }
1233
1234     /**
1235      * Special callback used during the login process to not dispatch the responses to this class but rather give them
1236      * back at each call to {@link NoDispatchingCallback#getResponse()}
1237      *
1238      * @author Tim Roberts
1239      *
1240      */
1241     private class NoDispatchingCallback implements SocketSessionListener {
1242
1243         /**
1244          * Cache of responses that have occurred
1245          */
1246         private BlockingQueue<Object> responses = new ArrayBlockingQueue<>(5);
1247
1248         /**
1249          * Will return the next response from {@link #responses}. If the response is an exception, that exception will
1250          * be thrown instead.
1251          *
1252          * @return a non-null, possibly empty response
1253          * @throws Exception an exception if one occurred during reading
1254          */
1255         String getResponse() throws Exception {
1256             final Object lastResponse = responses.poll(5, TimeUnit.SECONDS);
1257             if (lastResponse instanceof String) {
1258                 return (String) lastResponse;
1259             } else if (lastResponse instanceof Exception) {
1260                 throw (Exception) lastResponse;
1261             } else if (lastResponse == null) {
1262                 throw new Exception("Didn't receive response in time");
1263             } else {
1264                 return lastResponse.toString();
1265             }
1266         }
1267
1268         @Override
1269         public void responseReceived(String response) {
1270             try {
1271                 responses.put(response);
1272             } catch (InterruptedException e) {
1273             }
1274         }
1275
1276         @Override
1277         public void responseException(Exception e) {
1278             try {
1279                 responses.put(e);
1280             } catch (InterruptedException e1) {
1281             }
1282         }
1283     }
1284 }