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