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