]> git.basschouten.com Git - openhab-addons.git/blob
9fc2c5c08e832fafe6de49d11422f2a4326a95ca
[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.lutron.internal.grxprg;
14
15 import java.io.IOException;
16 import java.time.ZoneId;
17 import java.time.ZonedDateTime;
18 import java.util.Calendar;
19 import java.util.HashMap;
20 import java.util.Map;
21 import java.util.concurrent.ArrayBlockingQueue;
22 import java.util.concurrent.BlockingQueue;
23 import java.util.concurrent.TimeUnit;
24 import java.util.regex.Matcher;
25 import java.util.regex.Pattern;
26
27 import org.apache.commons.lang.NullArgumentException;
28 import org.openhab.core.library.types.DateTimeType;
29 import org.openhab.core.library.types.DecimalType;
30 import org.openhab.core.library.types.OnOffType;
31 import org.openhab.core.library.types.PercentType;
32 import org.openhab.core.library.types.StringType;
33 import org.openhab.core.thing.ThingStatus;
34 import org.openhab.core.thing.ThingStatusDetail;
35 import org.slf4j.Logger;
36 import org.slf4j.LoggerFactory;
37
38 /**
39  * This is the protocol handler for the GRX-PRG/GRX-CI-PRG. This handler will issue the protocol commands and will
40  * process the responses from the interface. This handler was written to respond to any response that can be sent from
41  * the TCP/IP session (either in response to our own commands or in response to external events [other TCP/IP sessions,
42  * web GUI, etc]).
43  *
44  * @author Tim Roberts - Initial contribution
45  *
46  */
47 class PrgProtocolHandler {
48     private Logger logger = LoggerFactory.getLogger(PrgProtocolHandler.class);
49
50     /**
51      * The {@link SocketSession} used by this protocol handler
52      */
53     private final SocketSession session;
54
55     /**
56      * The {@link PrgBridgeHandler} to call back to update status and state
57      */
58     private final PrgHandlerCallback phCallback;
59
60     // ------------------------------------------------------------------------------------------------
61     // The following are the various command formats specified by the
62     // http://www.lutron.com/TechnicalDocumentLibrary/RS232ProtocolCommandSet.040196d.pdf
63     private static final String CMD_SCENE = "A";
64     private static final String CMD_SCENELOCK = "SL";
65     private static final String CMD_SCENESTATUS = "G";
66     private static final String CMD_SCENESEQ = "SQ";
67     private static final String CMD_ZONELOCK = "ZL";
68     private static final String CMD_ZONELOWER = "D";
69     private static final String CMD_ZONELOWERSTOP = "E";
70     private static final String CMD_ZONERAISE = "B";
71     private static final String CMD_ZONERAISESTOP = "C";
72     private static final String CMD_ZONEINTENSITY = "szi";
73     private static final String CMD_ZONEINTENSITYSTATUS = "rzi";
74     private static final String CMD_SETTIME = "ST";
75     private static final String CMD_READTIME = "RT";
76     private static final String CMD_SELECTSCHEDULE = "SS";
77     private static final String CMD_REPORTSCHEDULE = "RS";
78     private static final String CMD_SUNRISESUNSET = "RA";
79     private static final String CMD_SUPERSEQUENCESTART = "QS";
80     private static final String CMD_SUPERSEQUENCEPAUSE = "QP";
81     private static final String CMD_SUPERSEQUENCERESUME = "QC";
82     private static final String CMD_SUPERSEQUENCESTATUS = "Q?";
83
84     // ------------------------------------------------------------------------------------------------
85     // The following are the various responses specified by the
86     // http://www.lutron.com/TechnicalDocumentLibrary/RS232ProtocolCommandSet.040196d.pdf
87     private static final Pattern RSP_FAILED = Pattern.compile("^~ERROR # (\\d+) (\\d+) OK");
88     private static final Pattern RSP_OK = Pattern.compile("^~(\\d+) OK");
89     private static final Pattern RSP_RESETTING = Pattern.compile("^~:Reseting Device... (\\d+) OK");
90     private static final Pattern RSP_RMU = Pattern
91             .compile("^~:mu (\\d) (\\d+) (\\w+) (\\w+) (\\w+) (\\w+) (\\w+) (\\w+) (\\w+)");
92     private static final Pattern RSP_SCENESTATUS = Pattern.compile("^~?:ss (\\w{8,8})( (\\d+) OK)?");
93     private static final Pattern RSP_ZONEINTENSITY = Pattern.compile(
94             "^~:zi (\\d) (\\w{1,3}) (\\w{1,3}) (\\w{1,3}) (\\w{1,3}) (\\w{1,3}) (\\w{1,3}) (\\w{1,3}) (\\w{1,3}) (\\d+) OK");
95     private static final Pattern RSP_REPORTIME = Pattern
96             .compile("^~:rt (\\d{1,2}) (\\d{1,2}) (\\d{1,2}) (\\d{1,2}) (\\d{1,2}) (\\d) (\\d+) OK");
97     private static final Pattern RSP_REPORTSCHEDULE = Pattern.compile("^~:rs (\\d) (\\d+) OK");
98     private static final Pattern RSP_SUNRISESUNSET = Pattern
99             .compile("^~:ra (\\d{1,3}) (\\d{1,3}) (\\d{1,3}) (\\d{1,3}) (\\d+) OK");
100     private static final Pattern RSP_SUPERSEQUENCESTATUS = Pattern
101             .compile("^~:s\\? (\\w) (\\d+) (\\d{1,2}) (\\d{1,2}) (\\d+) OK");
102     private static final Pattern RSP_BUTTON = Pattern.compile("^[^~:].*");
103     private static final String RSP_CONNECTION_ESTABLISHED = "connection established";
104
105     /**
106      * A lookup between a 0-100 percentage and corresponding hex value. Note: this specifically matches the liason
107      * software setup
108      */
109     private static final Map<Integer, String> INTENSITY_MAP = new HashMap<>();
110
111     /**
112      * The reverse lookup for the {{@link #INTENSITY_MAP}
113      */
114     private static final Map<String, Integer> REVERSE_INTENSITY_MAP = new HashMap<>();
115
116     /**
117      * A lookup between returned shade hex intensity to corresponding shade values
118      */
119     private static final Map<String, Integer> SHADE_INTENSITY_MAP = new HashMap<>();
120
121     /**
122      * Cache of current zone intensities
123      */
124     private final int[] zoneIntensities = new int[8];
125
126     /**
127      * Static method to setup the intensity lookup maps
128      */
129     static {
130         INTENSITY_MAP.put(0, "0");
131         INTENSITY_MAP.put(1, "2");
132         INTENSITY_MAP.put(2, "3");
133         INTENSITY_MAP.put(3, "4");
134         INTENSITY_MAP.put(4, "6");
135         INTENSITY_MAP.put(5, "7");
136         INTENSITY_MAP.put(6, "8");
137         INTENSITY_MAP.put(7, "9");
138         INTENSITY_MAP.put(8, "B");
139         INTENSITY_MAP.put(9, "C");
140         INTENSITY_MAP.put(10, "D");
141         INTENSITY_MAP.put(11, "F");
142         INTENSITY_MAP.put(12, "10");
143         INTENSITY_MAP.put(13, "11");
144         INTENSITY_MAP.put(14, "12");
145         INTENSITY_MAP.put(15, "14");
146         INTENSITY_MAP.put(16, "15");
147         INTENSITY_MAP.put(17, "16");
148         INTENSITY_MAP.put(18, "18");
149         INTENSITY_MAP.put(19, "19");
150         INTENSITY_MAP.put(20, "1A");
151         INTENSITY_MAP.put(21, "1B");
152         INTENSITY_MAP.put(22, "1D");
153         INTENSITY_MAP.put(23, "1E");
154         INTENSITY_MAP.put(24, "1F");
155         INTENSITY_MAP.put(25, "20");
156         INTENSITY_MAP.put(26, "22");
157         INTENSITY_MAP.put(27, "23");
158         INTENSITY_MAP.put(28, "24");
159         INTENSITY_MAP.put(29, "26");
160         INTENSITY_MAP.put(30, "27");
161         INTENSITY_MAP.put(31, "28");
162         INTENSITY_MAP.put(32, "29");
163         INTENSITY_MAP.put(33, "2B");
164         INTENSITY_MAP.put(34, "2C");
165         INTENSITY_MAP.put(35, "2D");
166         INTENSITY_MAP.put(36, "2F");
167         INTENSITY_MAP.put(37, "30");
168         INTENSITY_MAP.put(38, "31");
169         INTENSITY_MAP.put(39, "32");
170         INTENSITY_MAP.put(40, "34");
171         INTENSITY_MAP.put(41, "35");
172         INTENSITY_MAP.put(42, "36");
173         INTENSITY_MAP.put(43, "38");
174         INTENSITY_MAP.put(44, "39");
175         INTENSITY_MAP.put(45, "3A");
176         INTENSITY_MAP.put(46, "3B");
177         INTENSITY_MAP.put(47, "3D");
178         INTENSITY_MAP.put(48, "3E");
179         INTENSITY_MAP.put(49, "3F");
180         INTENSITY_MAP.put(50, "40");
181         INTENSITY_MAP.put(51, "42");
182         INTENSITY_MAP.put(52, "43");
183         INTENSITY_MAP.put(53, "44");
184         INTENSITY_MAP.put(54, "46");
185         INTENSITY_MAP.put(55, "47");
186         INTENSITY_MAP.put(56, "48");
187         INTENSITY_MAP.put(57, "49");
188         INTENSITY_MAP.put(58, "4B");
189         INTENSITY_MAP.put(59, "4C");
190         INTENSITY_MAP.put(60, "4D");
191         INTENSITY_MAP.put(61, "4F");
192         INTENSITY_MAP.put(62, "50");
193         INTENSITY_MAP.put(63, "51");
194         INTENSITY_MAP.put(64, "52");
195         INTENSITY_MAP.put(65, "54");
196         INTENSITY_MAP.put(66, "55");
197         INTENSITY_MAP.put(67, "56");
198         INTENSITY_MAP.put(68, "58");
199         INTENSITY_MAP.put(69, "59");
200         INTENSITY_MAP.put(70, "5A");
201         INTENSITY_MAP.put(71, "5B");
202         INTENSITY_MAP.put(72, "5D");
203         INTENSITY_MAP.put(73, "5E");
204         INTENSITY_MAP.put(74, "5F");
205         INTENSITY_MAP.put(75, "60");
206         INTENSITY_MAP.put(76, "62");
207         INTENSITY_MAP.put(77, "63");
208         INTENSITY_MAP.put(78, "64");
209         INTENSITY_MAP.put(79, "66");
210         INTENSITY_MAP.put(80, "67");
211         INTENSITY_MAP.put(81, "68");
212         INTENSITY_MAP.put(82, "69");
213         INTENSITY_MAP.put(83, "6B");
214         INTENSITY_MAP.put(84, "6C");
215         INTENSITY_MAP.put(85, "6D");
216         INTENSITY_MAP.put(86, "6F");
217         INTENSITY_MAP.put(87, "70");
218         INTENSITY_MAP.put(88, "71");
219         INTENSITY_MAP.put(89, "72");
220         INTENSITY_MAP.put(90, "74");
221         INTENSITY_MAP.put(91, "75");
222         INTENSITY_MAP.put(92, "76");
223         INTENSITY_MAP.put(93, "78");
224         INTENSITY_MAP.put(94, "79");
225         INTENSITY_MAP.put(95, "7A");
226         INTENSITY_MAP.put(96, "7B");
227         INTENSITY_MAP.put(97, "7D");
228         INTENSITY_MAP.put(98, "7E");
229         INTENSITY_MAP.put(99, "7F");
230         INTENSITY_MAP.put(100, "7F");
231
232         for (int key : INTENSITY_MAP.keySet()) {
233             String value = INTENSITY_MAP.get(key);
234             REVERSE_INTENSITY_MAP.put(value, key);
235         }
236
237         SHADE_INTENSITY_MAP.put("0", 0);
238         SHADE_INTENSITY_MAP.put("5E", 0);
239         SHADE_INTENSITY_MAP.put("15", 1);
240         SHADE_INTENSITY_MAP.put("2D", 2);
241         SHADE_INTENSITY_MAP.put("71", 3);
242         SHADE_INTENSITY_MAP.put("72", 4);
243         SHADE_INTENSITY_MAP.put("73", 5);
244         SHADE_INTENSITY_MAP.put("5F", 1);
245         SHADE_INTENSITY_MAP.put("60", 2);
246         SHADE_INTENSITY_MAP.put("61", 3);
247         SHADE_INTENSITY_MAP.put("62", 4);
248         SHADE_INTENSITY_MAP.put("63", 5);
249     }
250
251     /**
252      * Lookup of valid scene numbers (H is also sometimes returned - no idea what it is however)
253      */
254     private static final String VALID_SCENES = "0123456789ABCDEFG";
255
256     /**
257      * Constructs the protocol handler from given parameters
258      *
259      * @param session a non-null {@link SocketSession} (may be connected or disconnected)
260      * @param config a non-null {@link PrgHandlerCallback}
261      */
262     PrgProtocolHandler(SocketSession session, PrgHandlerCallback callback) {
263         if (session == null) {
264             throw new IllegalArgumentException("session cannot be null");
265         }
266
267         if (callback == null) {
268             throw new IllegalArgumentException("callback cannot be null");
269         }
270
271         this.session = session;
272         this.phCallback = callback;
273     }
274
275     /**
276      * Attempts to log into the interface.
277      *
278      * @return a null if logged in successfully. Non-null if an exception occurred.
279      * @throws IOException an IO exception occurred during login
280      */
281     String login(String username) throws Exception {
282         logger.info("Logging into the PRG interface");
283         final NoDispatchingCallback callback = new NoDispatchingCallback();
284         session.setCallback(callback);
285
286         String response = callback.getResponse();
287         if (response.equals("login")) {
288             session.sendCommand(username);
289         } else {
290             return "Protocol violation - wasn't initially a command failure or login prompt: " + response;
291         }
292
293         // We should have received back a connection established response
294         response = callback.getResponse();
295
296         // Burn the empty response if we got one (
297         if (response.equals("")) {
298             response = callback.getResponse();
299         }
300
301         if (RSP_CONNECTION_ESTABLISHED.equals(response)) {
302             postLogin();
303             return null;
304         } else {
305             return "login failed";
306         }
307     }
308
309     /**
310      * Post successful login stuff - mark us online and refresh from the switch
311      *
312      * @throws IOException
313      */
314     private void postLogin() throws IOException {
315         logger.info("PRG interface now connected");
316         session.setCallback(new NormalResponseCallback());
317         phCallback.statusChanged(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
318     }
319
320     /**
321      * Refreshes the state of the specified control unit
322      *
323      * @param controlUnit the control unit to refresh
324      */
325     void refreshState(int controlUnit) {
326         logger.debug("Refreshing control unit ({}) state", controlUnit);
327         refreshScene();
328         refreshTime();
329         refreshSchedule();
330         refreshSunriseSunset();
331         reportSuperSequenceStatus();
332
333         // The RMU would return the zone lock, scene lock and scene seq state
334         // Unfortunately, if any of those are true - the PRG interface locks up
335         // the response until turned off - so comment out
336
337         // Get the current state of the zone/scene lock
338         // sendCommand("spm");
339         // sendCommand("rmu " + controlUnit);
340         // sendCommand("epm");
341
342         refreshZoneIntensity(controlUnit);
343     }
344
345     /**
346      * Validate the control unit parameter
347      *
348      * @param controlUnit a control unit between 1-8
349      * @throws IllegalArgumentException if controlUnit is < 0 or > 8
350      */
351     private void validateControlUnit(int controlUnit) {
352         if (controlUnit < 1 || controlUnit > 8) {
353             throw new IllegalArgumentException("Invalid control unit (must be between 1 and 8): " + controlUnit);
354         }
355     }
356
357     /**
358      * Validates the scene and converts it to the corresponding hex value
359      *
360      * @param scene a scene between 0 and 16
361      * @return the valid hex value of the scene
362      * @throws IllegalArgumentException if scene is < 0 or > 16
363      */
364     private char convertScene(int scene) {
365         if (scene < 0 || scene > VALID_SCENES.length()) {
366             throw new IllegalArgumentException(
367                     "Invalid scene (must be between 0 and " + VALID_SCENES.length() + "): " + scene);
368         }
369         return VALID_SCENES.charAt(scene);
370     }
371
372     /**
373      * Validates the zone
374      *
375      * @param zone the zone to validate
376      * @throws IllegalArgumentException if zone < 1 or > 8
377      */
378     private void validateZone(int zone) {
379         if (zone < 1 || zone > 8) {
380             throw new IllegalArgumentException("Invalid zone (must be between 1 and 8): " + zone);
381         }
382     }
383
384     /**
385      * Validates the fade and converts it to hex
386      *
387      * @param fade the fade
388      * @return a valid fade value
389      * @throws IllegalArgumentException if fade < 0 or > 120
390      */
391     private String convertFade(int fade) {
392         if (fade < 0 || fade > 120) {
393             throw new IllegalArgumentException("Invalid fade (must be between 1 and 120): " + fade);
394         }
395         if (fade > 59) {
396             fade = (fade / 60) + 59;
397         }
398         return Integer.toHexString(fade).toUpperCase();
399     }
400
401     /**
402      * Validates a zone intensity and returns the hex corresponding value (handles shade intensity zones as well)
403      *
404      * @param controlUnit the control unit
405      * @param zone the zone
406      * @param intensity the new intensity level
407      * @return a valid hex representation
408      * @throws IllegalArgumentException if controlUnit, zone or intensity are invalid
409      */
410     private String convertIntensity(int controlUnit, int zone, int intensity) {
411         validateControlUnit(controlUnit);
412         validateZone(zone);
413
414         if (intensity < 0 || intensity > 100) {
415             throw new IllegalArgumentException("Invalid intensity (must be between 0 and 100): " + intensity);
416         }
417
418         final boolean isShade = phCallback.isShade(controlUnit, zone);
419         if (isShade) {
420             if (intensity > 5) {
421                 throw new IllegalArgumentException("Invalid SHADE intensity (must be between 0 and 5): " + intensity);
422             }
423             return Integer.toString(intensity);
424         } else {
425             final String hexNbr = INTENSITY_MAP.get(intensity);
426             if (hexNbr == null) { // this should be impossible as all 100 values are in table
427                 logger.warn("Unknown zone intensity ({})", intensity);
428                 return Integer.toHexString(intensity).toUpperCase();
429             }
430             return hexNbr;
431         }
432     }
433
434     /**
435      * Converts a hex zone intensity back to a integer - handles shade zones as well
436      *
437      * @param controlUnit the control unit
438      * @param zone the zone
439      * @param intensity the hex intensity value
440      * @return the new intensity (between 0-100)
441      * @throws IllegalArgumentException if controlUnit, zone or intensity are invalid
442      */
443     private int convertIntensity(int controlUnit, int zone, String intensity) {
444         validateControlUnit(controlUnit);
445         validateZone(zone);
446
447         final boolean isShade = phCallback.isShade(controlUnit, zone);
448
449         if (isShade) {
450             final Integer intNbr = SHADE_INTENSITY_MAP.get(intensity);
451             if (intNbr == null) {
452                 logger.warn("Unknown shade intensity ({})", intensity);
453                 return Integer.parseInt(intensity, 16);
454             }
455             return intNbr;
456         } else {
457             final Integer intNbr = REVERSE_INTENSITY_MAP.get(intensity);
458             if (intNbr == null) {
459                 logger.warn("Unknown zone intensity ({})", intensity);
460                 return Integer.parseInt(intensity, 16);
461             }
462             zoneIntensities[zone] = intNbr;
463             return intNbr;
464         }
465     }
466
467     /**
468      * Selects a specific scene on a control unit
469      *
470      * @param controlUnit the control unit
471      * @param scene the new scene
472      * @throws IllegalArgumentException if controlUnit or scene are invalid
473      */
474     void selectScene(int controlUnit, int scene) {
475         validateControlUnit(controlUnit);
476         sendCommand(CMD_SCENE + convertScene(scene) + controlUnit);
477     }
478
479     /**
480      * Queries the interface for the current scene status on all control units
481      */
482     void refreshScene() {
483         sendCommand(CMD_SCENESTATUS);
484     }
485
486     /**
487      * Sets the scene locked/unlocked for the specific control unit
488      *
489      * @param controlUnit the control unit
490      * @param locked true for locked, false otherwise
491      * @throws IllegalArgumentException if controlUnit is invalid
492      */
493     void setSceneLock(int controlUnit, boolean locked) {
494         validateControlUnit(controlUnit);
495         sendCommand(CMD_SCENELOCK + (locked ? "+" : "-") + controlUnit);
496     }
497
498     /**
499      * Sets the scene sequence on/off for the specific control unit
500      *
501      * @param controlUnit the control unit
502      * @param on true for sequencing on, false otherwise
503      * @throws IllegalArgumentException if controlUnit is invalid
504      */
505     void setSceneSequence(int controlUnit, boolean on) {
506         validateControlUnit(controlUnit);
507         sendCommand(CMD_SCENESEQ + (on ? "+" : "-") + controlUnit);
508     }
509
510     /**
511      * Sets the zone locked/unlocked for the specific control unit
512      *
513      * @param controlUnit the control unit
514      * @param locked true for locked, false otherwise
515      * @throws IllegalArgumentException if controlUnit is invalid
516      */
517     void setZoneLock(int controlUnit, boolean locked) {
518         validateControlUnit(controlUnit);
519         sendCommand(CMD_ZONELOCK + (locked ? "+" : "-") + controlUnit);
520     }
521
522     /**
523      * Sets the zone to lowering for the specific control unit
524      *
525      * @param controlUnit the control unit
526      * @param zone the zone to lower
527      * @throws IllegalArgumentException if controlUnit or zone is invalid
528      */
529     void setZoneLower(int controlUnit, int zone) {
530         validateControlUnit(controlUnit);
531         validateZone(zone);
532         sendCommand(CMD_ZONELOWER + controlUnit + zone);
533     }
534
535     /**
536      * Stops the zone lowering on all control units
537      */
538     void setZoneLowerStop() {
539         sendCommand(CMD_ZONELOWERSTOP);
540     }
541
542     /**
543      * Sets the zone to raising for the specific control unit
544      *
545      * @param controlUnit the control unit
546      * @param zone the zone to raise
547      * @throws IllegalArgumentException if controlUnit or zone is invalid
548      */
549     void setZoneRaise(int controlUnit, int zone) {
550         validateControlUnit(controlUnit);
551         validateZone(zone);
552         sendCommand(CMD_ZONERAISE + controlUnit + zone);
553     }
554
555     /**
556      * Stops the zone raising on all control units
557      */
558     void setZoneRaiseStop() {
559         sendCommand(CMD_ZONERAISESTOP);
560     }
561
562     /**
563      * Sets the zone intensity up/down by 1 with the corresponding fade time on the specific zone/control unit. Does
564      * nothing if already at floor or ceiling. If the specified zone is a shade, does nothing.
565      *
566      * @param controlUnit the control unit
567      * @param zone the zone
568      * @param fade the fade time (0-59 seconds, 60-3600 seconds converted to minutes)
569      * @param increase true to increase by 1, false otherwise
570      * @throws IllegalArgumentException if controlUnit, zone or fade is invalid
571      */
572     void setZoneIntensity(int controlUnit, int zone, int fade, boolean increase) {
573         if (phCallback.isShade(controlUnit, zone)) {
574             return;
575         }
576
577         validateControlUnit(controlUnit);
578         validateZone(zone);
579
580         int newInt = zoneIntensities[zone] += (increase ? 1 : -1);
581         if (newInt < 0) {
582             newInt = 0;
583         }
584         if (newInt > 100) {
585             newInt = 100;
586         }
587
588         setZoneIntensity(controlUnit, zone, fade, newInt);
589     }
590
591     /**
592      * Sets the zone intensity to a specific number with the corresponding fade time on the specific zone/control unit.
593      * If a shade, only deals with intensities from 0 to 5 (stop, open close, preset 1, preset 2, preset 3).
594      *
595      * @param controlUnit the control unit
596      * @param zone the zone
597      * @param fade the fade time (0-59 seconds, 60-3600 seconds converted to minutes)
598      * @param increase true to increase by 1, false otherwise
599      * @throws IllegalArgumentException if controlUnit, zone, fade or intensity is invalid
600      */
601     void setZoneIntensity(int controlUnit, int zone, int fade, int intensity) {
602         validateControlUnit(controlUnit);
603         validateZone(zone);
604
605         final String hexFade = convertFade(fade);
606         final String hexIntensity = convertIntensity(controlUnit, zone, intensity);
607
608         final StringBuilder sb = new StringBuilder(16);
609         for (int z = 1; z <= 8; z++) {
610             sb.append(' ');
611             sb.append(zone == z ? hexIntensity : "*");
612         }
613
614         sendCommand(CMD_ZONEINTENSITY + " " + controlUnit + " " + hexFade + sb);
615     }
616
617     /**
618      * Refreshes the current zone intensities for the control unit
619      *
620      * @param controlUnit the control unit
621      * @throws IllegalArgumentException if control unit is invalid
622      */
623     void refreshZoneIntensity(int controlUnit) {
624         validateControlUnit(controlUnit);
625         sendCommand(CMD_ZONEINTENSITYSTATUS + " " + controlUnit);
626     }
627
628     /**
629      * Sets the time on the PRG interface
630      *
631      * @param calendar a non-null calendar to set the time to
632      * @throws NullArgumentException if calendar is null
633      */
634     void setTime(Calendar calendar) {
635         if (calendar == null) {
636             throw new NullArgumentException("calendar cannot be null");
637         }
638         final String cmd = String.format("%1 %2$tk %2$tM %2$tm %2$te %2ty %3", CMD_SETTIME, calendar,
639                 calendar.get(Calendar.DAY_OF_WEEK));
640         sendCommand(cmd);
641     }
642
643     /**
644      * Refreshes the time from the PRG interface
645      */
646     void refreshTime() {
647         sendCommand(CMD_READTIME);
648     }
649
650     /**
651      * Selects the specific schedule (0=none, 1=weekday, 2=weekend)
652      *
653      * @param schedule the new schedule
654      * @throws IllegalArgumentException if schedule is < 0 or > 32
655      */
656     void selectSchedule(int schedule) {
657         if (schedule < 0 || schedule > 2) {
658             throw new IllegalArgumentException("Schedule invalid (must be between 0 and 2): " + schedule);
659         }
660         sendCommand(CMD_SELECTSCHEDULE + " " + schedule);
661     }
662
663     /**
664      * Refreshes the current schedule
665      */
666     void refreshSchedule() {
667         sendCommand(CMD_REPORTSCHEDULE);
668     }
669
670     /**
671      * Refreshs the current sunrise/sunset
672      */
673     void refreshSunriseSunset() {
674         sendCommand(CMD_SUNRISESUNSET);
675     }
676
677     /**
678      * Starts the super sequence
679      */
680     void startSuperSequence() {
681         sendCommand(CMD_SUPERSEQUENCESTART);
682         reportSuperSequenceStatus();
683     }
684
685     /**
686      * Pauses the super sequence
687      */
688     void pauseSuperSequence() {
689         sendCommand(CMD_SUPERSEQUENCEPAUSE);
690     }
691
692     /**
693      * Resumes the super sequence
694      */
695     void resumeSuperSequence() {
696         sendCommand(CMD_SUPERSEQUENCERESUME);
697     }
698
699     /**
700      * Refreshes the status of the super sequence
701      */
702     void reportSuperSequenceStatus() {
703         sendCommand(CMD_SUPERSEQUENCESTATUS);
704     }
705
706     /**
707      * Sends the command and puts the thing into {@link ThingStatus#OFFLINE} if an IOException occurs
708      *
709      * @param command a non-null, non-empty command to send
710      * @throws IllegalArgumentException if command is null or empty
711      */
712     private void sendCommand(String command) {
713         if (command == null) {
714             throw new IllegalArgumentException("command cannot be null");
715         }
716         if (command.trim().length() == 0) {
717             throw new IllegalArgumentException("command cannot be empty");
718         }
719         try {
720             logger.debug("SendCommand: {}", command);
721             session.sendCommand(command);
722         } catch (IOException e) {
723             phCallback.statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
724                     "Exception occurred sending to PRG: " + e);
725         }
726     }
727
728     /**
729      * Handles a command failure - we simply log the response as an error (trying to convert the error number to a
730      * legible error message)
731      *
732      * @param resp the possibly null, possibly empty actual response
733      */
734     private void handleCommandFailure(Matcher m, String resp) {
735         if (m == null) {
736             throw new IllegalArgumentException("m (matcher) cannot be null");
737         }
738         if (m.groupCount() == 2) {
739             try {
740                 final int errorNbr = Integer.parseInt(m.group(1));
741                 String errorMsg = "ErrorCode: " + errorNbr;
742                 switch (errorNbr) {
743                     case 1: {
744                         errorMsg = "Control Unit Raise/Lower error";
745                         break;
746                     }
747                     case 2: {
748                         errorMsg = "Invalid scene selected";
749                         break;
750                     }
751                     case 6: {
752                         errorMsg = "Bad command was sent";
753                         break;
754                     }
755                     case 13: {
756                         errorMsg = "Not a timeclock unit (GRX-ATC or GRX-PRG)";
757                         break;
758                     }
759                     case 14: {
760                         errorMsg = "Illegal time was entered";
761                         break;
762                     }
763                     case 15: {
764                         errorMsg = "Invalid schedule";
765                         break;
766                     }
767                     case 16: {
768                         errorMsg = "No Super Sequence has been loaded";
769                         break;
770                     }
771                     case 20: {
772                         errorMsg = "Command was missing Control Units";
773                         break;
774                     }
775                     case 21: {
776                         errorMsg = "Command was missing data";
777                         break;
778                     }
779                     case 22: {
780                         errorMsg = "Error in command argument (improper hex value)";
781                         break;
782                     }
783                     case 24: {
784                         errorMsg = "Invalid Control Unit";
785                         break;
786                     }
787                     case 25: {
788                         errorMsg = "Invalid value, outside range of acceptable values";
789                         break;
790                     }
791                     case 26: {
792                         errorMsg = "Invalid Accessory Control";
793                         break;
794                     }
795                     case 31: {
796                         errorMsg = "Network address illegally formatted; 4 octets required (xxx.xxx.xxx.xxx)";
797                         break;
798                     }
799                     case 80: {
800                         errorMsg = "Time-out error, no response received";
801                         break;
802                     }
803                     case 100: {
804                         errorMsg = "Invalid Telnet login number";
805                         break;
806                     }
807                     case 101: {
808                         errorMsg = "Invalid Telnet login";
809                         break;
810                     }
811                     case 102: {
812                         errorMsg = "Telnet login name exceeds 8 characters";
813                         break;
814                     }
815                     case 103: {
816                         errorMsg = "INvalid number of arguments";
817                         break;
818                     }
819                     case 255: {
820                         errorMsg = "GRX-PRG must be in programming mode for specific commands";
821                         break;
822                     }
823                 }
824                 logger.error("Error response: {} ({})", errorMsg, errorNbr);
825             } catch (NumberFormatException e) {
826                 logger.error("Invalid failure response (can't parse error number): '{}'", resp);
827             }
828         } else {
829             logger.error("Invalid failure response: '{}'", resp);
830         }
831     }
832
833     /**
834      * Handles the scene status response
835      *
836      * @param m the non-null {@link Matcher} that matched the response
837      * @param resp the possibly null, possibly empty actual response
838      */
839     private void handleSceneStatus(Matcher m, String resp) {
840         if (m == null) {
841             throw new IllegalArgumentException("m (matcher) cannot be null");
842         }
843         if (m.groupCount() >= 2) {
844             try {
845                 final String sceneStatus = m.group(1);
846                 for (int i = 1; i <= 8; i++) {
847                     char status = sceneStatus.charAt(i - 1);
848                     if (status == 'M') {
849                         continue; // no control unit
850                     }
851
852                     int scene = VALID_SCENES.indexOf(status);
853                     if (scene < 0) {
854                         logger.warn("Unknown scene status returned for zone {}: {}", i, status);
855                     } else {
856                         phCallback.stateChanged(i, PrgConstants.CHANNEL_SCENE, new DecimalType(scene));
857                         refreshZoneIntensity(i); // request to get new zone intensities
858                     }
859                 }
860             } catch (NumberFormatException e) {
861                 logger.error("Invalid scene status (can't parse scene #): '{}'", resp);
862             }
863         } else {
864             logger.error("Invalid scene status response: '{}'", resp);
865         }
866     }
867
868     /**
869      * Handles the report time response
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 handleReportTime(Matcher m, String resp) {
875         if (m == null) {
876             throw new IllegalArgumentException("m (matcher) cannot be null");
877         }
878         if (m.groupCount() == 7) {
879             try {
880                 final Calendar c = Calendar.getInstance();
881                 c.set(Calendar.HOUR_OF_DAY, Integer.parseInt(m.group(1)));
882                 c.set(Calendar.MINUTE, Integer.parseInt(m.group(2)));
883                 c.set(Calendar.MONDAY, Integer.parseInt(m.group(3)));
884                 c.set(Calendar.DAY_OF_MONTH, Integer.parseInt(m.group(4)));
885
886                 final int yr = Integer.parseInt(m.group(5));
887                 c.set(Calendar.YEAR, yr + (yr < 50 ? 1900 : 2000));
888
889                 phCallback.stateChanged(PrgConstants.CHANNEL_TIMECLOCK,
890                         new DateTimeType(ZonedDateTime.ofInstant(c.toInstant(), ZoneId.systemDefault())));
891             } catch (NumberFormatException e) {
892                 logger.error("Invalid time response (can't parse number): '{}'", resp);
893             }
894         } else {
895             logger.error("Invalid time response: '{}'", resp);
896         }
897     }
898
899     /**
900      * Handles the report schedule response
901      *
902      * @param m the non-null {@link Matcher} that matched the response
903      * @param resp the possibly null, possibly empty actual response
904      */
905     private void handleReportSchedule(Matcher m, String resp) {
906         if (m == null) {
907             throw new IllegalArgumentException("m (matcher) cannot be null");
908         }
909         if (m.groupCount() == 2) {
910             try {
911                 int schedule = Integer.parseInt(m.group(1));
912                 phCallback.stateChanged(PrgConstants.CHANNEL_SCHEDULE, new DecimalType(schedule));
913             } catch (NumberFormatException e) {
914                 logger.error("Invalid schedule response (can't parse number): '{}'", resp);
915             }
916         } else {
917             logger.error("Invalid schedule volume response: '{}'", resp);
918         }
919     }
920
921     /**
922      * Handles the sunrise/sunset response
923      *
924      * @param m the non-null {@link Matcher} that matched the response
925      * @param resp the possibly null, possibly empty actual response
926      */
927     private void handleSunriseSunset(Matcher m, String resp) {
928         if (m == null) {
929             throw new IllegalArgumentException("m (matcher) cannot be null");
930         }
931         if (m.groupCount() == 5) {
932             if (m.group(1).equals("255")) {
933                 logger.warn("Sunrise/Sunset needs to be enabled via Liason Software");
934                 return;
935             }
936             try {
937                 final Calendar sunrise = Calendar.getInstance();
938                 sunrise.set(Calendar.HOUR_OF_DAY, Integer.parseInt(m.group(1)));
939                 sunrise.set(Calendar.MINUTE, Integer.parseInt(m.group(2)));
940                 phCallback.stateChanged(PrgConstants.CHANNEL_SUNRISE,
941                         new DateTimeType(ZonedDateTime.ofInstant(sunrise.toInstant(), ZoneId.systemDefault())));
942
943                 final Calendar sunset = Calendar.getInstance();
944                 sunset.set(Calendar.HOUR_OF_DAY, Integer.parseInt(m.group(3)));
945                 sunset.set(Calendar.MINUTE, Integer.parseInt(m.group(4)));
946                 phCallback.stateChanged(PrgConstants.CHANNEL_SUNSET,
947                         new DateTimeType(ZonedDateTime.ofInstant(sunset.toInstant(), ZoneId.systemDefault())));
948             } catch (NumberFormatException e) {
949                 logger.error("Invalid sunrise/sunset response (can't parse number): '{}'", resp);
950             }
951         } else {
952             logger.error("Invalid sunrise/sunset response: '{}'", resp);
953         }
954     }
955
956     /**
957      * Handles the super sequence response
958      *
959      * @param m the non-null {@link Matcher} that matched the response
960      * @param resp the possibly null, possibly empty actual response
961      */
962     private void handleSuperSequenceStatus(Matcher m, String resp) {
963         if (m == null) {
964             throw new IllegalArgumentException("m (matcher) cannot be null");
965         }
966         if (m.groupCount() == 5) {
967             try {
968                 final int nextStep = Integer.parseInt(m.group(2));
969                 final int nextMin = Integer.parseInt(m.group(3));
970                 final int nextSec = Integer.parseInt(m.group(4));
971                 phCallback.stateChanged(PrgConstants.CHANNEL_SUPERSEQUENCESTATUS, new StringType(m.group(1)));
972                 phCallback.stateChanged(PrgConstants.CHANNEL_SUPERSEQUENCENEXTSTEP, new DecimalType(nextStep));
973                 phCallback.stateChanged(PrgConstants.CHANNEL_SUPERSEQUENCENEXTMIN, new DecimalType(nextMin));
974                 phCallback.stateChanged(PrgConstants.CHANNEL_SUPERSEQUENCENEXTSEC, new DecimalType(nextSec));
975             } catch (NumberFormatException e) {
976                 logger.error("Invalid volume response (can't parse number): '{}'", resp);
977             }
978         } else {
979             logger.error("Invalid format volume response: '{}'", resp);
980         }
981     }
982
983     /**
984      * Handles the zone intensity response
985      *
986      * @param m the non-null {@link Matcher} that matched the response
987      * @param resp the possibly null, possibly empty actual response
988      */
989     private void handleZoneIntensity(Matcher m, String resp) {
990         if (m == null) {
991             throw new IllegalArgumentException("m (matcher) cannot be null");
992         }
993
994         if (m.groupCount() == 10) {
995             try {
996                 final int controlUnit = Integer.parseInt(m.group(1));
997                 for (int z = 1; z <= 8; z++) {
998                     final String zi = m.group(z + 1);
999                     if (zi.equals("*") || zi.equals(Integer.toString(z - 1))) {
1000                         continue; // not present
1001                     }
1002                     final int zid = convertIntensity(controlUnit, z, zi);
1003
1004                     phCallback.stateChanged(controlUnit, PrgConstants.CHANNEL_ZONEINTENSITY + z, new PercentType(zid));
1005                 }
1006             } catch (NumberFormatException e) {
1007                 logger.error("Invalid volume response (can't parse number): '{}'", resp);
1008             }
1009         } else {
1010             logger.error("Invalid format volume response: '{}'", resp);
1011         }
1012     }
1013
1014     /**
1015      * Handles the controller information response (currently not used).
1016      *
1017      * @param m the non-null {@link Matcher} that matched the response
1018      * @param resp the possibly null, possibly empty actual response
1019      */
1020     private void handleControlInfo(Matcher m, String resp) {
1021         if (m == null) {
1022             throw new IllegalArgumentException("m (matcher) cannot be null");
1023         }
1024         if (m.groupCount() == 9) {
1025             int controlUnit = 0;
1026             try {
1027                 controlUnit = Integer.parseInt(m.group(1));
1028
1029                 final String q4 = m.group(8);
1030                 final String q4bits = new StringBuilder(Integer.toBinaryString(Integer.parseInt(q4, 16))).reverse()
1031                         .toString();
1032                 // final boolean seqType = (q4bits.length() > 0 ? q4bits.charAt(0) : '0') == '1';
1033                 final boolean seqMode = (q4bits.length() > 1 ? q4bits.charAt(1) : '0') == '1';
1034                 final boolean zoneLock = (q4bits.length() > 2 ? q4bits.charAt(2) : '0') == '1';
1035                 final boolean sceneLock = (q4bits.length() > 3 ? q4bits.charAt(4) : '0') == '1';
1036
1037                 phCallback.stateChanged(controlUnit, PrgConstants.CHANNEL_SCENESEQ,
1038                         seqMode ? OnOffType.ON : OnOffType.OFF);
1039                 phCallback.stateChanged(controlUnit, PrgConstants.CHANNEL_SCENELOCK,
1040                         sceneLock ? OnOffType.ON : OnOffType.OFF);
1041                 phCallback.stateChanged(controlUnit, PrgConstants.CHANNEL_ZONELOCK,
1042                         zoneLock ? OnOffType.ON : OnOffType.OFF);
1043             } catch (NumberFormatException e) {
1044                 logger.error("Invalid controller information response: '{}'", resp);
1045             }
1046         } else {
1047             logger.error("Invalid controller information response: '{}'", resp);
1048         }
1049     }
1050
1051     /**
1052      * Handles the interface being reset
1053      *
1054      * @param m the non-null {@link Matcher} that matched the response
1055      * @param resp the possibly null, possibly empty actual response
1056      */
1057     private void handleResetting(Matcher m, String resp) {
1058         phCallback.statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.DUTY_CYCLE, "Device resetting");
1059     }
1060
1061     /**
1062      * Handles the button press response
1063      *
1064      * @param m the non-null {@link Matcher} that matched the response
1065      * @param resp the possibly null, possibly empty actual response
1066      */
1067     private void handleButton(Matcher m, String resp) {
1068         phCallback.stateChanged(PrgConstants.CHANNEL_BUTTONPRESS, new StringType(resp));
1069     }
1070
1071     /**
1072      * Handles an unknown response (simply logs it)
1073      *
1074      * @param resp the possibly null, possibly empty actual response
1075      */
1076     private void handleUnknownCommand(String response) {
1077         logger.info("Unhandled response: {}", response);
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 SocketSessionCallback {
1088
1089         @Override
1090         public void responseReceived(String response) {
1091             // logger.debug("Response received: " + response);
1092
1093             if (response == null || response.trim().length() == 0) {
1094                 return; // simple blank - do nothing
1095             }
1096
1097             Matcher m = RSP_OK.matcher(response);
1098             if (m.matches()) {
1099                 // logger.debug(response);
1100                 return; // nothing to do on an OK! response
1101             }
1102
1103             m = RSP_FAILED.matcher(response);
1104             if (m.matches()) {
1105                 handleCommandFailure(m, response);
1106                 return; // nothing really to do on an error response either
1107             }
1108
1109             m = RSP_SCENESTATUS.matcher(response);
1110             if (m.matches()) {
1111                 handleSceneStatus(m, response);
1112                 return;
1113             }
1114
1115             m = RSP_REPORTIME.matcher(response);
1116             if (m.matches()) {
1117                 handleReportTime(m, response);
1118                 return;
1119             }
1120
1121             m = RSP_REPORTSCHEDULE.matcher(response);
1122             if (m.matches()) {
1123                 handleReportSchedule(m, response);
1124                 return;
1125             }
1126
1127             m = RSP_SUNRISESUNSET.matcher(response);
1128             if (m.matches()) {
1129                 handleSunriseSunset(m, response);
1130                 return;
1131             }
1132
1133             m = RSP_SUPERSEQUENCESTATUS.matcher(response);
1134             if (m.matches()) {
1135                 handleSuperSequenceStatus(m, response);
1136                 return;
1137             }
1138
1139             m = RSP_ZONEINTENSITY.matcher(response);
1140             if (m.matches()) {
1141                 handleZoneIntensity(m, response);
1142                 return;
1143             }
1144
1145             m = RSP_RMU.matcher(response);
1146             if (m.matches()) {
1147                 handleControlInfo(m, response);
1148                 return;
1149             }
1150
1151             m = RSP_RESETTING.matcher(response);
1152             if (m.matches()) {
1153                 handleResetting(m, response);
1154                 return;
1155             }
1156
1157             m = RSP_BUTTON.matcher(response);
1158             if (m.matches()) {
1159                 handleButton(m, response);
1160                 return;
1161             }
1162
1163             if (RSP_CONNECTION_ESTABLISHED.equals(response)) {
1164                 return; // nothing to do on connection established
1165             }
1166
1167             handleUnknownCommand(response);
1168         }
1169
1170         @Override
1171         public void responseException(Exception exception) {
1172             phCallback.statusChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1173                     "Exception occurred reading from PRG: " + exception);
1174         }
1175     }
1176
1177     /**
1178      * Special callback used during the login process to not dispatch the responses to this class but rather give them
1179      * back at each call to {@link NoDispatchingCallback#getResponse()}
1180      *
1181      * @author Tim Roberts
1182      *
1183      */
1184     private class NoDispatchingCallback implements SocketSessionCallback {
1185
1186         /**
1187          * Cache of responses that have occurred
1188          */
1189         private BlockingQueue<Object> responses = new ArrayBlockingQueue<>(5);
1190
1191         /**
1192          * Will return the next response from {@link #responses}. If the response is an exception, that exception will
1193          * be thrown instead.
1194          *
1195          * @return a non-null, possibly empty response
1196          * @throws Exception an exception if one occurred during reading
1197          */
1198         String getResponse() throws Exception {
1199             final Object lastResponse = responses.poll(5, TimeUnit.SECONDS);
1200             if (lastResponse instanceof String) {
1201                 return (String) lastResponse;
1202             } else if (lastResponse instanceof Exception) {
1203                 throw (Exception) lastResponse;
1204             } else if (lastResponse == null) {
1205                 throw new Exception("Didn't receive response in time");
1206             } else {
1207                 return lastResponse.toString();
1208             }
1209         }
1210
1211         @Override
1212         public void responseReceived(String response) {
1213             try {
1214                 responses.put(response);
1215             } catch (InterruptedException e) {
1216             }
1217         }
1218
1219         @Override
1220         public void responseException(Exception e) {
1221             try {
1222                 responses.put(e);
1223             } catch (InterruptedException e1) {
1224             }
1225         }
1226     }
1227 }